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
+27 -21
View File
@@ -24,21 +24,25 @@ import (
// former transport field kept here: for a treasure_found it carries the item's
// name, which is the fact, not the delivery.
type AdvEvent struct {
GUID string `json:"guid"`
EventType string `json:"event_type"`
Tier string `json:"tier"`
Subject string `json:"subject"`
Opponent string `json:"opponent"`
Boss string `json:"boss"`
Zone string `json:"zone"`
Region string `json:"region"`
Level int `json:"level"`
Tally int `json:"tally"`
Outcome string `json:"outcome"`
Milestone string `json:"milestone"`
Stakes string `json:"stakes"`
Actors []string `json:"actors"`
OccurredAt int64 `json:"occurred_at"`
GUID string `json:"guid"`
EventType string `json:"event_type"`
Tier string `json:"tier"`
Subject string `json:"subject"`
Opponent string `json:"opponent"`
Boss string `json:"boss"`
Zone string `json:"zone"`
Region string `json:"region"`
Level int `json:"level"`
Tally int `json:"tally"`
Outcome string `json:"outcome"`
Milestone string `json:"milestone"`
Stakes string `json:"stakes"`
Actors []string `json:"actors"`
// RunID is set on the dispatches that are the *ending* of an expedition — a
// clear, a retreat, a death. It is the join from "how it went" to "what
// happened", and it is empty on every other kind of fact.
RunID string `json:"run_id,omitempty"`
OccurredAt int64 `json:"occurred_at"`
}
// InsertAdventureEvent records a fact. Idempotent on guid via INSERT OR IGNORE:
@@ -54,10 +58,11 @@ func InsertAdventureEvent(e *AdvEvent) error {
_, err = Get().Exec(`
INSERT OR IGNORE INTO adventure_events
(guid, event_type, tier, subject, opponent, boss, zone, region,
level, tally, outcome, milestone, stakes, actors, occurred_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
level, tally, outcome, milestone, stakes, actors, run_id, occurred_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.GUID, e.EventType, e.Tier, e.Subject, e.Opponent, e.Boss, e.Zone,
e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors), e.OccurredAt)
e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors),
e.RunID, e.OccurredAt)
return err
}
@@ -71,13 +76,13 @@ func AdventureEventByGUID(guid string) (*AdvEvent, error) {
return nil, nil
}
var e AdvEvent
var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors sql.NullString
var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors, runID sql.NullString
err := Get().QueryRow(`
SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
level, tally, outcome, milestone, stakes, actors, occurred_at
level, tally, outcome, milestone, stakes, actors, run_id, occurred_at
FROM adventure_events WHERE guid = ?`, guid).Scan(
&e.GUID, &e.EventType, &tier, &subject, &opponent, &boss, &zone, &region,
&e.Level, &e.Tally, &outcome, &milestone, &stakes, &actors, &e.OccurredAt)
&e.Level, &e.Tally, &outcome, &milestone, &stakes, &actors, &runID, &e.OccurredAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -87,6 +92,7 @@ func AdventureEventByGUID(guid string) (*AdvEvent, error) {
e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String
e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String
e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
e.RunID = runID.String
if actors.String != "" {
_ = json.Unmarshal([]byte(actors.String), &e.Actors)
}
+10
View File
@@ -105,6 +105,16 @@ func runMigrations(d *sql.DB) error {
// recorded before the treasure_found event existed carry NULL, which is right:
// they had no such noun to keep.
addColumnIfMissing(d, "adventure_events", "stakes", "TEXT")
// The run behind a dispatch that *ended* one. NULL on every fact filed before
// the run report existed and on every fact that isn't the end of an
// expedition; both simply render without the "read the run" link.
addColumnIfMissing(d, "adventure_events", "run_id", "TEXT")
// The liveblog's late-arriving prose and the column that carries it. Both are
// in their tables' CREATE TABLE — those tables have never shipped — so these
// two adds exist only for a database that already ran an earlier build of the
// run-liveblog branch.
addColumnIfMissing(d, "adventure_run", "summary", "TEXT NOT NULL DEFAULT ''")
addColumnIfMissing(d, "adventure_run_beat", "prose", "TEXT NOT NULL DEFAULT ''")
// Ask 7: upgrade orders carry a target tier for the 5 standard equipment slots.
addColumnIfMissing(d, "equip_orders", "tier", "INTEGER NOT NULL DEFAULT 0")
+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)
+21 -1
View File
@@ -88,6 +88,13 @@ CREATE TABLE IF NOT EXISTS adventure_events (
stakes TEXT, -- free-text noun the fact is about: a bounty, a found treasure's name
actors TEXT, -- JSON array; the fact-guard allow-list, kept for audit
-- The expedition this dispatch is the ending of, when it is the ending of one
-- (a clear, a retreat, a death). It is the join from "how it went" to "what
-- happened", and it is the reason Pete keeps a finished run's beats for two
-- weeks while only *showing* them for six hours: the dispatch outlives the run
-- it announced, and a story that can't reach its own log is the whole point
-- of the log going missing.
run_id TEXT,
occurred_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_adv_events_subject ON adventure_events(subject, occurred_at DESC);
@@ -154,6 +161,13 @@ CREATE TABLE IF NOT EXISTS adventure_siege_history (
-- 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.
--
-- summary is the one piece of PROSE anywhere in the liveblog. Every log line is
-- assembled by Pete out of a beat's own nouns and numbers; this is gogobee's LLM
-- reading the finished run back and saying what it was *about*, which is a
-- judgement no template can make. It arrives late — its own beat, a tick or two
-- after the run ends — and it is optional forever: with the model off, the report
-- is the log plus the numbers, which is still the report.
CREATE TABLE IF NOT EXISTS adventure_run (
run_id TEXT PRIMARY KEY,
token TEXT NOT NULL DEFAULT '', -- public board token; '' = unattributed
@@ -164,7 +178,8 @@ CREATE TABLE IF NOT EXISTS adventure_run (
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
outcome TEXT NOT NULL DEFAULT '', -- cleared|died|retreated|abandoned
summary TEXT NOT NULL DEFAULT '' -- LLM run summary, post prose-guard
);
CREATE INDEX IF NOT EXISTS idx_adv_run_token ON adventure_run(token, started_at DESC);
@@ -189,6 +204,11 @@ CREATE TABLE IF NOT EXISTS adventure_run_beat (
crits INTEGER NOT NULL DEFAULT 0,
fumbles INTEGER NOT NULL DEFAULT 0,
region TEXT NOT NULL DEFAULT '',
-- prose is carried by exactly one beat kind ("summary") and is never rendered
-- as a log line. It lives on the beat rather than on its own endpoint so the
-- summary inherits the whole channel: idempotent on (run_id, seq), retried
-- until delivered, and impossible to attach to a run that doesn't exist.
prose TEXT NOT NULL DEFAULT '',
PRIMARY KEY (run_id, seq)
);