Files
Pete/internal/web/adventure_digest.go
prosolis 4c671fb410 Adventure section: ingest, permalink, digest, emblems, noindex
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
2026-07-11 00:53:59 -07:00

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
}