diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go
index dc14ff4..8612442 100644
--- a/internal/peteclient/client.go
+++ b/internal/peteclient/client.go
@@ -48,6 +48,13 @@ type Fact struct {
Milestone string `json:"milestone,omitempty"`
OccurredAt int64 `json:"occurred_at"`
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
+ // RunID names the expedition this fact is the ENDING of, and only the three
+ // facts that are one carry it: a clear, a retreat, a death. It is what lets
+ // Pete's dispatch link back to 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 everywhere else, and safe to be
+ // empty: Pete renders the dispatch exactly as it did before the report existed.
+ RunID string `json:"run_id,omitempty"`
// Headline/Lede are LLM-authored prose for this fact, both optional. Pete
// prefers them over its own template when present and past its prose-guard,
// and falls back to the template otherwise — so an empty pair (LLM off, or
@@ -459,7 +466,7 @@ func PushSiege(ctx context.Context, snap SiegeSnapshot) error {
type RunBeat struct {
RunID string `json:"run_id"`
Seq int64 `json:"seq"`
- Kind string `json:"kind"` // start|room|combat|trap|treasure|haul|lock|camp|region|end
+ Kind string `json:"kind"` // start|room|combat|trap|treasure|haul|lock|camp|region|end|summary
OccurredAt int64 `json:"occurred_at"`
Token string `json:"token,omitempty"` // `start` only: whose run this is
@@ -478,6 +485,12 @@ type RunBeat struct {
HPMax int `json:"hp_max,omitempty"`
Crits int `json:"crits,omitempty"`
Fumbles int `json:"fumbles,omitempty"`
+ // Prose is the single exception to "nouns and numbers only", and it is
+ // confined to the one kind that has any: `summary`, the three sentences the
+ // local model writes over a finished run. Pete guards it exactly as it guards
+ // a dispatch lede and drops the words (not the beat) on a rejection. Every
+ // other kind must leave this empty — Pete scrubs it if they don't.
+ Prose string `json:"prose,omitempty"`
}
// PushRunBeats delivers a batch of beats. Unlike the snapshots this is
diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go
index c2c38fb..578a245 100644
--- a/internal/plugin/dnd_combat.go
+++ b/internal/plugin/dnd_combat.go
@@ -607,6 +607,7 @@ func emitDeathNews(userID id.UserID, location string) {
Zone: location,
Level: lvl,
Outcome: "lost",
+ RunID: latestRunIDForNews(userID),
OccurredAt: ts,
}, userID, "")
}
@@ -655,6 +656,7 @@ func emitRetreatNews(userID id.UserID, reason string, zoneID ZoneID, day int) {
Level: charLevel(userID),
Count: day, // the day they got to before it fell apart
Outcome: "retreated",
+ RunID: latestRunIDForNews(userID),
OccurredAt: ts,
}, userID, "")
}
diff --git a/internal/plugin/pete.go b/internal/plugin/pete.go
index 46a367a..df20d29 100644
--- a/internal/plugin/pete.go
+++ b/internal/plugin/pete.go
@@ -343,6 +343,7 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) {
Boss: zone.Boss.Name,
Level: lvl,
Outcome: "cleared",
+ RunID: latestRunIDForNews(userID),
OccurredAt: ts,
}, userID, "")
}
diff --git a/internal/plugin/pete_dispatch_voice.go b/internal/plugin/pete_dispatch_voice.go
index 6f45f49..21aa831 100644
--- a/internal/plugin/pete_dispatch_voice.go
+++ b/internal/plugin/pete_dispatch_voice.go
@@ -54,7 +54,7 @@ func authorDispatch(f peteclient.Fact) (headline, lede string) {
}
prompt := buildDispatchPrompt(f)
- raw, err := callOllamaDispatch(host, model, prompt)
+ raw, err := callOllamaDispatch(dispatchHTTP, host, model, prompt)
if err != nil {
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
return "", ""
@@ -129,9 +129,11 @@ The event:
}
// callOllamaDispatch posts a single non-streaming generation and returns the raw
-// completion (think-tags stripped). Its own bounded client, separate from the
-// interactive callOllama, because the game loop cannot wait 120s on the news.
-func callOllamaDispatch(host, model, prompt string) (string, error) {
+// completion (think-tags stripped). The client is a parameter because the two
+// callers have genuinely different patience: a dispatch is authored on a game
+// chokepoint and must not stall it, while a run summary rides a background
+// ticker and can afford to wait for a bigger model. See runSummaryHTTP.
+func callOllamaDispatch(client *http.Client, host, model, prompt string) (string, error) {
payload := map[string]interface{}{
"model": model,
"prompt": prompt,
@@ -146,7 +148,7 @@ func callOllamaDispatch(host, model, prompt string) (string, error) {
return "", fmt.Errorf("marshal payload: %w", err)
}
apiURL := strings.TrimRight(host, "/") + "/api/generate"
- resp, err := dispatchHTTP.Post(apiURL, "application/json", bytes.NewReader(data))
+ resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("ollama request: %w", err)
}
diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go
index 852d2e9..18c2f71 100644
--- a/internal/plugin/pete_roster.go
+++ b/internal/plugin/pete_roster.go
@@ -54,6 +54,11 @@ func (p *AdventurePlugin) peteRosterTicker() {
p.pushDetails()
p.pushSiege()
p.pushRunBeats()
+ // After the beats, not before: the summary is the last beat of a run's
+ // story and has no business overtaking the log it is about. It is also the
+ // only step here that can talk to the model, which is why it lives on a
+ // ticker at all rather than at the moment a run ends.
+ p.sweepRunSummaries()
}
}
diff --git a/internal/plugin/pete_run_summary.go b/internal/plugin/pete_run_summary.go
new file mode 100644
index 0000000..16b920e
--- /dev/null
+++ b/internal/plugin/pete_run_summary.go
@@ -0,0 +1,362 @@
+package plugin
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "sort"
+ "strings"
+ "time"
+
+ "gogobee/internal/db"
+ "gogobee/internal/peteclient"
+)
+
+// The run summary — three sentences over forty beats.
+//
+// Every other line in the liveblog is assembled by Pete out of a beat's own
+// nouns and numbers, and that is the right split: a log has to be exactly what
+// happened, in order, and prose in the middle of it would be the more convincing
+// of the two accounts and the less true. But a *report* is read afterwards, by
+// somebody who wasn't watching, and the question it answers is not "what
+// happened" — the log already answers that — it is "what was that run". That is
+// a judgement, and no template makes judgements.
+//
+// So this is the one piece of prose on the channel, and it earns the model far
+// better than a dispatch headline does. authorDispatch turns four fields into a
+// sentence a template could nearly have written; this reads a whole expedition
+// and picks out what mattered.
+//
+// Three rules, and the first one is why this file exists at all:
+//
+// - **Off the hot path.** It runs on the roster ticker, not at the moment the
+// run ends. A run ending is already a player-facing beat with a dispatch
+// being authored against it; adding a second bounded-but-real LLM call to
+// that chokepoint would stall the command that killed the boss.
+// - **One per tick.** A backlog after an outage drains over minutes rather
+// than spooling a hundred generations at once.
+// - **Best effort, exactly once.** A run that can't be summarised is filed
+// with an empty summary beat rather than retried forever — the row is what
+// stops the sweep picking it up again next tick, and a report with no
+// summary is still the log and the numbers, which is most of it.
+
+// runSummaryMaxBeats bounds what goes into the prompt. Far more than a normal
+// run produces; the cap is for the multi-day expedition that beat out hundreds,
+// where the last chunk is the part with the ending in it.
+const runSummaryMaxBeats = 120
+
+// maxRunSummary mirrors Pete's cap so we never ship prose Pete will reject on
+// length alone. Byte count, matching Pete's len() check.
+const maxRunSummary = 1200
+
+// runSummaryTimeout is deliberately four times the dispatch budget.
+//
+// dispatchLLMTimeout is tight because authoring runs on a game chokepoint and a
+// template dispatch now beats a voiced one late. Nothing here is waiting on this:
+// it is a background ticker, the run ended minutes ago, and the page it feeds is
+// already serving without it. The cost of being impatient is the opposite of
+// there — a timeout files an empty summary beat, and that run never gets another
+// chance at one. So this waits long enough that a timeout means the box is down
+// rather than that the model was thinking.
+const runSummaryTimeout = 60 * time.Second
+
+var runSummaryHTTP = &http.Client{Timeout: runSummaryTimeout}
+
+// sweepRunSummaries authors the summary for at most one finished run per call.
+// Called from the roster ticker, after the beats themselves have been pushed —
+// the summary is the last beat of a run's story and there is no rush to have it
+// overtake the log it is about.
+func (p *AdventurePlugin) sweepRunSummaries() {
+ if !peteclient.Enabled() || !newsEmissionOn() {
+ return
+ }
+ if os.Getenv("OLLAMA_HOST") == "" || os.Getenv("OLLAMA_MODEL") == "" {
+ return // no model, no summary, no wasted queries asking which run needs one
+ }
+ runID := nextRunNeedingSummary()
+ if runID == "" {
+ return
+ }
+ // File the beat whatever happens below. An empty one carries no prose and Pete
+ // stores nothing from it — its entire job is to be the row that stops this run
+ // coming back round every two minutes for the rest of the week.
+ summary, name := authorRunSummary(runID)
+ if summary == "" {
+ slog.Debug("run summary: nothing authored, filing an empty beat to close it out", "run", runID)
+ }
+ recordRunBeat(runID, peteclient.RunBeat{
+ Kind: "summary",
+ Name: name,
+ Prose: summary,
+ })
+}
+
+// nextRunNeedingSummary picks the most recently finished run that has an `end`
+// beat and no `summary` beat yet.
+//
+// Newest first, deliberately. If the sweep is behind — an outage, a busy
+// evening — the run somebody is most likely to be looking at right now is the
+// one that just ended, not the one from four hours ago. The old ones still get
+// their turn on later ticks; they just don't get to hold up the fresh one.
+func nextRunNeedingSummary() string {
+ var runID string
+ err := db.Get().QueryRow(`
+ SELECT e.run_id
+ FROM pete_run_beat e
+ WHERE e.kind = 'end'
+ AND NOT EXISTS (
+ SELECT 1 FROM pete_run_beat s
+ WHERE s.run_id = e.run_id AND s.kind = 'summary')
+ ORDER BY e.occurred_at DESC
+ LIMIT 1`).Scan(&runID)
+ if err != nil {
+ return "" // ErrNoRows is the common case: nothing to summarise
+ }
+ if !runBeatAllowed(runID) {
+ // An opted-out player's beats never leave the box, so there is nothing for
+ // a summary to be attached to. File the closing beat anyway (it will be
+ // retired locally with the rest) so this run stops being picked.
+ recordRunBeat(runID, peteclient.RunBeat{Kind: "summary"})
+ return ""
+ }
+ return runID
+}
+
+// authorRunSummary reads a run's beats back and returns Pete's summary of it,
+// plus the adventurer's name for the guard allow-list. Returns empty strings on
+// any failure — the model being off, a timeout, an unparseable completion, an
+// over-long generation — because every one of those is a report without a
+// summary rather than a problem.
+func authorRunSummary(runID string) (summary, name string) {
+ beats, err := loadRunBeatsForSummary(runID)
+ if err != nil || len(beats) == 0 {
+ return "", ""
+ }
+ name = runSummarySubject(beats)
+ if name == "" {
+ // No name means no allow-list on Pete's side, which means the guard rejects
+ // anything naming anyone. Don't spend a generation to have it thrown away.
+ return "", ""
+ }
+
+ raw, err := callOllamaDispatch(runSummaryHTTP, os.Getenv("OLLAMA_HOST"), os.Getenv("OLLAMA_MODEL"),
+ buildRunSummaryPrompt(name, beats))
+ if err != nil {
+ slog.Warn("run summary: LLM authoring failed", "run", runID, "err", err)
+ return "", name
+ }
+ summary = parseRunSummary(raw)
+ if summary == "" || len(summary) > maxRunSummary {
+ slog.Warn("run summary: unusable output", "run", runID, "len", len(summary))
+ return "", name
+ }
+ return summary, name
+}
+
+// loadRunBeatsForSummary reads a run's own beats back out of the outbound
+// buffer. It reads the TAIL and re-sorts, so a run long enough to hit the cap
+// contributes the part with its ending in it rather than its first morning.
+func loadRunBeatsForSummary(runID string) ([]peteclient.RunBeat, error) {
+ rows, err := db.Get().Query(`
+ SELECT seq, payload FROM pete_run_beat
+ WHERE run_id = ? ORDER BY seq DESC LIMIT ?`, runID, runSummaryMaxBeats)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []peteclient.RunBeat
+ for rows.Next() {
+ var seq int64
+ var payload string
+ if err := rows.Scan(&seq, &payload); err != nil {
+ return nil, err
+ }
+ var b peteclient.RunBeat
+ if err := json.Unmarshal([]byte(payload), &b); err != nil {
+ continue // a row the pusher will retire on its own; not this sweep's problem
+ }
+ b.RunID, b.Seq = runID, seq
+ out = append(out, b)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Seq < out[j].Seq })
+ return out, nil
+}
+
+// runSummarySubject finds the one name a summary is allowed to use. Only the
+// `start` beat carries identity, by design — so a run whose start beat was
+// dropped has no subject here, and gets no summary rather than an anonymous one.
+func runSummarySubject(beats []peteclient.RunBeat) string {
+ for _, b := range beats {
+ if b.Name != "" {
+ return b.Name
+ }
+ }
+ return ""
+}
+
+// buildRunSummaryPrompt renders the run as a plain numbered log and asks for
+// three sentences over it.
+//
+// The beats go in as facts, not as Pete's rendered lines: Pete's phrasing is
+// Pete's, and feeding a model its own output back would have it summarising a
+// summary. The rules are the dispatch prompt's, tightened in the one place that
+// matters here — a run log is full of monster names and a model asked to write
+// about a party is very willing to invent a second member of it.
+func buildRunSummaryPrompt(name string, beats []peteclient.RunBeat) string {
+ var log strings.Builder
+ zone, outcome := "", ""
+ n := 0
+ for _, b := range beats {
+ if b.Zone != "" && zone == "" {
+ zone = b.Zone
+ }
+ line := describeBeatForPrompt(b)
+ if line == "" {
+ continue
+ }
+ if b.Kind == "end" {
+ outcome = b.Outcome
+ }
+ n++
+ fmt.Fprintf(&log, "%d. %s\n", n, line)
+ }
+ if zone == "" {
+ zone = "a dungeon"
+ }
+ ending := "the run ended"
+ switch outcome {
+ case "cleared":
+ ending = "they cleared it"
+ case "died":
+ ending = "they died down there"
+ case "retreated":
+ ending = "they walked out alive but beaten"
+ }
+
+ return fmt.Sprintf(`You are Pete, a warm, friendly local news reporter for a fantasy adventuring town. Think a beloved local newscaster who genuinely knows everyone and is glad to see them. Conversational, never snarky, never a caps-lock hype-man. Warmth carries the register, not exclamation marks.
+
+Below is the log of one expedition, room by room, exactly as it was recorded. Write a SHORT summary of how the run went: what it cost them, the moment it turned, and how it ended.
+
+STRICT RULES — do not violate these:
+- The ONLY adventurer name you may use is: %s. Never invent another adventurer, companion, party member or friend. If the log does not say someone was there, they were not there.
+- Monster, zone and item names in the log are game names — use them as given.
+- Use ONLY what the log says. Do not invent numbers, fights, items or outcomes.
+- Do NOT add numbers together and do NOT state any total. The exact totals are printed next to your summary and a total you worked out yourself will contradict them. Quote a number only if that exact number appears on one line of the log.
+- Three sentences at most. No markdown, no emoji, no headline, no bullet points.
+- Past tense, third person. Do not address the reader as "you".
+
+Respond with ONLY a JSON object, no other text:
+{"summary": "at most three sentences about how the run went"}
+
+The expedition: %s went into %s, and %s.
+
+The log:
+%s`, name, name, zone, ending, log.String())
+}
+
+// describeBeatForPrompt renders one beat as a flat fact line for the prompt.
+// Returns "" for a beat with nothing in it worth a sentence — a room with no
+// identity, an empty haul — so the model isn't handed forty lines of "walked
+// into the next room" to find three sentences in.
+func describeBeatForPrompt(b peteclient.RunBeat) string {
+ switch b.Kind {
+ case "start":
+ if b.TotalRooms > 0 {
+ return fmt.Sprintf("set out into %s, %d rooms deep", orSomething(b.Zone), b.TotalRooms)
+ }
+ return "set out into " + orSomething(b.Zone)
+ case "combat":
+ what := orSomething(b.Target)
+ switch b.RoomKind {
+ case "boss":
+ what = "the boss, " + what
+ case "elite":
+ what = "an elite, " + what
+ }
+ switch b.Outcome {
+ case "won":
+ s := fmt.Sprintf("killed %s, taking %d damage", what, b.Amount)
+ if b.HPMax > 0 {
+ s += fmt.Sprintf(" (left on %d of %d health)", b.HP, b.HPMax)
+ }
+ if b.Crits > 0 {
+ s += fmt.Sprintf(", %d critical hit(s)", b.Crits)
+ }
+ return s
+ case "retreat":
+ return "could not finish " + what + " in time and withdrew"
+ default:
+ return "was beaten by " + what
+ }
+ case "trap":
+ if b.Amount <= 0 {
+ return "spotted a trap and stepped over it"
+ }
+ s := fmt.Sprintf("sprung a trap for %d damage", b.Amount)
+ if b.HPMax > 0 {
+ s += fmt.Sprintf(" (left on %d of %d health)", b.HP, b.HPMax)
+ }
+ return s
+ case "treasure":
+ return "found " + orSomething(b.Target)
+ case "lock":
+ if b.Outcome == "picked" {
+ return "picked a locked door"
+ }
+ return "found every way on sealed and doubled back"
+ case "region":
+ return "crossed out of " + orSomething(b.Region) + " into " + orSomething(b.Target)
+ case "haul":
+ if b.Amount <= 0 {
+ return ""
+ }
+ return fmt.Sprintf("gathered %d supplies along the way", b.Amount)
+ case "end":
+ switch b.Outcome {
+ case "cleared":
+ return "finished the run and got out"
+ case "died":
+ return "did not come home"
+ case "retreated":
+ return "withdrew, wounded but alive"
+ }
+ return "the run ended"
+ }
+ return ""
+}
+
+func orSomething(s string) string {
+ if s == "" {
+ return "something"
+ }
+ return s
+}
+
+// parseRunSummary pulls {"summary": ...} out of the completion, tolerating the
+// same noise parseDispatch does: reasoning blocks, fences, prose around the JSON.
+func parseRunSummary(raw string) string {
+ s := raw
+ if i := strings.Index(s, ""); i != -1 {
+ if j := strings.Index(s, ""); j != -1 {
+ s = s[:i] + s[j+len(""):]
+ }
+ }
+ start := strings.Index(s, "{")
+ end := strings.LastIndex(s, "}")
+ if start < 0 || end <= start {
+ return ""
+ }
+ var out struct {
+ Summary string `json:"summary"`
+ }
+ if err := json.Unmarshal([]byte(s[start:end+1]), &out); err != nil {
+ return ""
+ }
+ return strings.TrimSpace(out.Summary)
+}
diff --git a/internal/plugin/pete_run_summary_test.go b/internal/plugin/pete_run_summary_test.go
new file mode 100644
index 0000000..74ba92c
--- /dev/null
+++ b/internal/plugin/pete_run_summary_test.go
@@ -0,0 +1,199 @@
+package plugin
+
+import (
+ "strings"
+ "testing"
+
+ "gogobee/internal/db"
+ "gogobee/internal/peteclient"
+
+ "maunium.net/go/mautrix/id"
+)
+
+// finishRun writes a small realistic run's beats and closes it.
+func finishRun(runID, name string) {
+ recordRunBeat(runID, peteclient.RunBeat{Kind: "start", Token: "tok", Name: name,
+ Level: 14, Zone: "Crypt of Valdris", TotalRooms: 9, Room: 1})
+ recordRunBeat(runID, peteclient.RunBeat{Kind: "combat", Room: 2, Target: "Bone Chanter",
+ Outcome: "won", Amount: 7, HP: 61, HPMax: 68})
+ recordRunBeat(runID, peteclient.RunBeat{Kind: "trap", Room: 3, Outcome: "sprung",
+ Amount: 22, HP: 39, HPMax: 68})
+ recordRunBeat(runID, peteclient.RunBeat{Kind: "end", Room: 3, Outcome: "died"})
+}
+
+// TestSummarySweepPicksAFinishedRunOnce. The sweep runs every two minutes
+// forever, so the property that matters is not that it finds a run — it is that
+// it lets one go. A run that stayed pickable would author a fresh summary every
+// tick for the rest of the week.
+func TestSummarySweepPicksAFinishedRunOnce(t *testing.T) {
+ newBoredomTestDB(t)
+ enablePeteSeam(t)
+ seedBeatRun(t, "run-done", id.UserID("@josie:example.com"))
+ finishRun("run-done", "Josie")
+
+ if got := nextRunNeedingSummary(); got != "run-done" {
+ t.Fatalf("sweep didn't find the finished run: %q", got)
+ }
+ // Filing the closing beat is what retires it, whether or not any prose was
+ // authored into it.
+ recordRunBeat("run-done", peteclient.RunBeat{Kind: "summary"})
+ if got := nextRunNeedingSummary(); got != "" {
+ t.Errorf("run came back round after its summary beat was filed: %q", got)
+ }
+}
+
+// TestSummarySweepIgnoresARunStillWalking. A summary is a reading of a finished
+// run. Writing one over a run in progress would be an ending invented before
+// there was one.
+func TestSummarySweepIgnoresARunStillWalking(t *testing.T) {
+ newBoredomTestDB(t)
+ enablePeteSeam(t)
+ seedBeatRun(t, "run-live", id.UserID("@josie:example.com"))
+ recordRunBeat("run-live", peteclient.RunBeat{Kind: "start", Name: "Josie", Zone: "Crypt"})
+ recordRunBeat("run-live", peteclient.RunBeat{Kind: "combat", Target: "Rat", Outcome: "won"})
+
+ if got := nextRunNeedingSummary(); got != "" {
+ t.Errorf("picked a run that hasn't ended: %q", got)
+ }
+}
+
+// TestSummarySweepRetiresAnOptedOutRun. Their beats never leave the box, so
+// there is nothing on Pete for a summary to attach to — but the run must still
+// stop being picked, or the sweep spends a generation on it every tick and
+// throws the result away.
+func TestSummarySweepRetiresAnOptedOutRun(t *testing.T) {
+ newBoredomTestDB(t)
+ enablePeteSeam(t)
+ uid := id.UserID("@quiet:example.com")
+ seedBeatRun(t, "run-quiet", uid)
+ finishRun("run-quiet", "Quack")
+ setNewsOptout(uid, true)
+
+ if got := nextRunNeedingSummary(); got != "" {
+ t.Errorf("offered an opted-out player's run for summarising: %q", got)
+ }
+ kinds := beatKinds(t, "run-quiet")
+ if kinds[len(kinds)-1] != "summary" {
+ t.Errorf("opted-out run wasn't closed out; kinds = %v", kinds)
+ }
+ // And it stays closed out.
+ if got := nextRunNeedingSummary(); got != "" {
+ t.Errorf("opted-out run came back round: %q", got)
+ }
+}
+
+// TestSummaryPromptCarriesTheRunAndOnlyOneName.
+//
+// The prompt is the whole safety story on this side (Pete's guard is the other
+// half, and it only ever sees the answer). A run log is full of monster names,
+// and a model asked to write warmly about "the party" will happily invent a
+// second member of it — which on a public page is words put in a real person's
+// mouth. So the one name is stated twice and the facts are handed over as facts.
+func TestSummaryPromptCarriesTheRunAndOnlyOneName(t *testing.T) {
+ newBoredomTestDB(t)
+ enablePeteSeam(t)
+ seedBeatRun(t, "run-p", id.UserID("@josie:example.com"))
+ finishRun("run-p", "Josie")
+
+ beats, err := loadRunBeatsForSummary("run-p")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(beats) != 4 {
+ t.Fatalf("want 4 beats, got %d", len(beats))
+ }
+ if beats[0].Seq >= beats[len(beats)-1].Seq {
+ t.Error("beats came back out of order; the log would read backwards")
+ }
+ if got := runSummarySubject(beats); got != "Josie" {
+ t.Fatalf("subject = %q, want Josie", got)
+ }
+
+ p := buildRunSummaryPrompt("Josie", beats)
+ for _, want := range []string{
+ "The ONLY adventurer name you may use is: Josie",
+ "Josie went into Crypt of Valdris, and they died down there",
+ "killed Bone Chanter, taking 7 damage",
+ "sprung a trap for 22 damage",
+ "did not come home",
+ "Three sentences at most",
+ } {
+ if !strings.Contains(p, want) {
+ t.Errorf("prompt is missing %q", want)
+ }
+ }
+}
+
+// TestUnattributedRunGetsNoSummary. Only the `start` beat carries identity. A
+// run that lost it has no name to hand the guard, so Pete would reject any
+// summary naming anybody — spending a generation to have it thrown away, and
+// risking an anonymous paragraph about a player nobody can consent for.
+func TestUnattributedRunGetsNoSummary(t *testing.T) {
+ newBoredomTestDB(t)
+ enablePeteSeam(t)
+ seedBeatRun(t, "run-anon", id.UserID("@josie:example.com"))
+ recordRunBeat("run-anon", peteclient.RunBeat{Kind: "combat", Target: "Rat", Outcome: "won"})
+ recordRunBeat("run-anon", peteclient.RunBeat{Kind: "end", Outcome: "cleared"})
+
+ summary, name := authorRunSummary("run-anon")
+ if summary != "" || name != "" {
+ t.Errorf("authored over a run with no owner: name=%q summary=%q", name, summary)
+ }
+}
+
+// TestParseRunSummaryTolerance mirrors parseDispatch's: the model wraps its
+// answer in reasoning blocks, fences and apologies, and none of that is a reason
+// to lose a summary that is sitting right there.
+func TestParseRunSummaryTolerance(t *testing.T) {
+ cases := []struct{ name, raw, want string }{
+ {"plain", `{"summary": "It went badly."}`, "It went badly."},
+ {"think block", "hmm\n{\"summary\": \"It went badly.\"}", "It went badly."},
+ {"fenced with chatter", "Sure!\n```json\n{\"summary\": \"It went badly.\"}\n```\n", "It went badly."},
+ {"no json", "It went badly.", ""},
+ {"empty summary", `{"summary": " "}`, ""},
+ }
+ for _, c := range cases {
+ if got := parseRunSummary(c.raw); got != c.want {
+ t.Errorf("%s: parseRunSummary = %q, want %q", c.name, got, c.want)
+ }
+ }
+}
+
+// TestDispatchRunLinkNeedsARecentRunWithBeats.
+//
+// latestRunIDForNews answers "which run is this dispatch about", and it is asked
+// from call sites that have already let go of the run. Both of its guards are
+// load-bearing: a run with no beats behind it would mint a dispatch link to a
+// 404, and a stale run would attach a campaign death at the Empty Throne to
+// whatever dungeon that player last walked.
+func TestDispatchRunLinkNeedsARecentRunWithBeats(t *testing.T) {
+ newBoredomTestDB(t)
+ enablePeteSeam(t)
+ uid := id.UserID("@josie:example.com")
+
+ // A run with no beats: pre-liveblog, or the seam was off while it walked.
+ seedBeatRun(t, "run-silent", uid)
+ if got := latestRunIDForNews(uid); got != "" {
+ t.Errorf("linked a dispatch to a run Pete has never heard of: %q", got)
+ }
+
+ // The real one, closed seconds ago.
+ seedBeatRun(t, "run-real", uid)
+ finishRun("run-real", "Josie")
+ if _, err := db.Get().Exec(
+ `UPDATE dnd_zone_run SET completed_at = CURRENT_TIMESTAMP WHERE run_id = 'run-real'`); err != nil {
+ t.Fatal(err)
+ }
+ if got := latestRunIDForNews(uid); got != "run-real" {
+ t.Errorf("run link = %q, want run-real", got)
+ }
+
+ // A day later, they die somewhere that isn't a dungeon at all.
+ if _, err := db.Get().Exec(
+ `UPDATE dnd_zone_run SET completed_at = datetime('now', '-1 day') WHERE run_id = 'run-real'`); err != nil {
+ t.Fatal(err)
+ }
+ if got := latestRunIDForNews(uid); got != "" {
+ t.Errorf("attached an unrelated death to yesterday's expedition: %q", got)
+ }
+}
diff --git a/internal/plugin/pete_runbeat.go b/internal/plugin/pete_runbeat.go
index 3846051..04f5afb 100644
--- a/internal/plugin/pete_runbeat.go
+++ b/internal/plugin/pete_runbeat.go
@@ -85,6 +85,49 @@ func runHasEndBeat(runID string) bool {
return err == nil && n > 0
}
+// latestRunIDForNews is the run a just-filed dispatch is about, or "" when there
+// isn't one to point at.
+//
+// The three dispatches that end an expedition — a clear, a retreat, a death —
+// are all emitted *after* the run they concluded has been closed, and two of
+// them from call sites several frames away from the run row. So rather than
+// thread a run id through five signatures and hope the lifetimes line up, this
+// asks the question that is actually true at that moment: what is the last run
+// this player started. A player has one run at a time and a dispatch about their
+// expedition ending is about that one. Multi-region is the case worth stating:
+// each region gets its own run, and the last one started is the one they were
+// standing in when it ended, which is the log the dispatch should open.
+//
+// Two clauses do the real work and neither is optional:
+//
+// - The `pete_run_beat` check. Runs exist with no beats behind them — from
+// before the liveblog shipped, or with the seam off — and handing Pete a run
+// id it has nothing for would mint a dispatch link to a 404.
+// - The recency window. Not every death happens in a dungeon: the campaign
+// path kills people at the Empty Throne, and without this a death that had
+// nothing to do with any expedition would link to whatever run that player
+// last walked, possibly days ago. An expedition-ending dispatch is filed
+// seconds after its run closes, so "still open, or closed just now" is the
+// honest test for "this dispatch is about that run".
+func latestRunIDForNews(userID id.UserID) string {
+ if userID == "" || !peteclient.Enabled() {
+ return ""
+ }
+ var runID string
+ err := db.Get().QueryRow(`
+ SELECT r.run_id
+ FROM dnd_zone_run r
+ WHERE r.user_id = ?
+ AND (r.completed_at IS NULL OR r.completed_at >= datetime('now', '-10 minutes'))
+ AND EXISTS (SELECT 1 FROM pete_run_beat b WHERE b.run_id = r.run_id)
+ ORDER BY r.started_at DESC, r.rowid DESC
+ LIMIT 1`, string(userID)).Scan(&runID)
+ if err != nil {
+ return ""
+ }
+ return runID
+}
+
// runBeatPushOK mirrors rosterPushOK: log the transitions, stay quiet otherwise.
var runBeatPushOK bool