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
This commit is contained in:
@@ -152,6 +152,15 @@ func RunMaintenance() {
|
||||
exec("prune old daily_visitors",
|
||||
`DELETE FROM daily_visitors WHERE day < ?`, unixDay()-30)
|
||||
|
||||
// Finished expedition logs. Kept for longer than the page shows them (the
|
||||
// adventurer page hides a run six hours after it ends) because the dispatch
|
||||
// that announced the run outlives the run, and a dead link from a story to
|
||||
// its own log is worse than a log nobody reads. A run still walking is never
|
||||
// pruned however old it looks — see PruneRuns for why.
|
||||
if err := PruneRuns(nowUnix() - int64(14*86400)); err != nil {
|
||||
slog.Error("db exec failed", "op", "prune finished runs", "err", err)
|
||||
}
|
||||
|
||||
exec("wal checkpoint", "PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
exec("optimize", "PRAGMA optimize")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
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
|
||||
}
|
||||
@@ -144,6 +144,54 @@ CREATE TABLE IF NOT EXISTS adventure_siege_history (
|
||||
ended_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- The expedition liveblog. Two tables, and — unlike everything else gogobee
|
||||
-- pushes — these are append-only history, not a replaceable snapshot. A beat is
|
||||
-- something that HAPPENED; there is no later truth that corrects it, only more
|
||||
-- of it.
|
||||
--
|
||||
-- adventure_run is the header, assembled from the run's beats rather than pushed
|
||||
-- as its own object: the "start" beat opens it and the "end" beat closes it.
|
||||
-- That means a run whose start beat never arrived still gets a row (created by
|
||||
-- whatever beat did arrive) and is simply unattributed — the log survives
|
||||
-- nameless instead of being dropped for want of a name.
|
||||
CREATE TABLE IF NOT EXISTS adventure_run (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL DEFAULT '', -- public board token; '' = unattributed
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
zone TEXT NOT NULL DEFAULT '',
|
||||
total_rooms INTEGER NOT NULL DEFAULT 0,
|
||||
started_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0,
|
||||
ended_at INTEGER NOT NULL DEFAULT 0, -- 0 = still walking
|
||||
outcome TEXT NOT NULL DEFAULT '' -- cleared|died|retreated|abandoned
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_run_token ON adventure_run(token, started_at DESC);
|
||||
|
||||
-- (run_id, seq) is the identity, so a re-sent batch collapses on the primary key
|
||||
-- and needs no content comparison. seq is gogobee's monotonic counter, which is
|
||||
-- also the render order — beats can arrive out of order across two batches and
|
||||
-- still read correctly.
|
||||
CREATE TABLE IF NOT EXISTS adventure_run_beat (
|
||||
run_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
occurred_at INTEGER NOT NULL DEFAULT 0,
|
||||
room INTEGER NOT NULL DEFAULT 0,
|
||||
total_rooms INTEGER NOT NULL DEFAULT 0,
|
||||
room_kind TEXT NOT NULL DEFAULT '',
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL DEFAULT '',
|
||||
amount INTEGER NOT NULL DEFAULT 0,
|
||||
qty INTEGER NOT NULL DEFAULT 0,
|
||||
hp INTEGER NOT NULL DEFAULT 0,
|
||||
hp_max INTEGER NOT NULL DEFAULT 0,
|
||||
crits INTEGER NOT NULL DEFAULT 0,
|
||||
fumbles INTEGER NOT NULL DEFAULT 0,
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (run_id, seq)
|
||||
);
|
||||
|
||||
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
|
||||
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
|
||||
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
|
||||
|
||||
Reference in New Issue
Block a user