From 4c671fb410abe4a42864dced2977d95f7b283422 Mon Sep 17 00:00:00 2001
From: prosolis <5590409+prosolis@users.noreply.github.com>
Date: Sat, 11 Jul 2026 00:53:59 -0700
Subject: [PATCH 1/3] 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
---
internal/config/config.go | 46 +++-
internal/storage/queries.go | 43 +++-
internal/web/adventure.go | 370 +++++++++++++++++++++++++++++
internal/web/adventure_digest.go | 157 ++++++++++++
internal/web/adventure_test.go | 289 ++++++++++++++++++++++
internal/web/feed_test.go | 2 +-
internal/web/handlers.go | 5 +
internal/web/reader_test.go | 2 +-
internal/web/server.go | 22 +-
internal/web/static/css/input.css | 5 +
internal/web/static/css/output.css | 2 +-
internal/web/status_render_test.go | 2 +-
internal/web/templates/layout.html | 2 +
internal/web/templates/story.html | 27 +++
internal/web/thumbs.go | 6 +
internal/web/trending_test.go | 2 +-
main.go | 22 +-
17 files changed, 986 insertions(+), 18 deletions(-)
create mode 100644 internal/web/adventure.go
create mode 100644 internal/web/adventure_digest.go
create mode 100644 internal/web/adventure_test.go
create mode 100644 internal/web/templates/story.html
diff --git a/internal/config/config.go b/internal/config/config.go
index daeeefc..ab56435 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -13,11 +13,29 @@ import (
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
type Config struct {
- Matrix MatrixConfig `toml:"matrix"`
- Posting PostingConfig `toml:"posting"`
- Storage StorageConfig `toml:"storage"`
- Web WebConfig `toml:"web"`
- Sources []SourceConfig `toml:"sources"`
+ Matrix MatrixConfig `toml:"matrix"`
+ Posting PostingConfig `toml:"posting"`
+ Storage StorageConfig `toml:"storage"`
+ Web WebConfig `toml:"web"`
+ Adventure AdventureConfig `toml:"adventure"`
+ Sources []SourceConfig `toml:"sources"`
+}
+
+// AdventureConfig wires the gogobee adventure-news seam: gogobee POSTs
+// game-event facts to Pete's ingest endpoint, Pete templates them into stories
+// on the /adventure section and posts PRIORITY beats live to Matrix. Disabled by
+// default โ the endpoint 404s and no adventure channel appears until enabled.
+type AdventureConfig struct {
+ Enabled bool `toml:"enabled"`
+ // IngestToken is the shared bearer secret gogobee presents on
+ // POST /api/ingest/adventure. Required when enabled. Use ${ENV_VAR}.
+ IngestToken string `toml:"ingest_token"`
+ // Channel is the Matrix channel name (a key in [matrix.channels]) that
+ // PRIORITY adventure beats post to live. If it isn't a configured Matrix
+ // channel, adventure runs website-only (stories still appear on /adventure).
+ Channel string `toml:"channel"`
+ // DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default 17.
+ DigestHour int `toml:"digest_hour"`
}
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
@@ -228,6 +246,21 @@ func (c *Config) validate() error {
}
}
+ if c.Adventure.Enabled {
+ if c.Adventure.IngestToken == "" {
+ return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
+ }
+ if c.Adventure.DigestHour < 0 || c.Adventure.DigestHour > 23 {
+ return fmt.Errorf("adventure.digest_hour must be 0-23")
+ }
+ if c.Adventure.Channel != "" {
+ if _, ok := c.Matrix.Channels[c.Adventure.Channel]; !ok {
+ slog.Warn("adventure.channel is not a configured Matrix channel โ adventure runs website-only",
+ "channel", c.Adventure.Channel)
+ }
+ }
+ }
+
for i, s := range c.Sources {
if s.Name == "" {
return fmt.Errorf("sources[%d].name is required", i)
@@ -299,6 +332,9 @@ func (c *Config) applyDefaults() {
if c.Web.Push.MinStories == 0 {
c.Web.Push.MinStories = 3
}
+ if c.Adventure.DigestHour == 0 {
+ c.Adventure.DigestHour = 17
+ }
for i := range c.Sources {
if c.Sources[i].PollIntervalMinutes == 0 {
c.Sources[i].PollIntervalMinutes = 20
diff --git a/internal/storage/queries.go b/internal/storage/queries.go
index cdd41d0..fbf3533 100644
--- a/internal/storage/queries.go
+++ b/internal/storage/queries.go
@@ -132,10 +132,10 @@ func MarkClassified(guid, channel, platforms string) {
// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
func GetStoryByGUID(guid string) (*Story, error) {
row := Get().QueryRow(
- `SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
+ `SELECT guid, headline, lede, COALESCE(content, ''), image_url, article_url, source, platforms, channel, seen_at
FROM stories WHERE guid = ?`, guid)
var s Story
- if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
+ if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.Content, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
return nil, err
}
return &s, nil
@@ -252,6 +252,45 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
return &s, nil
}
+// UnpostedAdventureSince returns adventure dispatches seen at or after `since`
+// that have never been posted to Matrix (no post_log row). PRIORITY beats post
+// live and so carry a post_log row; the ones left are exactly the BULLETINs the
+// daily digest collects. Oldest first so the digest reads chronologically.
+func UnpostedAdventureSince(since int64, limit int) ([]Story, error) {
+ rows, err := Get().Query(
+ `SELECT guid, headline, lede, article_url, seen_at
+ FROM stories
+ WHERE classified = 1
+ AND channel = 'adventure'
+ AND seen_at >= ?
+ AND guid NOT IN (SELECT guid FROM post_log)
+ ORDER BY seen_at ASC
+ LIMIT ?`, since, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Story
+ for rows.Next() {
+ var s Story
+ if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil {
+ return nil, err
+ }
+ out = append(out, s)
+ }
+ return out, rows.Err()
+}
+
+// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
+// event) so the next daily digest doesn't re-collect it. Idempotent per guid via
+// the post_log OR IGNORE. eventID is the digest's synthetic key (e.g.
+// "adv-digest:2026-07-11") shared by every story that went out in that digest.
+func MarkAdventureDigested(guids []string, eventID string) {
+ for _, g := range guids {
+ InsertPostLog(g, "adventure", eventID, "", false)
+ }
+}
+
// ListClassifiedByChannel returns up to `limit` classified stories routed to a
// real channel, newest first, with optional offset for pagination. Sentinel
// channels (_discarded, _duplicate) are excluded.
diff --git a/internal/web/adventure.go b/internal/web/adventure.go
new file mode 100644
index 0000000..3da3965
--- /dev/null
+++ b/internal/web/adventure.go
@@ -0,0 +1,370 @@
+package web
+
+import (
+ "crypto/subtle"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "pete/internal/storage"
+)
+
+// AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract
+// in pete_adventure_news_voice.md. Names are character names only (never Matrix
+// handles) and Actors is the allow-list of the only names permitted to appear in
+// rendered output.
+type AdvFact struct {
+ GUID string `json:"guid"`
+ EventType string `json:"event_type"`
+ Tier string `json:"tier"` // "priority" | "bulletin"
+ Actors []string `json:"actors"`
+ Subject string `json:"subject"`
+ Opponent string `json:"opponent"`
+ Boss string `json:"boss"`
+ Zone string `json:"zone"`
+ Region string `json:"region"`
+ Level int `json:"level"`
+ Count int `json:"count"`
+ Outcome string `json:"outcome"`
+ Stakes string `json:"stakes"`
+ ClassRace string `json:"class_race"`
+ Milestone string `json:"milestone"`
+ OccurredAt int64 `json:"occurred_at"`
+ NoPush bool `json:"no_push"`
+}
+
+// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
+// web-local so the web package needs no dependency on internal/poster.
+type AdvPost struct {
+ GUID string
+ Headline string
+ Lede string
+ ImageURL string
+ ArticleURL string
+ Source string
+ Channel string
+}
+
+// PriorityPoster posts a priority adventure item to Matrix immediately. main
+// adapts *poster.Queue.PostNow to this; nil in web-only/local modes.
+type PriorityPoster func(AdvPost)
+
+const advSource = "Pete"
+
+// handleAdventureIngest receives a game-event fact from gogobee, templates it
+// into a deterministic story, publishes it to the /adventure section, and posts
+// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
+func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
+ if !s.adv.Enabled {
+ http.NotFound(w, r)
+ return
+ }
+ if !s.bearerOK(r) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ var f AdvFact
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil {
+ http.Error(w, "bad json", http.StatusBadRequest)
+ return
+ }
+ if f.GUID == "" || f.EventType == "" {
+ http.Error(w, "guid and event_type are required", http.StatusBadRequest)
+ return
+ }
+ // Fact-guard: any player name we render must be in the actors allow-list.
+ // gogobee pre-sanitizes, but Pete never trusts the channel โ this is the
+ // last line before a name reaches a public page.
+ if !factGuard(f) {
+ slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType)
+ http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest)
+ return
+ }
+
+ headline, lede, ok := renderAdventure(f)
+ if !ok {
+ http.Error(w, "unknown event_type", http.StatusBadRequest)
+ return
+ }
+
+ // Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
+ if storage.IsGUIDSeen(f.GUID) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("duplicate"))
+ return
+ }
+
+ articleURL := s.advPermalink(f.GUID)
+ imageURL := advArtURL(f.EventType)
+ if err := storage.InsertStory(&storage.Story{
+ GUID: f.GUID,
+ Headline: headline,
+ Lede: lede,
+ ImageURL: imageURL,
+ ArticleURL: articleURL,
+ Source: advSource,
+ Channel: "adventure",
+ Classified: true,
+ SeenAt: f.OccurredAt,
+ PublishedAt: f.OccurredAt,
+ }); err != nil {
+ slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
+ http.Error(w, "insert failed", http.StatusInternalServerError)
+ return
+ }
+ slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
+
+ // PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
+ // digest. Website section always gets the row above regardless of tier.
+ // NoPush (cold-start backfill) suppresses the live post so a launch doesn't
+ // dump the whole back-catalogue into the channel โ the stories still land on
+ // the website with their backdated timestamps.
+ if f.Tier == "priority" && !f.NoPush && s.advPost != nil && s.adv.Channel != "" {
+ // No ImageURL: the emblem is an SVG (Matrix clients often block SVG
+ // media), and the link's og:image carries the preview instead.
+ s.advPost(AdvPost{
+ GUID: f.GUID,
+ Headline: headline,
+ Lede: lede,
+ ArticleURL: articleURL,
+ Source: advSource,
+ Channel: s.adv.Channel,
+ })
+ }
+
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+}
+
+// advEventMeta maps an event_type to a short display label and emoji used by the
+// permalink page (and, later, OG art selection). Unknown types fall back to a
+// neutral dispatch label so a new gogobee event never renders blank.
+func advEventMeta(eventType string) (label, emoji string) {
+ switch eventType {
+ case "siege_start", "siege_win", "siege_loss":
+ return "The Siege", "๐ฐ"
+ case "boss_first", "boss_kill":
+ return "Boss down", "๐"
+ case "zone_first":
+ return "First clear", "๐บ๏ธ"
+ case "death":
+ return "In memoriam", "๐ชฆ"
+ case "arrival":
+ return "New arrival", "๐"
+ case "standings", "rival_result":
+ return "The rival board", "โ๏ธ"
+ case "pete_duel_loss", "pete_duel_win":
+ return "Pete's duels", "๐ค"
+ case "milestone":
+ return "Milestone", "๐ "
+ }
+ return "Dispatch", "๐ฃ"
+}
+
+// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
+// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
+// the external-image thumbnailer.
+func advArtURL(eventType string) string {
+ return "/adventure/art/" + eventType + ".svg"
+}
+
+// handleAdventureArt renders the themed emblem for an event type โ an adventure
+// gradient with the event's emoji and label. Deterministic and dependency-free
+// (no external asset), so every dispatch card has visual identity instead of the
+// blank placeholder that made the section look broken next to RSS cards.
+func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
+ if !s.adv.Enabled {
+ http.NotFound(w, r)
+ return
+ }
+ eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
+ label, emoji := advEventMeta(eventType)
+ w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ _, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
+}
+
+// advArtSVG is the emblem template: %s = emoji, %s = label. 1200ร630 (the OG
+// card ratio) so the same image works as a link-preview image.
+const advArtSVG = ``
+
+// advStoryPage is the per-story permalink view. It reuses the shared layout so a
+// dispatch reads like the rest of the site, with an adventure-themed hero.
+type advStoryPage struct {
+ pageData
+ EventLabel string
+ Emoji string
+ Headline string
+ Body string
+ Region string
+ When string
+ Permalink string
+}
+
+// handleAdventureStory serves the server-rendered permalink for one dispatch
+// (the article_url every ingested story points at). Public and cacheable; 404s
+// when the section is disabled or the guid is unknown. Character names in the
+// stored headline/body already passed the ingest fact-guard, so nothing
+// player-controlled reaches here unchecked.
+func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
+ if !s.adv.Enabled {
+ http.NotFound(w, r)
+ return
+ }
+ guid := r.PathValue("guid")
+ st, err := storage.GetStoryByGUID(guid)
+ if err != nil || st == nil || st.Channel != "adventure" {
+ http.NotFound(w, r)
+ return
+ }
+ s.track(r, "adventure")
+
+ // event_type is encoded in the guid prefix (e.g. "death::"); fall
+ // back to the neutral dispatch meta when it isn't a known type.
+ eventType, _, _ := strings.Cut(guid, ":")
+ label, emoji := advEventMeta(eventType)
+
+ body := st.Content
+ if strings.TrimSpace(body) == "" {
+ body = st.Lede // template-only dispatches carry the write-up in the lede
+ }
+
+ base := s.base(r)
+ base.Active = "adventure"
+ base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
+ if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
+ base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
+ }
+ s.render(w, "story", advStoryPage{
+ pageData: base,
+ EventLabel: label,
+ Emoji: emoji,
+ Headline: st.Headline,
+ Body: body,
+ Region: "", // reserved: region isn't stored on the row yet
+ When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
+ Permalink: s.advPermalink(guid),
+ })
+}
+
+// bearerOK checks the Authorization: Bearer header against the configured ingest
+// token in constant time.
+func (s *Server) bearerOK(r *http.Request) bool {
+ const prefix = "Bearer "
+ h := r.Header.Get("Authorization")
+ if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" {
+ return false
+ }
+ got := strings.TrimPrefix(h, prefix)
+ return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1
+}
+
+// advPermalink builds the per-story Pete permalink used as article_url (the card
+// link + Matrix link). Absolute when BaseURL is configured (required for the
+// Matrix link to survive safeHref); relative otherwise (fine on-site).
+func (s *Server) advPermalink(guid string) string {
+ if base := strings.TrimRight(s.cfg.BaseURL, "/"); base != "" {
+ return base + "/adventure/" + guid
+ }
+ return "/adventure/" + guid
+}
+
+// factGuard verifies every player-name field we might render is present in the
+// actors allow-list. Boss/zone/region/milestone are game-authored content, not
+// player-controlled, so they are not guarded.
+func factGuard(f AdvFact) bool {
+ allow := make(map[string]bool, len(f.Actors))
+ for _, a := range f.Actors {
+ if a != "" {
+ allow[a] = true
+ }
+ }
+ if f.Subject != "" && !allow[f.Subject] {
+ return false
+ }
+ if f.Opponent != "" && !allow[f.Opponent] {
+ return false
+ }
+ return true
+}
+
+// renderAdventure returns the deterministic headline + lede for a fact. Copied
+// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only โ
+// no LLM โ so output is safe and reproducible. ok is false for an unknown type.
+func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
+ atLevel := ""
+ if f.Level > 0 {
+ atLevel = fmt.Sprintf(", at level %d", f.Level)
+ }
+ switch f.EventType {
+ case "siege_start":
+ return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss),
+ fmt.Sprintf("Folks, this is the big one โ %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true
+ case "siege_win":
+ return fmt.Sprintf("The town holds! %s turned back.", f.Boss),
+ fmt.Sprintf("What a turnout โ %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true
+ case "siege_loss":
+ return fmt.Sprintf("Heavy news: %s broke through.", f.Boss),
+ fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time โ heads up, everyone.", f.Boss), true
+ case "boss_first":
+ return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss),
+ fmt.Sprintf("History in %s today โ %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true
+ case "boss_kill":
+ return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss),
+ fmt.Sprintf("Another clean run in %s today. Routine for %s by now โ but still worth a nod.", f.Zone, f.Subject), true
+ case "zone_first":
+ // Realm-first (priority) vs personal (bulletin) share a lede.
+ if f.Tier == "priority" {
+ headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone)
+ } else {
+ headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone)
+ }
+ inRegion := ""
+ if f.Region != "" {
+ inRegion = " in " + f.Region
+ }
+ return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
+ case "death":
+ return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
+ fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
+ case "arrival":
+ return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
+ fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
+ case "standings":
+ return "The rival board's been shaken up.",
+ fmt.Sprintf("%s is on the move โ here's where the standings sit today.", f.Subject), true
+ case "rival_result":
+ return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent),
+ fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true
+ case "pete_duel_loss":
+ if f.Tier == "priority" {
+ headline = fmt.Sprintf("You got me, %s.", f.Subject)
+ } else {
+ headline = fmt.Sprintf("%s got the better of me again.", f.Subject)
+ }
+ return headline, fmt.Sprintf("Credit where it's due โ %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true
+ case "pete_duel_win":
+ return fmt.Sprintf("Held my ground against %s today.", f.Subject),
+ fmt.Sprintf("Closer than the record will show, honestly โ %s pushed me. Rematch whenever you like.", f.Subject), true
+ case "milestone":
+ return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
+ fmt.Sprintf("One for the books โ %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
+ }
+ return "", "", false
+}
diff --git a/internal/web/adventure_digest.go b/internal/web/adventure_digest.go
new file mode 100644
index 0000000..b433d5e
--- /dev/null
+++ b/internal/web/adventure_digest.go
@@ -0,0 +1,157 @@
+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
+}
diff --git a/internal/web/adventure_test.go b/internal/web/adventure_test.go
new file mode 100644
index 0000000..2ade317
--- /dev/null
+++ b/internal/web/adventure_test.go
@@ -0,0 +1,289 @@
+package web
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "pete/internal/config"
+ "pete/internal/storage"
+)
+
+// newAdvServer builds a web server with the adventure seam enabled and a
+// capturing priority poster, backed by a fresh temp DB.
+func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
+ t.Helper()
+ storage.Close()
+ if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { storage.Close() })
+
+ var posted []AdvPost
+ adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
+ // Mirror the production poster's side effect: queue.PostNow records a
+ // post_log row, which is exactly what marks a beat "posted" and thus
+ // excludes it from the bulletin digest.
+ poster := func(p AdvPost) {
+ posted = append(posted, p)
+ storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
+ }
+ s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
+ nil, true, adv, poster)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s, &posted
+}
+
+func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
+ t.Helper()
+ body, _ := json.Marshal(f)
+ req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rw := httptest.NewRecorder()
+ s.handleAdventureIngest(rw, req)
+ return rw
+}
+
+// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
+// bearer-accepted, templated, stored as an adventure story, and posted live.
+func TestAdventureIngestEndToEnd(t *testing.T) {
+ const token = "s3cret-token"
+ s, posted := newAdvServer(t, token)
+
+ f := AdvFact{
+ GUID: "death:abc:1000", EventType: "death", Tier: "priority",
+ Actors: []string{"Brannigan"}, Subject: "Brannigan",
+ Zone: "the Underforge", Level: 14, OccurredAt: 1000,
+ }
+ if rw := postFact(t, s, token, f); rw.Code != 200 {
+ t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
+ }
+
+ got, err := storage.GetStoryByGUID("death:abc:1000")
+ if err != nil || got == nil {
+ t.Fatalf("story not stored: %v", err)
+ }
+ if got.Channel != "adventure" || got.Source != advSource {
+ t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
+ }
+ if got.Headline != "We lost Brannigan in the Underforge." {
+ t.Errorf("headline = %q", got.Headline)
+ }
+ if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
+ t.Errorf("article_url = %q", got.ArticleURL)
+ }
+ if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
+ t.Fatalf("priority post not delivered: %+v", *posted)
+ }
+
+ // Idempotent re-delivery: no error, no second post.
+ if rw := postFact(t, s, token, f); rw.Code != 200 {
+ t.Fatalf("dup ingest status = %d", rw.Code)
+ }
+ if len(*posted) != 1 {
+ t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
+ }
+}
+
+// TestAdventurePermalink renders the per-story page the article_url points at:
+// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
+// and body, and an unknown guid must 404.
+func TestAdventurePermalink(t *testing.T) {
+ const token = "t"
+ s, _ := newAdvServer(t, token)
+ f := AdvFact{
+ GUID: "death:abc:1000", EventType: "death", Tier: "priority",
+ Actors: []string{"Brannigan"}, Subject: "Brannigan",
+ Zone: "the Underforge", Level: 14, OccurredAt: 1000,
+ }
+ if rw := postFact(t, s, token, f); rw.Code != 200 {
+ t.Fatalf("ingest status = %d", rw.Code)
+ }
+
+ req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
+ req.SetPathValue("guid", "death:abc:1000")
+ rw := httptest.NewRecorder()
+ s.handleAdventureStory(rw, req)
+ if rw.Code != 200 {
+ t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
+ }
+ body := rw.Body.String()
+ if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
+ t.Errorf("permalink missing headline; body=%s", body)
+ }
+ if !bytes.Contains([]byte(body), []byte("In memoriam")) {
+ t.Errorf("permalink missing event label; body=%s", body)
+ }
+
+ // Unknown guid 404s.
+ req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
+ req2.SetPathValue("guid", "nope:1")
+ rw2 := httptest.NewRecorder()
+ s.handleAdventureStory(rw2, req2)
+ if rw2.Code != 404 {
+ t.Errorf("unknown guid status = %d, want 404", rw2.Code)
+ }
+}
+
+// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
+// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
+func TestDurUntilNextHour(t *testing.T) {
+ // 14:30 UTC, targeting 17:00 โ 2h30m today.
+ now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
+ if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
+ t.Errorf("before hour: got %v", got)
+ }
+ // 17:00 exactly โ tomorrow's 17:00 (24h), not zero.
+ now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
+ if got := durUntilNextHour(now, 17); got != 24*time.Hour {
+ t.Errorf("at hour: got %v", got)
+ }
+ // 20:00, targeting 17:00 โ tomorrow, 21h.
+ now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
+ if got := durUntilNextHour(now, 17); got != 21*time.Hour {
+ t.Errorf("after hour: got %v", got)
+ }
+}
+
+// TestAdventureDigest covers the batched path: bulletins (no live post) are
+// collected into one roundup, priority beats are excluded (they already posted),
+// digested bulletins don't recur, and an empty window stays silent.
+func TestAdventureDigest(t *testing.T) {
+ const token = "t"
+ s, posted := newAdvServer(t, token)
+ now := time.Now()
+
+ // Two bulletins + one priority (which posts live and must be excluded).
+ postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
+ Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
+ postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
+ Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
+ postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
+ Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
+ if len(*posted) != 1 {
+ t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
+ }
+
+ s.postDailyDigest(now.UTC())
+ if len(*posted) != 2 {
+ t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
+ }
+ dg := (*posted)[1]
+ if !strings.Contains(dg.Headline, "2 dispatches") {
+ t.Errorf("digest headline = %q", dg.Headline)
+ }
+ if !strings.HasPrefix(dg.GUID, "adv-digest:") {
+ t.Errorf("digest guid = %q", dg.GUID)
+ }
+
+ // Re-running finds nothing new (bulletins marked digested).
+ s.postDailyDigest(now.UTC())
+ if len(*posted) != 2 {
+ t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
+ }
+}
+
+// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
+// returns a themed SVG, ingested cards carry its local path, and the permalink
+// page is noindex with an og:image.
+func TestAdventureArtAndMeta(t *testing.T) {
+ const token = "t"
+ s, _ := newAdvServer(t, token)
+
+ // Emblem endpoint returns SVG with the event's emoji.
+ areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
+ areq.SetPathValue("type", "death.svg")
+ arw := httptest.NewRecorder()
+ s.handleAdventureArt(arw, areq)
+ if arw.Code != 200 {
+ t.Fatalf("art status = %d", arw.Code)
+ }
+ if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
+ t.Errorf("art content-type = %q", ct)
+ }
+ if b := arw.Body.String(); !strings.Contains(b, "๐ชฆ") || !strings.Contains(b, "