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