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
+40
View File
@@ -211,6 +211,33 @@ type BattleResult struct {
// is apples each side banked for next round's hand (Skeleton Dog).
ManaAfter []int `json:"manaAfter,omitempty"`
NextRoundApples []int `json:"nextRoundApples,omitempty"`
// Draws is every random draw this battle made, in order: rock die faces,
// Komodo's apple shuffle, random target picks. It's a recording, not an
// input — feed it back through ReplayResult and the identical battle plays
// out, which is how a debug report reproduces a fight that went wrong.
// Public like the rest of the result: the dice are rolled in the open.
Draws []int `json:"draws,omitempty"`
// Inputs is the rest of what the battle started from, per side, and
// StartCardID is the card-id counter it began minting apples at. Recorded
// because the round clears those banks the moment the battles end, so
// without them a result can't be replayed after the fact. Nothing here is
// private: the battle already announces each of them in its own events.
Inputs []BattleInputs `json:"inputs,omitempty"`
StartCardID int `json:"startCardId,omitempty"`
}
// Replayable reports whether this result carries the recording a faithful
// replay needs. Results saved before the engine recorded battles don't: their
// lineups are still on file, so the fight can be re-run, but the dice will fall
// where they may and the outcome may differ from what the player saw.
func (r *BattleResult) Replayable() bool { return r.StartCardID > 0 }
// BattleInputs is the persistent player state one side brought into a battle —
// everything runBattle reads off the Player besides the arranged lineup.
type BattleInputs struct {
Mana int `json:"mana,omitempty"` // Player.Mana (Unicorn)
Trumpets int `json:"trumpets,omitempty"` // Player.PendingTrumpets (Golden)
ApplesInPlay int `json:"applesInPlay,omitempty"` // Player.PendingApplesInPlay (Golden)
}
// Side returns the battle-side index (0 or 1) for a seat at the table, or -1
@@ -458,6 +485,13 @@ func (g *Game) runBattle(first, second int) *BattleResult {
seats := []int{first, second}
res := &BattleResult{Round: g.Round, WinnerSeat: -1, Seats: seats,
StackSizes: make([]int, n), Lineups: make([][]Card, n)}
// Every die this battle rolls lands on the tape and ships with the result,
// so a debug report can replay the fight exactly (see debug.go). The tape
// belongs to one battle: start it empty and hand it over on the way out.
g.drawTape = nil
defer func() { res.Draws, g.drawTape, g.drawReplay = g.drawTape, nil, nil }()
res.StartCardID = g.NextCardID
res.Inputs = make([]BattleInputs, n)
sides := make([]*battleSide, n)
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
// pname is the owning player's display name for a side, for log text.
@@ -472,6 +506,12 @@ func (g *Game) runBattle(first, second int) *BattleResult {
sides[side] = s
res.StackSizes[side] = len(p.Deck)
res.Lineups[side] = append([]Card(nil), p.Deck...)
// Everything else runBattle reads off the Player, banked for the replay.
res.Inputs[side] = BattleInputs{
Mana: p.Mana,
Trumpets: p.PendingTrumpets,
ApplesInPlay: p.PendingApplesInPlay,
}
}
// enemyOf returns the opposing side.
enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] }