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 (
+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
+2
View File
@@ -931,6 +931,7 @@ func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf
if err := removeAdvInventoryItem(spendTool); err != nil {
slog.Warn("expedition: autopilot tools spend", "user", run.UserID, "err", err)
}
beatLock(run, chosen.Label, "picked")
}
fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
if exp != nil {
@@ -993,6 +994,7 @@ func (p *AdventurePlugin) backtrackFromDeadFork(exp *Expedition, run *DungeonRun
slog.Warn("expedition: backtrack clear fork", "run", run.RunID, "err", err)
return false
}
beatLock(run, "", "sealed")
if _, err := revisitZoneRun(run.RunID, target, run.VisitedNodes); err != nil {
slog.Warn("expedition: backtrack from dead fork", "run", run.RunID, "err", err)
return false
@@ -170,6 +170,13 @@ func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition,
tc := resolveTransitWanderingCheck(exp, charClass, nil)
_ = processTransitWanderingCheck(exp, tc)
// The liveblog beat goes on the *outgoing* run, and it has to be filed before
// the run is retired — a region crossing is the last thing that happens in
// the region being left, and it is what explains why that run's log stops.
if outgoing, _ := getZoneRun(exp.RunID); outgoing != nil {
beatRegion(outgoing, cur.Name, next.Name)
}
// R2 — retire the outgoing region's DungeonRun before mutating
// CurrentRegion so retireRegionRun keys the right region.
if err := retireRegionRun(exp, cur.ID); err != nil {
+17 -1
View File
@@ -893,6 +893,7 @@ func (p *AdventurePlugin) runHarvestForAdvance(
if herr != nil {
return autoHarvestResult{}, ""
}
beatHaul(fresh, hr.Summary)
return hr, renderAutoHarvestFooter(hr.Summary)
}
@@ -1061,7 +1062,8 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
case RoomEntry:
return
case RoomTrap:
_, narration := p.resolveTrapRoom(userID, run, zone)
damage, narration := p.resolveTrapRoom(userID, run, zone)
beatTrap(userID, run, damage)
outcome = narration
return
case RoomExploration:
@@ -1125,6 +1127,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
result := pres.Seats[0]
postHP, maxHP := dndHPSnapshot(userID)
nat20s, nat1s := scanMoodEventsFromEvents(run.RunID, pres.Events)
// One beat for the fight, filed here rather than on each of the three
// outcome branches below — every one of them passes through this point with
// the result already decided, and a single site can't drift out of step with
// the others.
beatCombat(run, monster.Name, elite, isBoss, result.PlayerWon, result.TimedOut,
preHP, postHP, maxHP, nat20s, nat1s)
// Compact mode: skip TwinBee banter, skip the multi-beat play-by-play.
// Render a single outcome line. Still records kills, threat, and drops.
@@ -1226,6 +1234,14 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
// tryPatrolEncounter); see retreatThreatBump in
// dnd_expedition_combat.go.
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
// Ahead of abandonZoneRun, which would close the story as "abandoned" —
// the difference between being killed and running out of clock is the
// most interesting fact in the whole log.
if result.TimedOut {
beatRunEnd(run, "retreated")
} else {
beatRunEnd(run, "died")
}
_ = abandonZoneRun(userID)
// Timeout loss = retreat; the fighters took wounds but nobody actually
// died. Don't fire markAdventureDead — that would trigger the 6h respawn
+6
View File
@@ -238,6 +238,7 @@ func (p *AdventurePlugin) resolveSecretRoom(userID id.UserID, run *DungeonRun, z
it := item
if line := p.grantZoneItem(userID, &it, "🧪"); line != "" {
rewards = append(rewards, line)
beatTreasure(run, it.Name, "cache")
}
}
@@ -392,6 +393,11 @@ func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone Z
slog.Error("zone: addLoot audit", "user", userID, "item", entry.ItemID, "err", err)
}
granted = append(granted, entry.ItemID)
source := "zone"
if bossCleared {
source = "boss"
}
beatTreasure(run, item.Name, source)
}
return granted
}
+8
View File
@@ -295,6 +295,7 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
); err != nil {
return nil, fmt.Errorf("insert zone run: %w", err)
}
beatRunStart(userID, run, zone)
return run, nil
}
@@ -581,6 +582,7 @@ func abandonZoneRun(userID id.UserID) error {
if r == nil {
return ErrNoActiveRun
}
beatRunEnd(r, "abandoned")
_, err = db.Get().Exec(`
UPDATE dnd_zone_run
SET abandoned = 1,
@@ -599,6 +601,12 @@ func abandonZoneRunByID(runID string) error {
if runID == "" {
return nil
}
// The generic funnel: it fires for an idle reap, a region retirement, and a
// completed run being tidied up alike. beatRunEnd is first-writer-wins, so
// this only ever supplies the outcome nothing more specific already did.
if r, _ := getZoneRun(runID); r != nil {
beatRunEnd(r, "abandoned")
}
_, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET abandoned = 1,
+1
View File
@@ -53,6 +53,7 @@ func (p *AdventurePlugin) peteRosterTicker() {
p.pushRoster()
p.pushDetails()
p.pushSiege()
p.pushRunBeats()
}
}
+238
View File
@@ -0,0 +1,238 @@
package plugin
import (
"context"
"encoding/json"
"log/slog"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// Run beats — the room-by-room texture of an expedition, on its way to Pete.
//
// Until now Pete learned that an expedition happened only when it ended: a
// zone_clear, a retreat, a death. The run itself — the fight that nearly went
// wrong, the trap, the haul — was narrated to one Matrix DM and then discarded.
// This records the shape of each moment as it happens so Pete can retell it.
//
// Three rules hold the design together:
//
// - **Facts, not prose.** A beat carries nouns and numbers; the engine's
// narration stays in Matrix. Pete owns the words, the same split every Fact
// already respects.
// - **Its own channel.** Beats never touch 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.
// - **Never block, never fail the game.** recordRunBeat swallows its errors to
// a log line. A liveblog is a nice-to-have; the walk it is watching is not.
//
// Delivery rides the roster ticker (one extra request per 2 minutes, not one per
// room) but unlike the roster it is retried, because a dropped beat is a hole in
// a story rather than a stale number the next snapshot corrects.
// runBeatBatch bounds one push. A busy realm mid-evening might produce a few
// hundred beats between ticks; this keeps any single request small and lets the
// backlog drain over a few ticks instead of one enormous POST.
const runBeatBatch = 200
// recordRunBeat appends a beat to the outbound buffer. Never returns an error:
// every caller is on the walk's hot path and none of them can do anything useful
// with a failure to log a story.
//
// Seq is assigned by the INSERT itself (MAX+1 within the statement), so two
// concurrent writers on the same run can't collide on a number — SQLite
// serialises the statement, and the primary key would reject the loser anyway.
func recordRunBeat(runID string, b peteclient.RunBeat) {
if runID == "" || !peteclient.Enabled() || !newsEmissionOn() {
return
}
b.RunID = runID
if b.OccurredAt == 0 {
b.OccurredAt = nowUnix()
}
// Seq and RunID live in columns; the rest of the beat is the payload, so
// adding a field later needs no migration.
kind, occurred := b.Kind, b.OccurredAt
b.Seq = 0
payload, err := json.Marshal(b)
if err != nil {
slog.Debug("runbeat: marshal failed", "run", runID, "kind", kind, "err", err)
return
}
if _, err := db.Get().Exec(`
INSERT INTO pete_run_beat (run_id, seq, kind, occurred_at, payload)
SELECT ?, COALESCE(MAX(seq), 0) + 1, ?, ?, ?
FROM pete_run_beat WHERE run_id = ?`,
runID, kind, occurred, string(payload), runID); err != nil {
slog.Debug("runbeat: record failed", "run", runID, "kind", kind, "err", err)
}
}
// runHasEndBeat reports whether this run's story has already been closed. Cheap
// enough to ask at every end site because a run only ends once; see beatRunEnd
// for why the first answer is the one that must stick.
func runHasEndBeat(runID string) bool {
if runID == "" || !peteclient.Enabled() {
return false
}
var n int
err := db.Get().QueryRow(
`SELECT COUNT(*) FROM pete_run_beat WHERE run_id = ? AND kind = 'end'`, runID).Scan(&n)
return err == nil && n > 0
}
// runBeatPushOK mirrors rosterPushOK: log the transitions, stay quiet otherwise.
var runBeatPushOK bool
// pushRunBeats drains the unsent buffer to Pete. Called from the roster ticker.
func (p *AdventurePlugin) pushRunBeats() {
beats, drop, err := loadRunBeatBatch(runBeatBatch)
if err != nil {
slog.Error("runbeat: load batch failed", "err", err)
return
}
// Beats belonging to an opted-out player are retired locally without ever
// going out. Marking them sent (rather than deleting) keeps one code path for
// "this row is done with" and lets the retention sweep reap them on its own
// clock. Opting back in mid-run loses the earlier beats, which is the right
// way round to be wrong.
if len(drop) > 0 {
if err := markRunBeatsSent(drop); err != nil {
slog.Warn("runbeat: retire opted-out beats", "err", err)
}
}
if len(beats) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), rosterPushTimeout)
defer cancel()
if err := peteclient.PushRunBeats(ctx, beats); err != nil {
if runBeatPushOK {
slog.Warn("runbeat: push failed, liveblog will lag on Pete", "err", err, "beats", len(beats))
} else {
slog.Debug("runbeat: push failed, will retry next tick", "err", err)
}
runBeatPushOK = false
return // rows stay unsent: this is the one push that retries
}
if err := markRunBeatsSent(beats); err != nil {
// Delivered but not marked. Pete is idempotent on (run_id, seq), so the
// re-send next tick is a no-op there — better than dropping the row.
slog.Warn("runbeat: mark sent failed, beats will re-send", "err", err)
}
if !runBeatPushOK {
slog.Info("runbeat: liveblog accepted by Pete", "beats", len(beats))
runBeatPushOK = true
}
}
// loadRunBeatBatch reads up to limit unsent beats in (run, seq) order and splits
// them into the ones to send and the ones to retire unsent.
//
// It drains the cursor completely before resolving a single owner, and that is
// not a style preference. The pool is one connection wide, so a query issued
// while these rows are still open waits for a connection that this loop is
// holding and will not release until the loop ends — a deadlock that the roster
// ticker would hit on its very first tick with any beat in the buffer.
//
// The opt-out check is then per *run*, resolved once and cached for the batch: a
// run belongs to exactly one player, and re-asking per beat would turn a 200-row
// batch into 200 lookups of an answer that cannot change inside one tick.
func loadRunBeatBatch(limit int) (send []peteclient.RunBeat, drop []peteclient.RunBeat, err error) {
type row struct {
runID string
seq int64
payload string
}
rows, qerr := db.Get().Query(`
SELECT run_id, seq, payload
FROM pete_run_beat
WHERE sent_at IS NULL
ORDER BY run_id ASC, seq ASC
LIMIT ?`, limit)
if qerr != nil {
return nil, nil, qerr
}
var raw []row
for rows.Next() {
var r row
if err := rows.Scan(&r.runID, &r.seq, &r.payload); err != nil {
rows.Close()
return nil, nil, err
}
raw = append(raw, r)
}
rerr := rows.Err()
rows.Close()
if rerr != nil {
return nil, nil, rerr
}
allowed := map[string]bool{}
for _, r := range raw {
var b peteclient.RunBeat
if err := json.Unmarshal([]byte(r.payload), &b); err != nil {
// An undecodable row is dead weight forever; retire it rather than
// letting it head the queue and block every beat behind it.
slog.Warn("runbeat: undecodable payload, retiring", "run", r.runID, "seq", r.seq, "err", err)
drop = append(drop, peteclient.RunBeat{RunID: r.runID, Seq: r.seq})
continue
}
b.RunID, b.Seq = r.runID, r.seq
ok, known := allowed[r.runID]
if !known {
ok = runBeatAllowed(r.runID)
allowed[r.runID] = ok
}
if ok {
send = append(send, b)
} else {
drop = append(drop, b)
}
}
return send, drop, nil
}
// runBeatAllowed reports whether this run's beats may leave the box. A run whose
// owner can't be resolved is refused: the liveblog is a public surface, and
// "don't know who this is" is not a safe basis for publishing where they are.
func runBeatAllowed(runID string) bool {
run, err := getZoneRun(runID)
if err != nil || run == nil || run.UserID == "" {
return false
}
return !isNewsOptedOut(id.UserID(run.UserID))
}
// markRunBeatsSent stamps a batch delivered, in one transaction so a crash
// mid-mark can't leave half a run looking unsent and re-send it.
func markRunBeatsSent(beats []peteclient.RunBeat) error {
if len(beats) == 0 {
return nil
}
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
stmt, err := tx.Prepare(`UPDATE pete_run_beat SET sent_at = ? WHERE run_id = ? AND seq = ?`)
if err != nil {
return err
}
defer stmt.Close()
now := time.Now().UTC().Unix()
for _, b := range beats {
if _, err := stmt.Exec(now, b.RunID, b.Seq); err != nil {
return err
}
}
return tx.Commit()
}
+241
View File
@@ -0,0 +1,241 @@
package plugin
import (
"sort"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// The emit half of the run liveblog: the handful of places in the walk that
// know something worth telling, and the shape they tell it in.
//
// Every function here is a leaf. They read state, they append a row, they return
// nothing. None of them is allowed to change what the engine does or how long it
// takes to do it — if a beat can't be recorded, the run carries on exactly as it
// did before this file existed.
//
// The nouns are the payload and the numbers are the payload. No sentence
// assembled here ever reaches a reader: Pete writes the words, the same contract
// emitFact has always had.
// beatRunStart opens a run's story: who, where, and how far it goes. The token
// is the public board token, so Pete can hang the liveblog off the adventurer
// page the roster already links to.
//
// This is the only beat carrying identity. Every beat after it is keyed on the
// run id alone, which means a run whose start beat was dropped is anonymous
// rather than misattributed.
func beatRunStart(userID id.UserID, run *DungeonRun, zone ZoneDefinition) {
if run == nil {
return
}
b := peteclient.RunBeat{
Kind: "start",
Token: eventToken(userID, "roster"),
Zone: zone.Display,
TotalRooms: run.TotalRooms,
Room: 1,
RoomKind: string(RoomEntry),
}
if name := charName(userID); name != "" {
b.Name = name
}
if c, err := LoadDnDCharacter(userID); err == nil && c != nil && !c.PendingSetup {
b.Level = c.Level
}
recordRunBeat(run.RunID, b)
}
// beatRoom records an arrival. outcome distinguishes walking on from doubling
// back — the map on the who page already shows *where* the party is, and the
// difference between those two is most of what the log adds to it.
func beatRoom(run *DungeonRun, node string, idx int, outcome string) {
if run == nil {
return
}
b := peteclient.RunBeat{
Kind: "room",
Room: idx + 1,
TotalRooms: run.TotalRooms,
Outcome: outcome,
}
if g, ok := loadZoneGraph(run.ZoneID); ok {
if n, exists := g.Nodes[node]; exists {
b.RoomKind = string(nodeKindToRoomType(n.Kind))
}
}
recordRunBeat(run.RunID, b)
}
// beatCombat records one resolved fight: what it was, how it went, and what it
// cost. Amount is damage taken by the party's leader — the HP pair is the state
// after, so a reader can see the run getting thinner room by room, which is the
// tension the Matrix DM has and the web has never had.
func beatCombat(run *DungeonRun, monster string, elite, boss, won, timedOut bool,
preHP, postHP, maxHP, crits, fumbles int) {
if run == nil {
return
}
outcome := "won"
switch {
case won:
case timedOut:
outcome = "retreat" // outlasted, not killed: mechanically a withdrawal
default:
outcome = "down"
}
kind := string(RoomExploration)
switch {
case boss:
kind = string(RoomBoss)
case elite:
kind = string(RoomElite)
}
dmg := preHP - postHP
if dmg < 0 {
dmg = 0 // healed through the fight; "negative damage" is not a fact
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "combat",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
RoomKind: kind,
Target: monster,
Outcome: outcome,
Amount: dmg,
HP: postHP,
HPMax: maxHP,
Crits: crits,
Fumbles: fumbles,
})
}
// beatTrap records a sprung trap. A zero-damage trap is still worth a beat: the
// near-miss is part of the run, and the log reads wrong if the party walks
// through a trap room and nothing at all is said about it.
func beatTrap(userID id.UserID, run *DungeonRun, damage int) {
if run == nil {
return
}
hp, maxHP := dndHPSnapshot(userID)
outcome := "sprung"
if damage <= 0 {
outcome = "avoided"
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "trap",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
RoomKind: string(RoomTrap),
Outcome: outcome,
Amount: damage,
HP: hp,
HPMax: maxHP,
})
}
// beatTreasure records one thing found and kept. One beat per item rather than a
// count: an item is a name, and the name is the whole reason anybody reads a
// loot line.
func beatTreasure(run *DungeonRun, item, source string) {
if run == nil || item == "" {
return
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "treasure",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
Target: item,
Outcome: source, // "cache" | "boss" | "zone"
})
}
// beatHaul records a room's auto-harvest take, one beat for the room rather than
// one per resource — this is background gathering, and a per-resource beat would
// bury the fights it happens between. Target names the biggest single yield so
// the line has a noun in it; Amount is the total.
func beatHaul(run *DungeonRun, sum autoHarvestSummary) {
if run == nil || len(sum.Yields) == 0 {
return
}
total := 0
// Deterministic pick: biggest yield, ties broken by name, so re-running the
// same room can't produce two different beats from the same map.
keys := make([]string, 0, len(sum.Yields))
for k, v := range sum.Yields {
total += v
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if sum.Yields[keys[i]] != sum.Yields[keys[j]] {
return sum.Yields[keys[i]] > sum.Yields[keys[j]]
}
return keys[i] < keys[j]
})
top := sum.Names[keys[0]]
if top == "" {
top = keys[0]
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "haul",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
Target: top,
Amount: total,
Count: len(sum.Yields),
})
}
// beatLock records a door the party had to deal with. Only the interesting
// outcomes reach here — an unlocked door is not an event.
func beatLock(run *DungeonRun, target, outcome string) {
if run == nil {
return
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "lock",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
Target: target,
Outcome: outcome, // "picked" | "sealed"
})
}
// beatRegion records a border crossing on a multi-region expedition. The run id
// changes at a crossing (each region gets its own run), so this beat closes one
// liveblog and the next run's start beat opens the next — naming the region
// ahead is what lets Pete stitch them into one journey.
func beatRegion(run *DungeonRun, from, to string) {
if run == nil {
return
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "region",
Region: from,
Target: to,
Outcome: "crossed",
})
}
// beatRunEnd closes the story. outcome is the only field that matters and it is
// the one the whole log is read for.
//
// First writer wins, and that is load-bearing. A run ends once, but it passes
// through more than one place that could say so: a death in the combat resolver
// goes on to call abandonZoneRun, and a completed run gets retired by the
// expedition layer. The specific callers file first and know what happened; the
// generic funnels file "abandoned" and would otherwise overwrite them with the
// least informative answer available.
func beatRunEnd(run *DungeonRun, outcome string) {
if run == nil || runHasEndBeat(run.RunID) {
return
}
recordRunBeat(run.RunID, peteclient.RunBeat{
Kind: "end",
Room: run.CurrentRoom + 1,
TotalRooms: run.TotalRooms,
Outcome: outcome, // "cleared" | "died" | "retreated" | "abandoned"
})
}
+343
View File
@@ -0,0 +1,343 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// seedBeatRun writes a dnd_zone_run row directly. The beat pusher resolves a
// run's owner through this table to decide whether the log may leave the box, so
// a test that skips it is testing a code path production never takes.
func seedBeatRun(t *testing.T, runID string, uid id.UserID) {
t.Helper()
if _, err := db.Get().Exec(`
INSERT INTO dnd_zone_run
(run_id, user_id, zone_id, total_rooms, rooms_cleared, gm_mood,
current_node, visited_nodes, node_choices, rooms_traversed)
VALUES (?, ?, 'goblin_warrens', 8, '[]', 50, 'goblin_warrens.r1', '["goblin_warrens.r1"]', '{}', 1)`,
runID, string(uid)); err != nil {
t.Fatalf("seed zone run: %v", err)
}
}
func beatKinds(t *testing.T, runID string) []string {
t.Helper()
rows, err := db.Get().Query(
`SELECT kind FROM pete_run_beat WHERE run_id = ? ORDER BY seq ASC`, runID)
if err != nil {
t.Fatalf("read beats: %v", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
t.Fatal(err)
}
out = append(out, k)
}
return out
}
// TestRunBeatSeqIsMonotonicPerRun. (run_id, seq) is the identity Pete is
// idempotent on and it is also the render order, so a repeated or missing number
// is either a lost beat or a duplicated one. Two runs walking at once must not
// share a counter.
func TestRunBeatSeqIsMonotonicPerRun(t *testing.T) {
newBoredomTestDB(t)
enablePeteSeam(t)
for i := 0; i < 3; i++ {
recordRunBeat("run-a", peteclient.RunBeat{Kind: "room", Room: i + 1})
recordRunBeat("run-b", peteclient.RunBeat{Kind: "room", Room: i + 1})
}
for _, run := range []string{"run-a", "run-b"} {
rows, err := db.Get().Query(
`SELECT seq FROM pete_run_beat WHERE run_id = ? ORDER BY seq ASC`, run)
if err != nil {
t.Fatal(err)
}
var seqs []int64
for rows.Next() {
var s int64
if err := rows.Scan(&s); err != nil {
t.Fatal(err)
}
seqs = append(seqs, s)
}
rows.Close()
if len(seqs) != 3 {
t.Fatalf("%s: %d beats, want 3", run, len(seqs))
}
for i, s := range seqs {
if s != int64(i+1) {
t.Errorf("%s: seq[%d] = %d, want %d", run, i, s, i+1)
}
}
}
}
// TestRunBeatsAreDroppedForOptedOutPlayers is the privacy guard, and it is
// stricter than the board's.
//
// The board omits an opted-out player from a snapshot. The liveblog would be a
// room-by-room account of where somebody is and what is happening to them, which
// is the most exposing surface in the whole plan — so the rule here is that the
// beats never leave the box at all. They are retired locally instead, so the
// buffer can't fill up with rows that will never ship.
//
// It is also the pin on the connection-pool deadlock this originally shipped
// with: resolving an owner requires a second query, and doing that with the beat
// cursor still open waits forever on a one-connection pool. If loadRunBeatBatch
// ever goes back to resolving inside its own rows loop, this test stops failing
// and starts HANGING — which is what it did the first time, and is why the note
// is here rather than in a comment nobody reads at 3am.
func TestRunBeatsAreDroppedForOptedOutPlayers(t *testing.T) {
newBoredomTestDB(t)
enablePeteSeam(t)
now := time.Now().UTC()
seedRosterPlayer(t, "@shy:test", "Quack", &now, &now)
seedRosterPlayer(t, "@loud:test", "Josie", &now, &now)
seedBeatRun(t, "run-shy", "@shy:test")
seedBeatRun(t, "run-loud", "@loud:test")
setNewsOptout("@shy:test", true)
recordRunBeat("run-shy", peteclient.RunBeat{Kind: "room", Room: 2})
recordRunBeat("run-loud", peteclient.RunBeat{Kind: "room", Room: 2})
send, drop, err := loadRunBeatBatch(100)
if err != nil {
t.Fatalf("loadRunBeatBatch: %v", err)
}
if len(send) != 1 || send[0].RunID != "run-loud" {
t.Fatalf("sendable beats = %+v, want only run-loud", send)
}
if len(drop) != 1 || drop[0].RunID != "run-shy" {
t.Fatalf("dropped beats = %+v, want only run-shy", drop)
}
// Retiring means marked sent, not deleted — one code path for "done with this
// row", and the retention sweep reaps it on its own clock.
if err := markRunBeatsSent(drop); err != nil {
t.Fatalf("retire: %v", err)
}
send, drop, _ = loadRunBeatBatch(100)
if len(drop) != 0 {
t.Errorf("retired beats came back: %+v", drop)
}
if len(send) != 1 {
t.Errorf("retiring the opted-out beats disturbed the rest: %+v", send)
}
}
// TestRunBeatsRefuseAnUnresolvableRun. The liveblog is public. A run whose owner
// can't be resolved is not a run we know is safe to publish — "don't know who
// this is" is not a basis for saying where they are.
func TestRunBeatsRefuseAnUnresolvableRun(t *testing.T) {
newBoredomTestDB(t)
enablePeteSeam(t)
recordRunBeat("run-ghost", peteclient.RunBeat{Kind: "room", Room: 1})
send, drop, err := loadRunBeatBatch(100)
if err != nil {
t.Fatal(err)
}
if len(send) != 0 {
t.Errorf("beats for an unknown run were queued for publication: %+v", send)
}
if len(drop) != 1 {
t.Errorf("orphan beats = %d, want 1 retired", len(drop))
}
}
// TestRunEndIsFirstWriterWins. A run ends once, but it passes through more than
// one place that can say so: a death in the combat resolver goes on to call
// abandonZoneRun, and the expedition layer retires completed runs. The specific
// outcome is filed first and must survive the generic one behind it — a log that
// says "abandoned" about somebody who was killed is worse than no log.
func TestRunEndIsFirstWriterWins(t *testing.T) {
newBoredomTestDB(t)
enablePeteSeam(t)
now := time.Now().UTC()
seedRosterPlayer(t, "@a:test", "Josie", &now, &now)
seedBeatRun(t, "run-a", "@a:test")
run, err := getZoneRun("run-a")
if err != nil || run == nil {
t.Fatalf("load run: %v", err)
}
beatRunEnd(run, "died")
beatRunEnd(run, "abandoned")
beatRunEnd(run, "cleared")
if kinds := beatKinds(t, "run-a"); len(kinds) != 1 || kinds[0] != "end" {
t.Fatalf("beats = %v, want exactly one end beat", kinds)
}
send, _, err := loadRunBeatBatch(10)
if err != nil {
t.Fatal(err)
}
if len(send) != 1 || send[0].Outcome != "died" {
t.Errorf("stored outcome = %+v, want the first (specific) close", send)
}
}
// TestRunBeatsAreANoOpWhenTheSeamIsOff. The whole channel hangs off the same
// master switch as the dispatch queue: with news emission off, nothing is
// recorded at all, so turning it off doesn't quietly accrue a buffer that floods
// Pete the moment it comes back on.
func TestRunBeatsAreANoOpWhenTheSeamIsOff(t *testing.T) {
newBoredomTestDB(t)
recordRunBeat("run-a", peteclient.RunBeat{Kind: "room", Room: 1})
if kinds := beatKinds(t, "run-a"); len(kinds) != 0 {
t.Errorf("recorded %v with the seam disabled", kinds)
}
}
// TestBeatCombatReadsTheOutcome pins the three fight endings apart. "Outlasted
// by the monster" and "killed by the monster" are the same losing branch in the
// engine and mechanically different events — one starts a respawn timer and the
// other doesn't — so the log must not collapse them.
func TestBeatCombatReadsTheOutcome(t *testing.T) {
newBoredomTestDB(t)
enablePeteSeam(t)
now := time.Now().UTC()
seedRosterPlayer(t, "@a:test", "Josie", &now, &now)
seedBeatRun(t, "run-a", "@a:test")
run, _ := getZoneRun("run-a")
beatCombat(run, "Rat", false, false, true, false, 30, 24, 30, 1, 0)
beatCombat(run, "Aldric", false, true, false, true, 24, 8, 30, 0, 2)
beatCombat(run, "The Rotmother", false, true, false, false, 8, 0, 30, 0, 0)
send, _, err := loadRunBeatBatch(10)
if err != nil {
t.Fatal(err)
}
if len(send) != 3 {
t.Fatalf("got %d combat beats, want 3", len(send))
}
want := []string{"won", "retreat", "down"}
for i, w := range want {
if send[i].Outcome != w {
t.Errorf("beat %d outcome = %q, want %q", i, send[i].Outcome, w)
}
}
if send[0].Amount != 6 || send[0].HP != 24 || send[0].HPMax != 30 {
t.Errorf("won beat lost its numbers: %+v", send[0])
}
if send[0].Crits != 1 {
t.Errorf("crits = %d, want 1", send[0].Crits)
}
if send[1].RoomKind != "boss" {
t.Errorf("room kind = %q, want boss", send[1].RoomKind)
}
}
// TestBeatCombatNeverReportsNegativeDamage. A party that healed through a fight
// finishes on more HP than it started with. "Took 4 damage" is not a fact.
func TestBeatCombatNeverReportsNegativeDamage(t *testing.T) {
newBoredomTestDB(t)
enablePeteSeam(t)
now := time.Now().UTC()
seedRosterPlayer(t, "@a:test", "Josie", &now, &now)
seedBeatRun(t, "run-a", "@a:test")
run, _ := getZoneRun("run-a")
beatCombat(run, "Rat", false, false, true, false, 20, 28, 30, 0, 0)
send, _, _ := loadRunBeatBatch(10)
if len(send) != 1 || send[0].Amount != 0 {
t.Fatalf("amount = %+v, want 0", send)
}
}
// TestBeatHaulPicksTheBiggestYieldDeterministically. Go's map order is random,
// so a "mostly X" line built off a range would name a different resource every
// time the same room was rendered.
func TestBeatHaulPicksTheBiggestYieldDeterministically(t *testing.T) {
newBoredomTestDB(t)
enablePeteSeam(t)
now := time.Now().UTC()
seedRosterPlayer(t, "@a:test", "Josie", &now, &now)
for i, runID := range []string{"run-1", "run-2", "run-3", "run-4", "run-5"} {
seedBeatRun(t, runID, "@a:test")
run, _ := getZoneRun(runID)
beatHaul(run, autoHarvestSummary{
Yields: map[string]int{"ironcap": 5, "moss": 2, "flint": 1},
Names: map[string]string{"ironcap": "Ironcap", "moss": "Moss", "flint": "Flint"},
})
_ = i
}
send, _, err := loadRunBeatBatch(20)
if err != nil {
t.Fatal(err)
}
if len(send) != 5 {
t.Fatalf("got %d haul beats, want 5", len(send))
}
for _, b := range send {
if b.Target != "Ironcap" {
t.Fatalf("haul named %q, want Ironcap every time", b.Target)
}
if b.Amount != 8 || b.Count != 3 {
t.Errorf("haul totals = %d over %d kinds, want 8 over 3", b.Amount, b.Count)
}
}
// Nothing gathered is not a beat.
seedBeatRun(t, "run-empty", "@a:test")
empty, _ := getZoneRun("run-empty")
beatHaul(empty, autoHarvestSummary{})
if kinds := beatKinds(t, "run-empty"); len(kinds) != 0 {
t.Errorf("an empty haul produced %v", kinds)
}
}
// TestStartingARunOpensItsLog is the end-to-end seam check on the game side: the
// engine primitive every zone entry goes through files the one beat that carries
// identity, so nothing downstream has to be told who is walking.
func TestStartingARunOpensItsLog(t *testing.T) {
newBoredomTestDB(t)
enablePeteSeam(t)
now := time.Now().UTC()
seedRosterPlayer(t, "@a:test", "Josie", &now, &now)
run, err := startZoneRun("@a:test", "goblin_warrens", 5, nil)
if err != nil {
t.Fatalf("startZoneRun: %v", err)
}
send, _, err := loadRunBeatBatch(10)
if err != nil {
t.Fatal(err)
}
if len(send) != 1 || send[0].Kind != "start" {
t.Fatalf("beats = %+v, want one start", send)
}
b := send[0]
if b.RunID != run.RunID {
t.Errorf("start beat run = %q, want %q", b.RunID, run.RunID)
}
if b.Name != "Josie" || b.Level != 5 {
t.Errorf("start beat identity = %q L%d, want Josie L5", b.Name, b.Level)
}
if b.Token == "" || b.Token != eventToken("@a:test", "roster") {
t.Errorf("start beat token = %q, want the public board token", b.Token)
}
if b.TotalRooms != run.TotalRooms {
t.Errorf("start beat rooms = %d, want %d", b.TotalRooms, run.TotalRooms)
}
}
+9 -1
View File
@@ -436,6 +436,9 @@ func completeRunAtNode(runID string, boss bool) error {
if boss {
bossI = 1
}
if run, _ := getZoneRun(runID); run != nil {
beatRunEnd(run, "cleared")
}
_, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET boss_defeated = ?,
@@ -474,7 +477,12 @@ func advanceZoneRunNode(runID, nextNode string) (int, error) {
nextNode, string(visitedJSON), runID); err != nil {
return 0, err
}
return pathIndexOf(visited, nextNode), nil
idx := pathIndexOf(visited, nextNode)
// Every forward move in the game funnels through here — auto-advance, a
// player's `!zone go`, and the autopilot's stale-fork pick alike — which is
// what makes this the one honest place to say "the party is now in room N".
beatRoom(r, nextNode, idx, "entered")
return idx, nil
}
// resolveForkChoice takes a 1-based choice index against a pending
+5 -1
View File
@@ -69,7 +69,11 @@ func revisitZoneRun(runID, targetNode string, visited []string) (int, error) {
WHERE run_id = ?`, targetNode, runID); err != nil {
return 0, err
}
return pathIndexOf(visited, targetNode), nil
idx := pathIndexOf(visited, targetNode)
if run, _ := getZoneRun(runID); run != nil {
beatRoom(run, targetNode, idx, "doubled back")
}
return idx, nil
}
// handleRevisitCmd implements `!revisit <N>` (also reachable as