gogobee announces treasure finds, duels, mischief contracts and the Siege to the games room in TwinBee's voice, and files the same moments here as facts. Both land in the same room, so the realm heard every one of them twice, in two voices, minutes apart. Hold those types back from Matrix. They are stored and published on the site exactly as before, and the push alerts still fire; only the live priority beat and the digest sweep skip them, retired against the digest the same way a no_push backfill is. Types with no room announce behind them (zone clears, arrivals, deaths, milestones) are untouched and stay Pete's to report. room_silent_types overrides the default set; an explicit empty list turns the suppression off.
765 lines
32 KiB
Go
765 lines
32 KiB
Go
package web
|
||
|
||
import (
|
||
"crypto/subtle"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"unicode"
|
||
|
||
"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"`
|
||
// RunID names the expedition this dispatch is the ending of, on the three
|
||
// event types that are one (a clear, a retreat, a death). It is what lets the
|
||
// permalink offer the run's own report — the log, the numbers, the moment it
|
||
// turned — instead of leaving a paragraph about an outcome with no way back to
|
||
// what produced it. Empty on every other fact.
|
||
RunID string `json:"run_id,omitempty"`
|
||
// Headline/Lede are gogobee's LLM-authored prose, both optional. When present
|
||
// and past the prose-guard they replace the template render; otherwise Pete
|
||
// falls back to renderAdventure. gogobee is compute here, Pete is the editor:
|
||
// the templates are no longer the renderer, they are the safety net. See
|
||
// proseGuard and adventure_expansion_spec.md §2.
|
||
Headline string `json:"headline,omitempty"`
|
||
Lede string `json:"lede,omitempty"`
|
||
}
|
||
|
||
// 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"
|
||
|
||
// advBackfillEvent is the synthetic post_log event id used to retire a no_push
|
||
// (cold-start backfill) dispatch against the daily digest. It never went to
|
||
// Matrix; the row exists only so the digest skips it.
|
||
const advBackfillEvent = "adv-backfill"
|
||
|
||
// advRoomSilentEvent is the synthetic post_log event id used to retire a
|
||
// dispatch whose event type gogobee announces in the games room itself. Like
|
||
// advBackfillEvent it never went to Matrix; the row exists so the digest skips
|
||
// it, and the distinct id keeps "TwinBee said it" separable from "backfilled"
|
||
// when reading post_log later.
|
||
const advRoomSilentEvent = "adv-room-silent"
|
||
|
||
// 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
|
||
}
|
||
|
||
// Render the template first. It is the fallback for every fact whose LLM
|
||
// prose is absent or fails the guard.
|
||
//
|
||
// An event type Pete has no template for is NOT an error. It used to be a 400,
|
||
// and that was the wrong call: gogobee retries a 400 to its cap and then parks
|
||
// the dispatch forever, so the only thing the rejection accomplished was
|
||
// deleting a real game event that Pete simply hadn't learned to phrase yet.
|
||
// `companion_hire` was dropped that way from the day it shipped, and the
|
||
// mitigation on the books ("always deploy Pete first") is a rule a human has
|
||
// to remember rather than a property of the system.
|
||
//
|
||
// So: an unknown type publishes on the neutral fallback and is counted for the
|
||
// operator. gogobee can ship a new event type any day; the worst case is a
|
||
// thin card until Pete learns the words. 400 stays for facts that are actually
|
||
// invalid — no guid, or a failed name guard.
|
||
headline, lede, known := renderAdventure(f)
|
||
if !known {
|
||
advNoteUnknownType(f.EventType)
|
||
slog.Warn("adventure ingest: no template for event_type, publishing on fallback",
|
||
"guid", f.GUID, "event_type", f.EventType)
|
||
headline, lede = advFallbackRender(f)
|
||
}
|
||
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success. Checked
|
||
// before the prose-guard because the guard runs a board query (KnownCharacterNames);
|
||
// a retried dispatch we already have a story for should cost nothing.
|
||
if storage.IsGUIDSeen(f.GUID) {
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("duplicate"))
|
||
return
|
||
}
|
||
|
||
// Prefer gogobee's LLM prose when it is present and safe. Both fields must be
|
||
// supplied — a half-authored dispatch is not a voice, and mixing an LLM
|
||
// headline with a template lede reads as two writers. The guard is what makes
|
||
// the untrusted prose safe to print; a rejection is worth seeing loudly, since
|
||
// it is either a hallucinated name or someone who found an injection path.
|
||
if f.Headline != "" && f.Lede != "" {
|
||
if proseGuard(f.Headline, f.Lede, f.Actors) {
|
||
headline, lede = f.Headline, f.Lede
|
||
} else {
|
||
slog.Warn("adventure ingest: prose-guard rejected LLM dispatch, using template",
|
||
"guid", f.GUID, "event_type", f.EventType)
|
||
}
|
||
}
|
||
|
||
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
|
||
// dated 1970 on the permalink, pinned to the bottom of the section, and
|
||
// outside every digest window. Treat "missing" as "now".
|
||
occurredAt := f.OccurredAt
|
||
if occurredAt <= 0 {
|
||
occurredAt = time.Now().Unix()
|
||
}
|
||
|
||
articleURL := s.advPermalink(f.GUID)
|
||
// Keyed on the guid, not the event type: the card renderer reads the fact
|
||
// behind the dispatch so it can put the boss's name on it. The fact insert
|
||
// below is best-effort, and the card degrades to the type-only emblem when
|
||
// it isn't there — so this URL is safe to bake in before that runs.
|
||
imageURL := advArtURL(f.GUID)
|
||
if err := storage.InsertStory(&storage.Story{
|
||
GUID: f.GUID,
|
||
Headline: headline,
|
||
Lede: lede,
|
||
ImageURL: imageURL,
|
||
ArticleURL: articleURL,
|
||
Source: advSource,
|
||
Channel: "adventure",
|
||
Classified: true,
|
||
SeenAt: occurredAt,
|
||
PublishedAt: occurredAt,
|
||
}); err != nil {
|
||
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
|
||
http.Error(w, "insert failed", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
// Keep the fact itself, not just the sentence we made out of it. The story row
|
||
// above is what people read; this is what the trophy case and timeline can
|
||
// count. Best-effort on purpose: a dispatch that published is published, and
|
||
// losing its structured twin costs a tally, not the news. Failing the request
|
||
// here would make gogobee retry a fact we already have a story for.
|
||
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
|
||
GUID: f.GUID,
|
||
EventType: f.EventType,
|
||
Tier: f.Tier,
|
||
Subject: f.Subject,
|
||
Opponent: f.Opponent,
|
||
Boss: f.Boss,
|
||
Zone: f.Zone,
|
||
Region: f.Region,
|
||
Level: f.Level,
|
||
Tally: f.Count,
|
||
Outcome: f.Outcome,
|
||
Milestone: f.Milestone,
|
||
Stakes: f.Stakes,
|
||
Actors: f.Actors,
|
||
RunID: f.RunID,
|
||
OccurredAt: occurredAt,
|
||
}); err != nil {
|
||
slog.Error("adventure ingest: event record failed", "guid", f.GUID, "err", err)
|
||
}
|
||
|
||
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
|
||
|
||
// Two reasons a dispatch never reaches Matrix, both retired the same way:
|
||
//
|
||
// - NoPush: a cold-start backfill, the back-catalogue dump it exists to
|
||
// prevent.
|
||
// - A room-silent type: gogobee already announced this exact moment to the
|
||
// games room in TwinBee's voice, and relaying it is the room hearing one
|
||
// beat twice in two voices.
|
||
//
|
||
// Suppressing only the live post isn't enough: the digest collects adventure
|
||
// rows that carry no post_log entry, so a held-back bulletin would still be
|
||
// swept into the next roundup. Retire the guid against the digest up front
|
||
// instead. The row was stored above either way, so the site, the permalink
|
||
// and the push alerts keep the full record.
|
||
if f.NoPush || s.roomSilent[f.EventType] {
|
||
retiredAs := advBackfillEvent
|
||
if !f.NoPush {
|
||
retiredAs = advRoomSilentEvent
|
||
}
|
||
storage.MarkAdventureDigested([]string{f.GUID}, retiredAs)
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("ok"))
|
||
return
|
||
}
|
||
|
||
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
|
||
// digest. Website section always gets the row above regardless of tier.
|
||
//
|
||
// An untemplated type never interrupts the room, whatever tier it claims.
|
||
// Publishing one to the site is cheap and reversible — a thin card among
|
||
// cards. Pinging everyone in Matrix with a dispatch Pete couldn't phrase is
|
||
// neither. It still reaches Matrix through the daily digest, one line among
|
||
// many, which is the right volume for something we don't understand yet.
|
||
if f.Tier == "priority" && known && 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.
|
||
// No Source: the source tag exists to credit an outlet Pete is relaying
|
||
// (`ars technica`). On his own reporting it renders as him signing his
|
||
// own name under his own message.
|
||
s.advPost(AdvPost{
|
||
GUID: f.GUID,
|
||
Headline: headline,
|
||
Lede: lede,
|
||
ArticleURL: articleURL,
|
||
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 "zone_clear":
|
||
return "Zone cleared", "🗺️"
|
||
case "treasure_found":
|
||
return "Treasure", "💎"
|
||
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", "🏅"
|
||
case "retreat":
|
||
return "Pulled out", "🎒"
|
||
case "departure":
|
||
return "Wandered off", "🚪"
|
||
case "mischief_contract":
|
||
return "Coin on their head", "😈"
|
||
case "mischief_survived":
|
||
return "They walked away", "🛡️"
|
||
case "mischief_downed":
|
||
return "The contract landed", "💀"
|
||
case "mischief_fizzled":
|
||
return "Nobody home", "🚪"
|
||
case "companion_hire":
|
||
return "Pete tags along", "🎒"
|
||
}
|
||
return "Dispatch", "📣"
|
||
}
|
||
|
||
// advUnknownTypes counts event types that arrived without a template, so an
|
||
// operator can see what Pete needs to learn to write. Before the unknown-type
|
||
// inversion these were 400s: gogobee retried to its cap and then parked the
|
||
// dispatch forever, which is how `companion_hire` was silently dropped for
|
||
// months. Now they publish on the neutral fallback and land here instead, where
|
||
// the admin status page can say "companion_hire ×12, still no template".
|
||
var advUnknownTypes = struct {
|
||
sync.Mutex
|
||
counts map[string]int
|
||
}{counts: map[string]int{}}
|
||
|
||
func advNoteUnknownType(eventType string) {
|
||
advUnknownTypes.Lock()
|
||
defer advUnknownTypes.Unlock()
|
||
advUnknownTypes.counts[eventType]++
|
||
}
|
||
|
||
// AdvUnknownTypeCounts returns a copy of the untemplated-type tally.
|
||
func AdvUnknownTypeCounts() map[string]int {
|
||
advUnknownTypes.Lock()
|
||
defer advUnknownTypes.Unlock()
|
||
out := make(map[string]int, len(advUnknownTypes.counts))
|
||
for k, v := range advUnknownTypes.counts {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// withArticle prefixes a noun with "a"/"an". Class names are a small closed set
|
||
// from the game ("cleric", "artificer"), so first-letter vowel is enough — this
|
||
// is not trying to be a general English article engine.
|
||
func withArticle(noun string) string {
|
||
if noun == "" {
|
||
return ""
|
||
}
|
||
switch noun[0] {
|
||
case 'a', 'e', 'i', 'o', 'u':
|
||
return "an " + noun
|
||
}
|
||
return "a " + noun
|
||
}
|
||
|
||
// advFallbackRender is the dispatch for an event type Pete has no template for.
|
||
//
|
||
// It is deliberately thin and deliberately honest: it says something happened
|
||
// and admits the details aren't in yet, rather than guessing at semantics Pete
|
||
// doesn't have. Only guarded or game-authored fields reach it — Subject has
|
||
// already passed factGuard, and Zone is game-authored — so it carries no more
|
||
// exposure than any templated branch.
|
||
//
|
||
// In practice it is rarely what publishes. gogobee authors LLM prose for every
|
||
// fact from the fact's fields with no per-type switch (authorDispatch in
|
||
// pete_dispatch_voice.go), so an unknown type still arrives with a real headline
|
||
// and lede, and this only shows through when the model is off or the prose-guard
|
||
// rejected it.
|
||
func advFallbackRender(f AdvFact) (headline, lede string) {
|
||
const stillGetting = " I'm still getting the details on this one — I'll fill it in properly when I have them."
|
||
switch {
|
||
case f.Subject != "" && f.Zone != "":
|
||
return fmt.Sprintf("Word in about %s.", f.Subject),
|
||
fmt.Sprintf("Something happened out in %s involving %s.%s", f.Zone, f.Subject, stillGetting)
|
||
case f.Subject != "":
|
||
return fmt.Sprintf("Word in about %s.", f.Subject),
|
||
fmt.Sprintf("%s has been up to something.%s", f.Subject, stillGetting)
|
||
case f.Zone != "":
|
||
return fmt.Sprintf("Something's happened in %s.", f.Zone),
|
||
"Word just came in from the field." + stillGetting
|
||
}
|
||
return "Word in from the realm.", "Something happened out there." + stillGetting
|
||
}
|
||
|
||
// 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
|
||
// RunReportURL is the link to the expedition behind this dispatch, when the
|
||
// dispatch is the end of one and the run is still reachable. Empty is the
|
||
// common case and renders nothing.
|
||
RunReportURL 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:<hash>:<ts>"); 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
|
||
}
|
||
|
||
// The way back to what actually happened. Best-effort and usually absent: only
|
||
// the three end-of-expedition types carry a run id at all, and the run behind
|
||
// one is swept after a fortnight. A dispatch without it reads exactly as it
|
||
// did before the report existed.
|
||
// Region rides the same lookup rather than a second query. It is a *fact*
|
||
// field, never on the story row — the story is the words Pete wrote and they
|
||
// have no columns for where. So a dispatch filed before the fact table existed
|
||
// still renders regionless, which is what it always did.
|
||
runReport, region := "", ""
|
||
if ev, err := storage.AdventureEventByGUID(guid); err != nil {
|
||
slog.Error("adventure story: fact lookup failed", "guid", guid, "err", err)
|
||
} else {
|
||
runReport = runReportLinkFor(ev)
|
||
if ev != nil {
|
||
region = ev.Region
|
||
}
|
||
}
|
||
|
||
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(guid) // the dispatch's own card, for link unfurls
|
||
}
|
||
s.render(w, "story", advStoryPage{
|
||
pageData: base,
|
||
EventLabel: label,
|
||
Emoji: emoji,
|
||
Headline: st.Headline,
|
||
Body: body,
|
||
Region: region,
|
||
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
|
||
Permalink: s.advPermalink(guid),
|
||
RunReportURL: runReport,
|
||
})
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// siteURL makes a root-relative path absolute against BaseURL. Links that go out
|
||
// to Matrix need the absolute form to survive safeHref; when BaseURL isn't
|
||
// configured the relative form is all we have, and it's still fine on-site.
|
||
func (s *Server) siteURL(path string) string {
|
||
return strings.TrimRight(s.cfg.BaseURL, "/") + path
|
||
}
|
||
|
||
// advPermalink builds the per-story Pete permalink used as article_url (the card
|
||
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
|
||
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
|
||
func (s *Server) advPermalink(guid string) string {
|
||
return s.siteURL("/adventure/" + url.PathEscape(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
|
||
}
|
||
|
||
// Length caps for LLM-authored prose, enforced before render. The 64 KiB body
|
||
// cap on the ingest request is a transport limit, not a prose limit — a
|
||
// dispatch is a headline and a short paragraph, and anything past these is
|
||
// malformed, not a valid long story. Over the cap falls back to the template.
|
||
const (
|
||
maxDispatchHeadline = 200
|
||
maxDispatchLede = 800
|
||
)
|
||
|
||
// proseGuard decides whether gogobee's LLM-authored headline+lede is safe to
|
||
// print. factGuard checks the STRUCTURED Subject/Opponent fields; that was the
|
||
// whole safety story while Pete's own templates were the renderer, because a
|
||
// template can print nothing Pete did not interpolate. LLM prose breaks that
|
||
// assumption — the guard would be validating fields that are no longer the thing
|
||
// being rendered — so this checks the RENDERED TEXT itself.
|
||
//
|
||
// Two rejections, both falling back to the template:
|
||
// - Over the length caps: a runaway or padded generation, not a dispatch.
|
||
// - Naming a known adventurer the fact did not authorize: any character name
|
||
// Pete holds on the current board that is absent from the fact's Actors
|
||
// allow-list. Character names are player-chosen, so a hallucinated or
|
||
// injected name is a live way to put words in a real person's mouth on a
|
||
// public page. Boss/zone/region are game-authored, never on the board, so
|
||
// they never trip this.
|
||
//
|
||
// The name half is best-effort: an empty board (KnownCharacterNames nil) leaves
|
||
// only the length caps, which is the correct degraded behaviour — with no known
|
||
// names there is nothing to impersonate that Pete could recognise anyway.
|
||
func proseGuard(headline, lede string, actors []string) bool {
|
||
if len(headline) > maxDispatchHeadline || len(lede) > maxDispatchLede {
|
||
return false
|
||
}
|
||
allow := make(map[string]bool, len(actors))
|
||
for _, a := range actors {
|
||
if a != "" {
|
||
allow[strings.ToLower(a)] = true
|
||
}
|
||
}
|
||
text := strings.ToLower(headline + "\n" + lede)
|
||
for name := range storage.KnownCharacterNames() {
|
||
if allow[name] {
|
||
continue
|
||
}
|
||
if containsWholeWord(text, name) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// containsWholeWord reports whether needle appears in haystack bounded by
|
||
// non-letter/digit runes (or the string edges). Both are already lowercased.
|
||
// Bounding avoids a short character name ("Al") matching inside an unrelated
|
||
// word ("Alabama") while still catching it as a standalone name; it is
|
||
// deliberately rune-aware so a non-ASCII player name still bounds correctly,
|
||
// where a stdlib \b would not.
|
||
func containsWholeWord(haystack, needle string) bool {
|
||
if needle == "" {
|
||
return false
|
||
}
|
||
from := 0
|
||
for {
|
||
i := strings.Index(haystack[from:], needle)
|
||
if i < 0 {
|
||
return false
|
||
}
|
||
start := from + i
|
||
end := start + len(needle)
|
||
beforeOK := start == 0 || !isWordRune(lastRune(haystack[:start]))
|
||
afterOK := end == len(haystack) || !isWordRune(firstRune(haystack[end:]))
|
||
if beforeOK && afterOK {
|
||
return true
|
||
}
|
||
from = start + 1
|
||
if from >= len(haystack) {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
func isWordRune(r rune) bool {
|
||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||
}
|
||
|
||
func firstRune(s string) rune {
|
||
for _, r := range s {
|
||
return r
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func lastRune(s string) rune {
|
||
var last rune
|
||
for _, r := range s {
|
||
last = r
|
||
}
|
||
return last
|
||
}
|
||
|
||
// 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", "zone_clear":
|
||
// gogobee splits the realm's first-ever clear (zone_first, priority) from a
|
||
// later repeat (zone_clear, bulletin); they share a lede but differ in
|
||
// headline. Fall back to the tier for a legacy zone_first that predates the
|
||
// split.
|
||
if f.EventType == "zone_first" || 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 "treasure_found":
|
||
// A story-grade find pulled from a dungeon. stakes is the item's name,
|
||
// outcome its rarity, and the priority tier marks a realm-first hoard
|
||
// nobody had ever pulled before — the same split zone_first uses.
|
||
inZone := f.Zone
|
||
if inZone == "" {
|
||
inZone = "the dungeon"
|
||
}
|
||
rarity := ""
|
||
if f.Outcome != "" {
|
||
rarity = strings.ToLower(f.Outcome) + " "
|
||
}
|
||
if f.Tier == "priority" {
|
||
return fmt.Sprintf("First ever: %s pulls %s out of %s.", f.Subject, f.Stakes, inZone),
|
||
fmt.Sprintf("Nobody had laid hands on %s before today. %s found the %shoard deep in %s%s, first in the realm to do it. Some haul.", f.Stakes, f.Subject, rarity, inZone, atLevel), true
|
||
}
|
||
return fmt.Sprintf("%s turned up %s in %s.", f.Subject, f.Stakes, inZone),
|
||
fmt.Sprintf("%s came back from %s with %s to show for it%s. A %sfind like that is worth a mention. Nice one.", f.Subject, inZone, f.Stakes, atLevel, rarity), 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 "retreat":
|
||
// An expedition that came apart without killing anyone. Until gogobee
|
||
// started sending these, the feed had no way to say "it went badly and
|
||
// everyone lived" — so it never said it, and the classes that retreat
|
||
// often simply never appeared. Warm, not a failure notice: everyone came
|
||
// home, and that is the part Pete leads with.
|
||
howFar := "barely a day in"
|
||
if f.Count > 1 {
|
||
howFar = fmt.Sprintf("%d days in", f.Count)
|
||
}
|
||
return fmt.Sprintf("%s backed out of %s.", f.Subject, f.Zone),
|
||
fmt.Sprintf("%s turned around %s — %s got the better of them this time%s, and they made the call to walk out rather than push it. Everybody came home breathing, which is the bit that counts. That dungeon'll still be there next week.",
|
||
f.Subject, howFar, f.Zone, atLevel), true
|
||
case "departure":
|
||
// A bored adventurer let themselves out. Nobody sent them — they got
|
||
// restless waiting on a player who wasn't coming, took the cheap supplies
|
||
// they could afford, and went. Pete plays it straight and a little fond;
|
||
// the joke tells itself, and the player it's about may well be reading.
|
||
return fmt.Sprintf("%s got bored and left without waiting.", f.Subject),
|
||
fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true
|
||
case "mischief_contract":
|
||
// Somebody paid to have a monster sent after an adventurer who is out in a
|
||
// dungeon right now. Anonymous unless the buyer paid extra to sign it, and
|
||
// the anonymity is the story: Pete reports the money, not the name he
|
||
// doesn't have. Opponent carries the buyer only when it's public.
|
||
if f.Opponent != "" {
|
||
return fmt.Sprintf("%s has put %s on %s's head.", f.Opponent, f.Stakes, f.Subject),
|
||
fmt.Sprintf("No secret about it — %s paid for a %s to go find %s out in whatever hole they're currently down, and signed the thing. It's out there looking right now. If %s comes back breathing, they keep a cut of that money.",
|
||
f.Opponent, strings.ToLower(f.Boss), f.Subject, f.Subject), true
|
||
}
|
||
return fmt.Sprintf("Someone's put %s on %s's head.", f.Stakes, f.Subject),
|
||
fmt.Sprintf("Word came in quiet: %s has been paid for a %s to go looking for %s, and whoever paid it isn't saying so. It's already out there. Survive it and the money's theirs — and we all find out who signed the cheque.",
|
||
f.Stakes, strings.ToLower(f.Boss), f.Subject), true
|
||
case "mischief_survived":
|
||
// The unseal. A survival is the only thing that names an anonymous buyer,
|
||
// and it is the whole brake on casual griefing — so Pete leads with it.
|
||
return fmt.Sprintf("%s walked away from it. It was %s who paid.", f.Subject, f.Opponent),
|
||
fmt.Sprintf("%s came for %s, and %s is the one still standing — %s richer for the trouble. The contract's been opened up, and the name inside it is %s. Make of that what you will, folks.",
|
||
f.Boss, f.Subject, f.Subject, f.Stakes, f.Opponent), true
|
||
case "mischief_downed":
|
||
who := "Nobody's saying who paid for it"
|
||
if f.Opponent != "" {
|
||
who = fmt.Sprintf("%s paid for it, and put their name on it", f.Opponent)
|
||
}
|
||
return fmt.Sprintf("%s didn't walk away.", f.Subject),
|
||
fmt.Sprintf("A %s found %s mid-run%s and put them on the floor. They're being carried home — alive, which is more than the contract asked for, but that expedition's finished. %s.",
|
||
f.Boss, f.Subject, atLevel, who), true
|
||
case "mischief_fizzled":
|
||
return fmt.Sprintf("The monster sent for %s arrived to an empty dungeon.", f.Subject),
|
||
fmt.Sprintf("Somebody spent good money to have %s ambushed, and %s had already gone home. It wandered the halls for a bit and left. Most of the fee's been refunded. The rest, the town's keeping.",
|
||
f.Subject, f.Subject), 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
|
||
case "companion_hire":
|
||
// Pete himself has been hired onto somebody's expedition. He is the one
|
||
// being reported on here, so this is the one family besides his duels that
|
||
// is properly first-person. Subject is the LEADER who hired him (and whose
|
||
// opt-out therefore applies), class_race is the seat he's filling.
|
||
seat, seatArticled := strings.ToLower(f.ClassRace), ""
|
||
if seat == "" {
|
||
seat, seatArticled = "an extra pair of hands", "an extra pair of hands"
|
||
} else {
|
||
seatArticled = withArticle(seat)
|
||
}
|
||
intoZone := ""
|
||
if f.Zone != "" {
|
||
intoZone = " into " + f.Zone
|
||
}
|
||
return fmt.Sprintf("Filling in as %s for %s.", seat, f.Subject),
|
||
fmt.Sprintf("%s needed %s and I wasn't doing much, so I'm tagging along%s%s. I'll pull my weight — the reporting can wait till we're home.",
|
||
f.Subject, seatArticled, intoZone, atLevel), true
|
||
}
|
||
return "", "", false
|
||
}
|