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
+53
View File
@@ -443,6 +443,59 @@ func PushSiege(ctx context.Context, snap SiegeSnapshot) error {
return std.post(ctx, "/api/ingest/siege", payload)
}
// RunBeat is one structured moment inside an expedition run: a room entered, a
// fight resolved, a trap sprung, a haul taken. Facts, never prose — Pete owns
// the words, exactly as it does for a Fact. The engine already narrates every
// one of these to Matrix and then throws the narration away; this carries the
// shape underneath it so Pete can retell the run to somebody who wasn't there.
//
// (RunID, Seq) is the identity. Seq is monotonic per run and assigned at record
// time, so Pete can order a batch that arrives out of order and drop a duplicate
// without comparing contents.
//
// Nothing here is player-identifying except Token, which rides the `start` beat
// only and is the same public board token the roster uses. An opted-out player's
// beats are never pushed at all — see pushRunBeats.
type RunBeat struct {
RunID string `json:"run_id"`
Seq int64 `json:"seq"`
Kind string `json:"kind"` // start|room|combat|trap|treasure|haul|lock|camp|region|end
OccurredAt int64 `json:"occurred_at"`
Token string `json:"token,omitempty"` // `start` only: whose run this is
Name string `json:"name,omitempty"` // `start` only: character name
Level int `json:"level,omitempty"` // `start` only
Zone string `json:"zone,omitempty"`
Region string `json:"region,omitempty"`
Room int `json:"room,omitempty"` // 1-based, as the player sees it
TotalRooms int `json:"total_rooms,omitempty"` // 0 when unknown
RoomKind string `json:"room_kind,omitempty"` // entry|exploration|trap|elite|boss|secret
Target string `json:"target,omitempty"` // monster, item, region, lock — the noun
Outcome string `json:"outcome,omitempty"`
Amount int `json:"amount,omitempty"` // damage taken, or a total quantity
Count int `json:"count,omitempty"` // how many distinct things Amount covers
HP int `json:"hp,omitempty"`
HPMax int `json:"hp_max,omitempty"`
Crits int `json:"crits,omitempty"`
Fumbles int `json:"fumbles,omitempty"`
}
// PushRunBeats delivers a batch of beats. Unlike the snapshots this is
// append-only and IS retried — a dropped beat is a hole in a story, not a stale
// number that the next tick corrects. The caller only marks rows sent on success.
func PushRunBeats(ctx context.Context, beats []RunBeat) error {
if !Enabled() || len(beats) == 0 {
return nil
}
payload, err := json.Marshal(struct {
Beats []RunBeat `json:"beats"`
}{beats})
if err != nil {
return err
}
return std.post(ctx, "/api/ingest/run", payload)
}
// PlayerDetail is the private, owner-only expansion for one player: inventory,
// vault, house, and pets. Like MischiefBalance it is keyed by localpart (the
// sign-in name), in its own keyspace on Pete — Pete only ever serves it back to