Files
prosolis b4a276da36 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
2026-07-24 17:11:04 -07:00

270 lines
10 KiB
Go

package storage
import (
"database/sql"
)
// The expedition liveblog, as gogobee beats it out room by room.
//
// Everything else the game pushes is a snapshot: the board, the war room, a
// player's own sheet. Those get replaced whole, because they describe what is
// currently true. Beats are the opposite kind of thing — each one is a moment
// that happened, and the only correction a later push can make to a moment is to
// add another one after it. So this is append-only, keyed on (run_id, seq), and
// a re-sent batch collapses on the primary key instead of duplicating a story.
//
// The run header is *derived*, not pushed. gogobee sends beats and nothing else;
// AppendRunBeats folds the identifying ones into adventure_run as they arrive.
// The upside is that a run missing its `start` beat still has a log — anonymous,
// but readable — rather than being discarded for want of a name to hang it on.
// RunBeat is one moment inside a run, exactly as gogobee filed it. Nouns and
// numbers only: Pete writes the sentence, the same split every dispatch fact
// already respects.
type RunBeat struct {
RunID string `json:"run_id"`
Seq int64 `json:"seq"`
Kind string `json:"kind"`
OccurredAt int64 `json:"occurred_at"`
Token string `json:"token,omitempty"`
Name string `json:"name,omitempty"`
Level int `json:"level,omitempty"`
Zone string `json:"zone,omitempty"`
Region string `json:"region,omitempty"`
Room int `json:"room,omitempty"`
TotalRooms int `json:"total_rooms,omitempty"`
RoomKind string `json:"room_kind,omitempty"`
Target string `json:"target,omitempty"`
Outcome string `json:"outcome,omitempty"`
Amount int `json:"amount,omitempty"`
Count int `json:"count,omitempty"`
HP int `json:"hp,omitempty"`
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).
type Run struct {
RunID string
Token string
Name string
Level int
Zone string
TotalRooms int
StartedAt int64
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.
func (r Run) Live() bool { return r.EndedAt == 0 }
// AppendRunBeats stores a batch and folds each beat into its run header, in one
// transaction so a reader never sees a header that has moved ahead of its beats.
//
// INSERT OR IGNORE, not upsert: a beat is immutable. If gogobee re-sends
// (run_id, seq) the stored copy wins, which makes a duplicated batch free and
// makes a *changed* beat impossible — the second is a bug upstream, and quietly
// rewriting history to match it would hide that.
func AppendRunBeats(beats []RunBeat) error {
if len(beats) == 0 {
return nil
}
tx, err := Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
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, prose)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer bstmt.Close()
// The header is created by whichever beat arrives first and enriched by any
// later one that knows more. COALESCE(NULLIF(...)) is the whole trick: a beat
// that doesn't carry a field leaves the stored value alone, so the `start`
// 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, 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),
level = COALESCE(NULLIF(excluded.level, 0), adventure_run.level),
zone = COALESCE(NULLIF(excluded.zone, ''), adventure_run.zone),
total_rooms = COALESCE(NULLIF(excluded.total_rooms, 0), adventure_run.total_rooms),
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),
summary = COALESCE(NULLIF(adventure_run.summary, ''), excluded.summary)`)
if err != nil {
return err
}
defer hstmt.Close()
for _, b := range beats {
if b.RunID == "" {
continue
}
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, b.Prose); err != nil {
return err
}
// A run ends once. First close wins here for the same reason it does on
// the game side: the specific outcome ("died") is filed before the generic
// one ("abandoned") that follows it down the same drain.
var endedAt int64
var outcome string
if b.Kind == "end" {
endedAt = b.OccurredAt
outcome = b.Outcome
}
started := int64(0)
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, summary); err != nil {
return err
}
}
return tx.Commit()
}
// LatestRunForToken returns the run worth showing on one adventurer's page.
//
// A LIVE run always wins, and only then does recency decide. That ordering is
// load-bearing at exactly one moment and it is a moment that happens on every
// multi-region expedition: crossing a border closes one run and opens the next
// in the same breath, so the outgoing run's `end` beat and the incoming run's
// `start` beat carry the same second. Ordering on the clock alone would leave
// the page showing the log of a region the party has already left, with a
// "cleared" chip on it, while they walk on in the next one.
//
// Recency is updated_at rather than started_at for the same reason: the run
// still moving is the one still being written to.
func LatestRunForToken(token string) (Run, bool, error) {
if token == "" {
return Run{}, false, nil
}
var r Run
err := Get().QueryRow(`
SELECT run_id, token, name, level, zone, total_rooms,
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.Summary)
if err == sql.ErrNoRows {
return Run{}, false, nil
}
if err != nil {
return Run{}, false, err
}
return r, true, nil
}
// RunByID returns one run header.
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, 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.Summary)
if err == sql.ErrNoRows {
return Run{}, false, nil
}
if err != nil {
return Run{}, false, err
}
return r, true, nil
}
// RunBeats returns a run's beats in the order they happened.
//
// limit caps from the END, not the start: a log is read for what just happened,
// and a run deep into its third region would otherwise show its first forty
// beats forever. The returned slice is still oldest-first.
func RunBeats(runID string, limit int) ([]RunBeat, error) {
if runID == "" {
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, 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, prose
FROM adventure_run_beat WHERE run_id = ? ORDER BY seq DESC LIMIT ?
) ORDER BY seq ASC`
args = append(args, limit)
}
rows, err := Get().Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RunBeat
for rows.Next() {
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, &b.Prose); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// PruneRuns drops runs (and their beats) that ended before cutoff. Live runs are
// never touched however old they look — a run that has been walking for a week
// is a stuck expedition, and deleting its log is exactly the wrong response to
// the one case where somebody wants to read it.
func PruneRuns(cutoff int64) error {
if _, err := Get().Exec(`
DELETE FROM adventure_run_beat
WHERE run_id IN (SELECT run_id FROM adventure_run WHERE ended_at > 0 AND ended_at < ?)`,
cutoff); err != nil {
return err
}
_, err := Get().Exec(`DELETE FROM adventure_run WHERE ended_at > 0 AND ended_at < ?`, cutoff)
return err
}