Files
Pete/internal/storage/run.go
T
prosolis 8c3f2b0d07 adventure: tell the story of a run, not just how it ended
Pete only ever heard that an expedition happened once it was over — a zone
cleared, a retreat, a death. The run itself was narrated into one Matrix DM
and thrown away. The map on the adventurer page has always shown where
somebody is; this shows what happened there.

Beats arrive on their own channel, append-only and idempotent on
(run_id, seq). They are the one thing gogobee pushes that is history rather
than state, so they accumulate instead of replacing — and they stay off the
dispatch queue so a chatty run can never spend the retry budget a death
dispatch depends on.

The run header is derived from the beats rather than pushed: a run whose
start beat never arrived still gets a readable, unattributed log instead of
being dropped for want of a name.

An unknown beat kind renders as its own noun rather than 400ing. That is the
same lesson the dispatch ingest learned the hard way, and the regression test
covers the class, not the case.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 16:33:48 -07:00

254 lines
9.3 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"`
}
// 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
}
// 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)
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)
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)`)
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); 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
}
if _, err := hstmt.Exec(b.RunID, b.Token, b.Name, b.Level, b.Zone,
b.TotalRooms, started, b.OccurredAt, endedAt, outcome); 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
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)
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
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)
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
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
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); 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
}