mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,6 +53,7 @@ func (p *AdventurePlugin) peteRosterTicker() {
|
||||
p.pushRoster()
|
||||
p.pushDetails()
|
||||
p.pushSiege()
|
||||
p.pushRunBeats()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user