mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
adventure: read a finished run back and tell Pete what it was about
Two halves of the same gap. A dispatch announcing a clear, a retreat or a death said how an expedition ended and gave a reader no way to reach what happened in it — so those three facts now carry the run they concluded, and Pete's permalink can open the log behind the headline. Finding that run is done by asking what is actually true at the moment a dispatch is filed rather than by threading a run id through five call sites that have already let go of it. Both guards on that lookup are load-bearing: a run with no beats behind it would mint a link to a 404, and without the recency window a campaign death at the Empty Throne would attach itself to whatever dungeon that player last walked. The other half is the summary. Every line of the liveblog is assembled by Pete out of a beat's own nouns, which is right for a log — it has to be exactly what happened, in order. But a report is read afterwards and the question it answers is not "what happened", it is "what was that run", and that is a judgement. So this is the one piece of prose on the channel, and it earns the model far better than a dispatch headline does. It runs on the roster ticker, not at the moment the run ends. A run ending is already a chokepoint with a dispatch being authored against it, and a second generation there would stall the command that killed the boss. One per tick, and a run that can't be summarised is closed out with an empty beat rather than retried forever — the row is what stops the sweep picking it up again, and a report with no summary is still the log and the numbers. The prompt states the one permitted name twice and forbids arithmetic: a model asked to write warmly about a party will invent a second member of it, and a total it works out itself will contradict the exact totals printed beside it. Both were seen against the real box before this was tightened. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "<think>"); i != -1 {
|
||||
if j := strings.Index(s, "</think>"); j != -1 {
|
||||
s = s[:i] + s[j+len("</think>"):]
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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", "<think>hmm</think>\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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user