Pete's side of the Adventure news feed. Receives structured game-event
facts from gogobee, templates them in Pete's warm-reporter voice, and
publishes to a new /adventure section + live Matrix posts.
- adventure.go: bearer ingest + fact-guard + 13 event templates;
/adventure/{guid} permalink (story.html); per-event SVG emblems at
/adventure/art/{type}.svg (card image + og:image); NoPush suppresses
the live Matrix post (cold-start backfill).
- adventure_digest.go: daily BULLETIN roundup at DigestHour (UTC);
unposted-in-48h = bulletins; marks them digested; per-day ?digest= URL
avoids canonical dedup.
- config AdventureConfig (enabled/ingest_token/channel/digest_hour);
web.New takes the seam + a priority poster; started in main.
- adventure theme colors; thumbURL passes through local emblem paths;
adventure pages are noindex (player-named; gap #5).
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
158 lines
5.3 KiB
Go
158 lines
5.3 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// The BULLETIN digest is the batched counterpart to the live PRIORITY beats.
|
|
// Once a day (Adventure.DigestHour, UTC) Pete collects the adventure dispatches
|
|
// that were seen since the last digest but never posted live — exactly the
|
|
// bulletins — and posts a single warm roundup to the adventure channel. The
|
|
// website already carries each one as its own card; the digest is the Matrix-only
|
|
// nudge so quiet-but-real activity surfaces without one ping per event.
|
|
|
|
const (
|
|
// digestWindow bounds how far back a digest looks. Wider than a day so a
|
|
// missed run (process down over a digest hour) still sweeps up the gap;
|
|
// re-collection is prevented by MarkAdventureDigested, not by the window.
|
|
digestWindow = 48 * time.Hour
|
|
// digestCap bounds a single digest. Far past the observed volume (a dormant
|
|
// community, per the voice spec); a runaway just truncates with a log line.
|
|
digestCap = 40
|
|
// digestPreview is how many headlines the roundup lede lists by name before
|
|
// collapsing the rest to "…and N more".
|
|
digestPreview = 4
|
|
)
|
|
|
|
// StartAdventureDigest launches the daily bulletin-digest loop. No-op unless the
|
|
// section is enabled AND there's a live Matrix poster AND a channel to post to —
|
|
// i.e. website-only and local modes never post a digest.
|
|
func (s *Server) StartAdventureDigest(ctx context.Context) {
|
|
if !s.adv.Enabled || s.advPost == nil || s.adv.Channel == "" {
|
|
return
|
|
}
|
|
go s.runAdventureDigest(ctx)
|
|
}
|
|
|
|
// runAdventureDigest sleeps until the next DigestHour, posts, then repeats every
|
|
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
|
|
// digest at a predictable time of day across restarts.
|
|
func (s *Server) runAdventureDigest(ctx context.Context) {
|
|
slog.Info("web: adventure digest scheduler started", "digest_hour_utc", s.adv.DigestHour, "channel", s.adv.Channel)
|
|
for {
|
|
wait := durUntilNextHour(time.Now().UTC(), s.adv.DigestHour)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(wait):
|
|
s.postDailyDigest(time.Now().UTC())
|
|
}
|
|
}
|
|
}
|
|
|
|
// durUntilNextHour returns the duration from now to the next occurrence of the
|
|
// given UTC hour. If it's exactly the hour now, it targets tomorrow so a restart
|
|
// at the hour doesn't double-fire.
|
|
func durUntilNextHour(now time.Time, hour int) time.Duration {
|
|
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
|
|
if !next.After(now) {
|
|
next = next.Add(24 * time.Hour)
|
|
}
|
|
return next.Sub(now)
|
|
}
|
|
|
|
// postDailyDigest collects the window's un-posted bulletins, posts one roundup,
|
|
// and marks them digested so they don't recur. Silent when there's nothing new —
|
|
// a dormant realm should stay quiet, not ship an empty digest.
|
|
func (s *Server) postDailyDigest(now time.Time) {
|
|
since := now.Add(-digestWindow).Unix()
|
|
bulletins, err := storage.UnpostedAdventureSince(since, digestCap+1)
|
|
if err != nil {
|
|
slog.Error("adventure digest: query failed", "err", err)
|
|
return
|
|
}
|
|
if len(bulletins) == 0 {
|
|
return
|
|
}
|
|
truncated := false
|
|
if len(bulletins) > digestCap {
|
|
bulletins = bulletins[:digestCap]
|
|
truncated = true
|
|
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap)
|
|
}
|
|
|
|
date := now.Format("2006-01-02")
|
|
eventID := "adv-digest:" + date
|
|
headline, lede := buildDigest(bulletins, truncated)
|
|
|
|
s.advPost(AdvPost{
|
|
GUID: eventID,
|
|
Headline: headline,
|
|
Lede: lede,
|
|
ArticleURL: s.digestURL(date),
|
|
Source: advSource,
|
|
Channel: s.adv.Channel,
|
|
})
|
|
|
|
guids := make([]string, len(bulletins))
|
|
for i, b := range bulletins {
|
|
guids[i] = b.GUID
|
|
}
|
|
storage.MarkAdventureDigested(guids, eventID)
|
|
slog.Info("adventure digest: posted", "date", date, "items", len(bulletins))
|
|
}
|
|
|
|
// buildDigest renders the roundup headline + lede in Pete's warm reporter voice.
|
|
// Headlines come from stored, fact-guarded rows, so no player-controlled text is
|
|
// introduced here.
|
|
func buildDigest(bulletins []storage.Story, truncated bool) (headline, lede string) {
|
|
n := len(bulletins)
|
|
if n == 1 {
|
|
return "Today in the realm: one dispatch.",
|
|
fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline))
|
|
}
|
|
headline = fmt.Sprintf("Today in the realm: %d dispatches.", n)
|
|
|
|
shown := bulletins
|
|
if len(shown) > digestPreview {
|
|
shown = shown[:digestPreview]
|
|
}
|
|
parts := make([]string, len(shown))
|
|
for i, b := range shown {
|
|
parts[i] = trimHeadline(b.Headline)
|
|
}
|
|
list := strings.Join(parts, " ")
|
|
more := ""
|
|
if n > len(shown) || truncated {
|
|
more = fmt.Sprintf(" …and %d more.", n-len(shown))
|
|
}
|
|
return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more)
|
|
}
|
|
|
|
// trimHeadline ensures a headline ends with sentence punctuation so the joined
|
|
// lede reads as prose rather than a run-on.
|
|
func trimHeadline(h string) string {
|
|
h = strings.TrimSpace(h)
|
|
if h == "" {
|
|
return ""
|
|
}
|
|
if last := h[len(h)-1]; last != '.' && last != '!' && last != '?' {
|
|
h += "."
|
|
}
|
|
return h
|
|
}
|
|
|
|
// digestURL points the roundup at the section, with a per-day query param so the
|
|
// canonical-URL dedup treats each day's digest as distinct (it strips only
|
|
// tracking params, keeping ?digest=).
|
|
func (s *Server) digestURL(date string) string {
|
|
base := strings.TrimRight(s.cfg.BaseURL, "/")
|
|
return base + "/adventure?digest=" + date
|
|
}
|