adventure: send Pete the run, room by room

The engine narrates every fight, trap and haul to one Matrix DM and then
discards the shape underneath it. This records that shape as it happens so
Pete can retell the run to somebody who wasn't in the room.

Beats ride the roster ticker (one extra request per two minutes, not one per
room) but on their own table, never pete_emit_queue: they are high-volume and
low-stakes, and a chatty run must not be able to spend the retry budget a
death dispatch depends on. Unlike the snapshots they ARE retried — a dropped
beat is a hole in a story, not a stale number the next tick corrects.

Recording is a leaf everywhere it is called. If a beat can't be written the
walk carries on exactly as it did before this existed.

Privacy is stricter here than on the board. The board omits an opted-out
player from a snapshot; a liveblog would be an account of where they are and
what is happening to them, so their beats never leave the box at all — and a
run whose owner can't be resolved is refused rather than published.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 16:34:02 -07:00
parent 0570afc2e4
commit a5b1961486
13 changed files with 953 additions and 3 deletions
+23
View File
@@ -700,6 +700,11 @@ func RunMaintenance() {
// weight the drain query already skips — drop it so a durable outage
// can't accrete rows forever.
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}},
// Run beats — the local copy is a delivery buffer, not an archive. Pete
// keeps the run report; once a beat is a week old it has either shipped
// or missed its window entirely (the liveblog it feeds is about a run
// happening *now*), so both states reap on the same clock.
{"pete_run_beat", `DELETE FROM pete_run_beat WHERE occurred_at < ?`, []interface{}{cutoff7d}},
// Rate limits — purge entries older than today
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
@@ -1045,6 +1050,24 @@ CREATE TABLE IF NOT EXISTS pete_emit_queue (
sent_at INTEGER
);
-- Run beats: the room-by-room texture of an expedition, on its way to Pete's
-- liveblog. Deliberately NOT pete_emit_queue — these are high-volume and
-- low-stakes, and a run that generates forty beats must never be able to crowd
-- a death dispatch out of the retry budget. Ordering is the whole contract:
-- (run_id, seq) is the primary key and Pete is idempotent on the pair, so a
-- re-sent batch collapses and a re-ordered one still sorts right on arrival.
CREATE TABLE IF NOT EXISTS pete_run_beat (
run_id TEXT NOT NULL,
seq INTEGER NOT NULL,
kind TEXT NOT NULL,
occurred_at INTEGER NOT NULL DEFAULT (unixepoch()),
payload TEXT NOT NULL DEFAULT '{}',
sent_at INTEGER,
PRIMARY KEY (run_id, seq)
);
CREATE INDEX IF NOT EXISTS idx_pete_run_beat_unsent
ON pete_run_beat(sent_at, run_id, seq);
-- Players who opted out of being named in Pete's adventure news. Enforced at
-- emit time (anonymize, never delete). Mirrors shade_optout.
CREATE TABLE IF NOT EXISTS news_optout (