Add replayable debug reports.

A report dumps everything needed to understand a game that went wrong:
the full state (decks card by card, shop row and tier deck order,
discards, pending choices, banked Mana/Trumpets/apples), the round's
battles, and the whole event log. Available as JSON, which replays, or
text, which reads — and the text ends with a paste-ready repro test.

Battles now record the randomness they consume alongside what they
started from, so a result can be replayed long after the round cleared
those banks: same lineups, same dice, same events, down to the log text.
Results predating the recording say so rather than quietly re-rolling.

Three ways in: the 🐛 panel's download/copy buttons, GET
/api/debug/report (DEBUG-only, since a report holds both players' hands
and the shop deck order), and `mise run report`, which reads the
database directly so a live game can be dumped without DEBUG.
This commit is contained in:
Greyson Parrelli
2026-08-10 10:06:06 -04:00
parent a4f5f6910d
commit 8ccde03023
16 changed files with 1480 additions and 19 deletions
+28 -7
View File
@@ -187,20 +187,41 @@ type Game struct {
// RollDie overrides the rock die (faces 0,0,1,1,2,2) for tests. Nil
// (including after loading from storage) means a fair random roll.
RollDie func() int `json:"-"`
// drawTape records every battleDraw the battle currently resolving makes,
// and drawReplay feeds a recording back in. Together they make a finished
// battle exactly reproducible from a debug report (see debug.go). Both are
// scratch state for one battle and are never serialized.
drawTape []int
drawReplay []int
}
// battleDraw returns a random value in [0, n) for a battle's randomness — rock
// dice and Komodo's apple shuffle alike. The RollDie test override applies to
// rock dice (n == 3).
// rock dice (n == 3). Every value handed out is appended to the running draw
// tape, which runBattle files with the result so the battle can be replayed.
func (g *Game) battleDraw(n int) int {
switch {
case n <= 0:
if n <= 0 {
return 0
case n == 3 && g.RollDie != nil:
return g.RollDie() // test override applies to rock dice
default:
return randInt(n)
}
var v int
switch {
case len(g.drawReplay) > 0:
// Replaying a recording: take the next value off the tape. It's folded
// back into range in case the replay diverged onto a differently-sized
// draw, so a stale tape can never panic or roll an illegal face.
v, g.drawReplay = g.drawReplay[0], g.drawReplay[1:]
if v < 0 {
v = -v
}
v %= n
case n == 3 && g.RollDie != nil:
v = g.RollDie() // test override applies to rock dice
default:
v = randInt(n)
}
g.drawTape = append(g.drawTape, v)
return v
}
// rollRockDie rolls one rock die: 0, 1, or 2 with equal probability.