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 }