adventure: give a finished run a report worth sharing

The liveblog answers "what is happening" — it is capped, it scrolls, and six
hours after a run ends it is gone, because the adventurer page is about now.
Nothing answered the question asked afterwards, usually by somebody who wasn't
watching: what WAS that run. So a dispatch announcing a clear or a death was a
paragraph about an outcome with no way back to what produced it.

The report is that way back. The whole log uncapped, the numbers rolled up, and
the single worst hit the party took pulled out of the middle where it otherwise
reads as one line among forty. It is stable for a fortnight, which is what makes
it a thing worth linking from a dispatch and worth sending to somebody.

It is assembled from the same beats through the same renderer as the liveblog.
A report that told a different story from the log it was built out of would be
the more convincing of the two and the less true.

The summary is the exception and the only prose on the channel: gogobee's model
reads the finished run back and says what it was about, which is a judgement no
template makes. It rides a summary beat rather than its own endpoint, so it
inherits the whole channel — idempotent, retried, impossible to attach to a run
that doesn't exist — and it passes the same class of guard a dispatch lede does
before it reaches a public page.

Visibility is the adventurer page's rule exactly, and that matters more here
than anywhere: the report outlives the log by a fortnight and is linked from a
public dispatch, so it is the surface most likely to still be reachable after
somebody opts out. Coming off the board closes it, including through links
minted days earlier.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 17:11:04 -07:00
parent 8c3f2b0d07
commit b4a276da36
14 changed files with 1052 additions and 49 deletions
+30 -14
View File
@@ -43,6 +43,13 @@ 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
// deliberately confined to one beat kind ("summary"). gogobee's LLM reads the
// finished run back and says what it was about; Pete guards that text at
// ingest exactly as it guards a dispatch lede, then folds it onto the run
// header. It is never rendered as a log line — see renderRunBeat.
Prose string `json:"prose,omitempty"`
}
// Run is the header: who walked, where, and how it ended (if it has).
@@ -57,6 +64,7 @@ type Run struct {
UpdatedAt int64
EndedAt int64 // 0 = still walking
Outcome string
Summary string // LLM run summary, empty until the summary beat lands (or forever)
}
// Live reports whether this run is still in progress.
@@ -82,8 +90,8 @@ func AppendRunBeats(beats []RunBeat) error {
bstmt, err := tx.Prepare(`
INSERT OR IGNORE INTO adventure_run_beat
(run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
@@ -95,8 +103,8 @@ func AppendRunBeats(beats []RunBeat) error {
// beat's name and zone survive the forty beats after it that have neither.
hstmt, err := tx.Prepare(`
INSERT INTO adventure_run
(run_id, token, name, level, zone, total_rooms, started_at, updated_at, ended_at, outcome)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(run_id, token, name, level, zone, total_rooms, started_at, updated_at, ended_at, outcome, summary)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(run_id) DO UPDATE SET
token = COALESCE(NULLIF(excluded.token, ''), adventure_run.token),
name = COALESCE(NULLIF(excluded.name, ''), adventure_run.name),
@@ -106,7 +114,8 @@ func AppendRunBeats(beats []RunBeat) error {
started_at = COALESCE(NULLIF(adventure_run.started_at, 0), excluded.started_at),
updated_at = MAX(adventure_run.updated_at, excluded.updated_at),
ended_at = COALESCE(NULLIF(adventure_run.ended_at, 0), excluded.ended_at),
outcome = COALESCE(NULLIF(adventure_run.outcome, ''), excluded.outcome)`)
outcome = COALESCE(NULLIF(adventure_run.outcome, ''), excluded.outcome),
summary = COALESCE(NULLIF(adventure_run.summary, ''), excluded.summary)`)
if err != nil {
return err
}
@@ -118,7 +127,7 @@ func AppendRunBeats(beats []RunBeat) error {
}
if _, err := bstmt.Exec(b.RunID, b.Seq, b.Kind, b.OccurredAt, b.Room, b.TotalRooms,
b.RoomKind, b.Target, b.Outcome, b.Amount, b.Count, b.HP, b.HPMax,
b.Crits, b.Fumbles, b.Region); err != nil {
b.Crits, b.Fumbles, b.Region, b.Prose); err != nil {
return err
}
@@ -135,8 +144,15 @@ func AppendRunBeats(beats []RunBeat) error {
if b.Kind == "start" {
started = b.OccurredAt
}
// Only the summary beat may set the summary. Any other kind carrying prose
// is upstream noise, and letting it through would put unguarded text on the
// header — the guard at ingest only inspects the kind it knows about.
summary := ""
if b.Kind == "summary" {
summary = b.Prose
}
if _, err := hstmt.Exec(b.RunID, b.Token, b.Name, b.Level, b.Zone,
b.TotalRooms, started, b.OccurredAt, endedAt, outcome); err != nil {
b.TotalRooms, started, b.OccurredAt, endedAt, outcome, summary); err != nil {
return err
}
}
@@ -162,13 +178,13 @@ func LatestRunForToken(token string) (Run, bool, error) {
var r Run
err := Get().QueryRow(`
SELECT run_id, token, name, level, zone, total_rooms,
started_at, updated_at, ended_at, outcome
started_at, updated_at, ended_at, outcome, summary
FROM adventure_run
WHERE token = ?
ORDER BY (ended_at = 0) DESC, updated_at DESC, started_at DESC
LIMIT 1`, token).Scan(
&r.RunID, &r.Token, &r.Name, &r.Level, &r.Zone, &r.TotalRooms,
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome)
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome, &r.Summary)
if err == sql.ErrNoRows {
return Run{}, false, nil
}
@@ -183,10 +199,10 @@ func RunByID(runID string) (Run, bool, error) {
var r Run
err := Get().QueryRow(`
SELECT run_id, token, name, level, zone, total_rooms,
started_at, updated_at, ended_at, outcome
started_at, updated_at, ended_at, outcome, summary
FROM adventure_run WHERE run_id = ?`, runID).Scan(
&r.RunID, &r.Token, &r.Name, &r.Level, &r.Zone, &r.TotalRooms,
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome)
&r.StartedAt, &r.UpdatedAt, &r.EndedAt, &r.Outcome, &r.Summary)
if err == sql.ErrNoRows {
return Run{}, false, nil
}
@@ -206,14 +222,14 @@ func RunBeats(runID string, limit int) ([]RunBeat, error) {
return nil, nil
}
q := `SELECT run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose
FROM adventure_run_beat WHERE run_id = ? ORDER BY seq ASC`
args := []any{runID}
if limit > 0 {
// Innermost query takes the tail, the wrapper puts it back in order.
q = `SELECT * FROM (
SELECT run_id, seq, kind, occurred_at, room, total_rooms, room_kind,
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region
target, outcome, amount, qty, hp, hp_max, crits, fumbles, region, prose
FROM adventure_run_beat WHERE run_id = ? ORDER BY seq DESC LIMIT ?
) ORDER BY seq ASC`
args = append(args, limit)
@@ -229,7 +245,7 @@ func RunBeats(runID string, limit int) ([]RunBeat, error) {
var b RunBeat
if err := rows.Scan(&b.RunID, &b.Seq, &b.Kind, &b.OccurredAt, &b.Room, &b.TotalRooms,
&b.RoomKind, &b.Target, &b.Outcome, &b.Amount, &b.Count, &b.HP, &b.HPMax,
&b.Crits, &b.Fumbles, &b.Region); err != nil {
&b.Crits, &b.Fumbles, &b.Region, &b.Prose); err != nil {
return nil, err
}
out = append(out, b)