diff --git a/internal/db/db.go b/internal/db/db.go index ccd8695..edf7740 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 ( diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index 2df4fb6..dc14ff4 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -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 diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index 07f8d88..eead47c 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -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 diff --git a/internal/plugin/dnd_expedition_region_cmd.go b/internal/plugin/dnd_expedition_region_cmd.go index 6ebe9a3..ad6156c 100644 --- a/internal/plugin/dnd_expedition_region_cmd.go +++ b/internal/plugin/dnd_expedition_region_cmd.go @@ -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 { diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 2d41531..b1062a3 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -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 diff --git a/internal/plugin/dnd_zone_combat.go b/internal/plugin/dnd_zone_combat.go index 50b5e49..4f1073c 100644 --- a/internal/plugin/dnd_zone_combat.go +++ b/internal/plugin/dnd_zone_combat.go @@ -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 } diff --git a/internal/plugin/dnd_zone_run.go b/internal/plugin/dnd_zone_run.go index 706fb10..0998520 100644 --- a/internal/plugin/dnd_zone_run.go +++ b/internal/plugin/dnd_zone_run.go @@ -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, diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index c369a50..852d2e9 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -53,6 +53,7 @@ func (p *AdventurePlugin) peteRosterTicker() { p.pushRoster() p.pushDetails() p.pushSiege() + p.pushRunBeats() } } diff --git a/internal/plugin/pete_runbeat.go b/internal/plugin/pete_runbeat.go new file mode 100644 index 0000000..3846051 --- /dev/null +++ b/internal/plugin/pete_runbeat.go @@ -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() +} diff --git a/internal/plugin/pete_runbeat_emit.go b/internal/plugin/pete_runbeat_emit.go new file mode 100644 index 0000000..91e2b41 --- /dev/null +++ b/internal/plugin/pete_runbeat_emit.go @@ -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" + }) +} diff --git a/internal/plugin/pete_runbeat_test.go b/internal/plugin/pete_runbeat_test.go new file mode 100644 index 0000000..9bb6fa8 --- /dev/null +++ b/internal/plugin/pete_runbeat_test.go @@ -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) + } +} diff --git a/internal/plugin/zone_graph_nav.go b/internal/plugin/zone_graph_nav.go index 9542fd7..08c5d9b 100644 --- a/internal/plugin/zone_graph_nav.go +++ b/internal/plugin/zone_graph_nav.go @@ -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 diff --git a/internal/plugin/zone_revisit.go b/internal/plugin/zone_revisit.go index 607ad83..a75c06b 100644 --- a/internal/plugin/zone_revisit.go +++ b/internal/plugin/zone_revisit.go @@ -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 ` (also reachable as