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] }
+520
View File
@@ -0,0 +1,520 @@
package game
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// A debug report is a snapshot of a game that went wrong, complete enough to
// reproduce it: the full authoritative state (every deck card by card, the shop
// row and remaining decks, discards, pending choices, per-player banks), every
// battle fought that round with its lineups and its recording of every die, and
// the entire event log.
//
// It exists in two forms. The JSON form is exact — feed it back through
// ParseDebugReport and you have the same Game the server had, ready to replay.
// The text form is for reading: the same content laid out for a human trying to
// work out which effect misfired. Report.Text() ends with a paste-ready test.
//
// Both forms contain hidden information — opponents' decks, the order of the
// shop decks — so a report is an operator's artifact, not a player's. The
// server only serves one in DEBUG mode; `go run ./cmd/report` pulls one out of
// the database with no such gate, because by then you are the operator.
// DebugReportVersion is the report format's version, so an old report found on
// disk can be recognized for what it is.
const DebugReportVersion = 1
// DebugReport is a captured game, ready to read or replay.
type DebugReport struct {
Version int `json:"version"`
// CapturedAt is an RFC3339 timestamp stamped by whoever took the report;
// the engine is clockless and leaves it empty. Note is free text — what
// looked wrong, in the reporter's words.
CapturedAt string `json:"capturedAt,omitempty"`
Note string `json:"note,omitempty"`
// Summary is the at-a-glance header, so a pile of reports can be triaged
// (and filenames built) without unmarshalling the state.
Summary DebugSummary `json:"summary"`
// State is the complete Game, byte for byte as the server persists it.
// Everything the report can tell you is derived from this.
State json.RawMessage `json:"state"`
}
// DebugSummary identifies a report at a glance.
type DebugSummary struct {
GameID string `json:"gameId"`
Code string `json:"code"`
Packs []string `json:"packs"`
Phase Phase `json:"phase"`
Round int `json:"round"`
Seats []string `json:"seats"` // display names, by seat
Battles int `json:"battles"` // battles recorded in this round
LogEntries int `json:"logEntries"` // size of the event log
WinnerSeat int `json:"winnerSeat"` // at gameover; -1 otherwise
Bots []int `json:"bots,omitempty"` // seats played by the computer
}
// DebugReport captures the game as it stands. The caller owns the game lock;
// the report is a copy and shares nothing with it afterwards.
func (g *Game) DebugReport() (*DebugReport, error) {
state, err := json.Marshal(g)
if err != nil {
return nil, fmt.Errorf("capture game state: %w", err)
}
sum := DebugSummary{
GameID: g.ID,
Code: g.Code,
Packs: g.packList(),
Phase: g.Phase,
Round: g.Round,
Battles: len(g.Battles),
LogEntries: len(g.Log),
WinnerSeat: g.WinnerSeat,
}
for _, p := range g.Players {
sum.Seats = append(sum.Seats, p.Name)
if p.IsBot {
sum.Bots = append(sum.Bots, p.Seat)
}
}
return &DebugReport{Version: DebugReportVersion, Summary: sum, State: state}, nil
}
// ParseDebugReport reads a report. It also accepts a bare game state — the raw
// JSON blob out of the store, or the `state` field pulled from a report — so
// anything game-shaped you can lay hands on can be replayed.
func ParseDebugReport(data []byte) (*DebugReport, error) {
var r DebugReport
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("parse debug report: %w", err)
}
if len(r.State) == 0 {
// Not a report — assume it's a game state and wrap it in one.
var g Game
if err := json.Unmarshal(data, &g); err != nil {
return nil, fmt.Errorf("parse debug report: not a report or a game state: %w", err)
}
if g.ID == "" && g.Code == "" && len(g.Players) == 0 {
return nil, fmt.Errorf("parse debug report: no game state found")
}
return g.DebugReport()
}
if _, err := r.Game(); err != nil {
return nil, err
}
return &r, nil
}
// LoadDebugReportFile reads a report (or a bare game state) off disk. This is
// the entry point for a test built around a report: keep the JSON in testdata
// and load it here.
func LoadDebugReportFile(path string) (*DebugReport, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseDebugReport(data)
}
// Game rebuilds the captured game. Each call returns a fresh copy, so callers
// are free to poke at it — replay a battle, take another action, run the state
// forward — without disturbing the report or each other.
func (r *DebugReport) Game() (*Game, error) {
var g Game
if err := json.Unmarshal(r.State, &g); err != nil {
return nil, fmt.Errorf("restore game state: %w", err)
}
return &g, nil
}
// JSON renders the report as indented JSON: what you save to a file, attach to
// a bug, or drop into testdata.
func (r *DebugReport) JSON() ([]byte, error) {
return json.MarshalIndent(r, "", " ")
}
// Filename is a descriptive, filesystem-safe name for this report.
func (r *DebugReport) Filename() string {
code := r.Summary.Code
if code == "" {
code = "game"
}
return fmt.Sprintf("sap-debug-%s-r%d-%s.json", strings.ToLower(code), r.Summary.Round, r.Summary.Phase)
}
// ReplayBattle re-runs one of the round's recorded battles — index 0 is the
// first pairing, matching Game.Battles — against a fresh copy of the captured
// game. The recording supplies the dice, so the fight plays out exactly as it
// did for the player, right down to the event text. Compare the result against
// the recorded one to see whether the engine still does what it did, or step
// through it to find where it went wrong.
func (r *DebugReport) ReplayBattle(i int) (*BattleResult, error) {
g, err := r.Game()
if err != nil {
return nil, err
}
if i < 0 || i >= len(g.Battles) {
return nil, fmt.Errorf("no battle %d in this report (it has %d)", i, len(g.Battles))
}
rec := g.Battles[i]
if len(rec.Seats) != 2 || len(rec.Lineups) != 2 {
return nil, fmt.Errorf("battle %d has no recorded lineups to replay", i)
}
for _, seat := range rec.Seats {
if seat < 0 || seat >= len(g.Players) {
return nil, fmt.Errorf("battle %d names seat %d, which isn't at this table", i, seat)
}
}
// Refuse rather than hand back a battle that quietly differs from the one
// the player saw: a game saved before the engine recorded its dice can only
// be re-rolled, not replayed.
if !rec.Replayable() {
return nil, fmt.Errorf("battle %d predates dice recording, so it can't be replayed faithfully — "+
"its lineups are in the report, and ReplayResult will fight it again with fresh dice", i)
}
// Rewind the two fighters to how they went in: the round has since cleared
// their banks and written their Mana back, and the battle itself spent card
// ids minting apples.
applyBattleInputs(g, rec)
g.Round = rec.Round
g.NextCardID = rec.StartCardID
g.drawReplay = append([]int(nil), rec.Draws...)
return g.runBattle(rec.Seats[0], rec.Seats[1]), nil
}
// Text renders the report for reading.
func (r *DebugReport) Text() string {
var b strings.Builder
g, err := r.Game()
if err != nil {
fmt.Fprintf(&b, "debug report v%d — UNREADABLE STATE: %v\n", r.Version, err)
return b.String()
}
fmt.Fprintf(&b, "Super Auto Pets debug report (v%d)\n", r.Version)
if r.CapturedAt != "" {
fmt.Fprintf(&b, "captured %s\n", r.CapturedAt)
}
if r.Note != "" {
fmt.Fprintf(&b, "note: %s\n", r.Note)
}
packs := strings.Join(g.packList(), " + ")
fmt.Fprintf(&b, "\ngame %s · code %s · packs %s\n", g.ID, g.Code, packs)
fmt.Fprintf(&b, "round %d/%d · phase %s · %d players\n", g.Round, MaxRounds, g.Phase, len(g.Players))
fmt.Fprintf(&b, "priority seat %d · shop turn seat %d · next card id %d\n",
g.PrioritySeat, g.Turn, g.NextCardID)
if g.Phase == PhaseGameOver {
fmt.Fprintf(&b, "winner: %s\n", seatList(g, g.WinnerSeats))
}
if pairs := g.Pairings(); len(pairs) > 0 {
var parts []string
for _, m := range pairs {
parts = append(parts, fmt.Sprintf("%d v %d", m[0], m[1]))
}
fmt.Fprintf(&b, "this round's pairings: %s\n", strings.Join(parts, ", "))
}
b.WriteString("\n=== Players ===\n")
for _, p := range g.Players {
writePlayer(&b, p)
}
b.WriteString("\n=== Shop ===\n")
writeShop(&b, g)
if len(g.Battles) > 0 {
fmt.Fprintf(&b, "\n=== Battles (round %d) ===\n", g.Battles[0].Round)
for i, res := range g.Battles {
writeBattle(&b, g, i, res)
}
}
fmt.Fprintf(&b, "\n=== Event log (%d entries) ===\n", len(g.Log))
for _, e := range g.Log {
seat := " -"
if e.Seat >= 0 {
seat = fmt.Sprintf("s%d", e.Seat)
}
fmt.Fprintf(&b, " #%-4d r%d %-8s %-3s %s %s", e.Seq, e.Round, e.Phase, seat, e.Icon, e.Text)
if e.Kind != "" {
fmt.Fprintf(&b, " [kind=%s]", e.Kind)
}
b.WriteString("\n")
}
b.WriteString("\n=== Reproducing this ===\n")
b.WriteString(r.GoTest())
return b.String()
}
// GoTest is a paste-ready test that reproduces the report's battle. Save the
// report's JSON next to it and the test replays the exact fight — same
// lineups, same dice — so you can assert on what should have happened and then
// step into the resolver to find out why it didn't.
func (r *DebugReport) GoTest() string {
var b strings.Builder
name := r.Filename()
fmt.Fprintf(&b, "Save the JSON report as internal/game/testdata/%s, then in\n"+
"internal/game (package game):\n\n", name)
fmt.Fprintf(&b, "func TestReproFrom%s(t *testing.T) {\n", identifier(r.Summary.Code))
fmt.Fprintf(&b, "\trep, err := LoadDebugReportFile(\"testdata/%s\")\n", name)
b.WriteString("\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n")
b.WriteString("\tres, err := rep.ReplayBattle(0) // 0 = the round's first pairing\n")
b.WriteString("\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n")
b.WriteString("\tfor i, ev := range res.Events {\n\t\tt.Logf(\"%3d %-8s %s\", i, ev.Type, ev.Text)\n\t}\n")
// The assertion is seeded with what actually happened, since that's the
// thing under suspicion: flip it to what should have happened and the test
// becomes the bug report.
winner := -1
if g, err := r.Game(); err == nil && len(g.Battles) > 0 {
winner = g.Battles[0].WinnerSeat
}
outcome := fmt.Sprintf("seat %d to win", winner)
if winner < 0 {
outcome = "a draw"
}
fmt.Fprintf(&b, "\tif res.WinnerSeat != %d { // what happened; assert what should have\n", winner)
fmt.Fprintf(&b, "\t\tt.Fatalf(\"expected %s, got winner seat %%d\", res.WinnerSeat)\n", outcome)
b.WriteString("\t}\n}\n\n")
b.WriteString("The whole game is there too, not just the battle: rep.Game() hands back\n" +
"the exact state the server held, so you can carry on from the shop, replay\n" +
"a different pairing, or read a deck card by card.\n")
return b.String()
}
// writePlayer renders one seat: its banks, then its deck in order.
func writePlayer(b *strings.Builder, p *Player) {
who := "human"
if p.IsBot {
who = fmt.Sprintf("bot %.2f", p.BotLevel)
}
conn := "connected"
if !p.Connected {
conn = "DISCONNECTED"
}
ready := ""
if p.Ready {
ready = " · ready/passed"
}
wins := ""
if len(p.RoundWins) > 0 {
wins = fmt.Sprintf(" (won rounds %v)", p.RoundWins)
}
fmt.Fprintf(b, "\nSeat %d %s (%s, %s)%s\n", p.Seat, p.Name, who, conn, ready)
fmt.Fprintf(b, " %d coins · %d trophies%s · %d pets\n", p.Coins, p.Trophies, wins, p.PetCount())
var banks []string
for _, bank := range []struct {
label string
n int
}{
{"mana", p.Mana}, {"avocados", p.Avocados}, {"trumpets banked", p.PendingTrumpets},
{"apples in play next battle", p.PendingApplesInPlay}, {"apples next round", p.NextRoundApples},
{"buys this round", p.BuysThisRound},
} {
if bank.n != 0 {
banks = append(banks, fmt.Sprintf("%s %d", bank.label, bank.n))
}
}
if p.FirstBuyFree {
banks = append(banks, "first buy free")
}
if p.TripledThisRound {
banks = append(banks, "tripled this round")
}
if len(banks) > 0 {
fmt.Fprintf(b, " %s\n", strings.Join(banks, " · "))
}
if p.ShopPeek != nil {
fmt.Fprintf(b, " peeked at the shop deck: %s\n", cardLine(*p.ShopPeek))
}
fmt.Fprintf(b, " deck, in order (%d):\n", len(p.Deck))
if len(p.Deck) == 0 {
b.WriteString(" (empty)\n")
}
for i, c := range p.Deck {
fmt.Fprintf(b, " %d. %s\n", i+1, cardLine(c))
}
}
// writeShop renders the shop row, what's left in each tier deck, the discards,
// and any choice a player is mid-way through.
func writeShop(b *strings.Builder, g *Game) {
if len(g.ShopRow) == 0 {
b.WriteString(" row: (none — the shop is closed)\n")
}
for i, c := range g.ShopRow {
if c.ID == "" {
fmt.Fprintf(b, " row %d: (bought)\n", i)
continue
}
fmt.Fprintf(b, " row %d: %s\n", i, cardLine(c))
}
for tier, deck := range g.ShopDecks {
fmt.Fprintf(b, " tier %d deck: %d left", tier+1, len(deck))
// The order is the whole point of dumping the deck: it decides what the
// next buy, peek or trade-in turns up.
if len(deck) > 0 {
fmt.Fprintf(b, " — next: %s", cardNames(deck[:min(len(deck), 6)]))
}
b.WriteString("\n")
}
for tier := 1; tier <= MaxRounds; tier++ {
if pile := g.Discards[tier]; len(pile) > 0 {
fmt.Fprintf(b, " tier %d discards: %s\n", tier, cardNames(pile))
}
}
if t := g.Pending; t != nil {
fmt.Fprintf(b, " PENDING trade-in by %s from tier %d: %s | %s\n",
playerName(g, t.PlayerID), t.Tier, cardLine(t.Options[0]), cardLine(t.Options[1]))
}
if rv := g.PendingReveal; rv != nil {
fmt.Fprintf(b, " PENDING reveal by %s (source %s, %d apples), options %v\n",
playerName(g, rv.PlayerID), rv.Source, rv.Apples, rv.Options)
}
if s := g.PendingSacrifice; s != nil {
fmt.Fprintf(b, " PENDING sacrifice by %s (source %s, tier %d), options %v\n",
playerName(g, s.PlayerID), s.Source, s.Tier, s.Options)
}
}
// writeBattle renders one battle: who fought, what they fielded, the dice it
// rolled, every event in order, and how it ended.
func writeBattle(b *strings.Builder, g *Game, i int, res *BattleResult) {
fmt.Fprintf(b, "\n-- battle %d: ", i)
if len(res.Seats) == 2 {
fmt.Fprintf(b, "seat %d (%s, first) v seat %d (%s) --\n",
res.Seats[0], seatName(g, res.Seats[0]), res.Seats[1], seatName(g, res.Seats[1]))
} else {
fmt.Fprintf(b, "seats %v --\n", res.Seats)
}
outcome := "a draw"
if res.WinnerSeat >= 0 {
outcome = fmt.Sprintf("seat %d (%s) won %d trophy(s)",
res.WinnerSeat, seatName(g, res.WinnerSeat), res.Trophies)
}
fmt.Fprintf(b, " outcome: %s · survivors by side %v\n", outcome, res.Survivors)
for side, lineup := range res.Lineups {
fmt.Fprintf(b, " side %d (seat %d) fielded, top of deck first:\n", side, res.SeatOf(side))
for j, c := range lineup {
fmt.Fprintf(b, " %d. %s\n", j+1, cardLine(c))
}
if side < len(res.Inputs) {
in := res.Inputs[side]
fmt.Fprintf(b, " started with: %d mana, %d trumpets, %d apples in play\n",
in.Mana, in.Trumpets, in.ApplesInPlay)
}
}
if res.Replayable() {
fmt.Fprintf(b, " dice tape (%d draws): %v\n", len(res.Draws), res.Draws)
} else {
b.WriteString(" dice tape: NOT RECORDED — this battle was played before the engine\n" +
" recorded its dice, so it can only be re-fought, not replayed\n")
}
fmt.Fprintf(b, " events (%d):\n", len(res.Events))
for j, ev := range res.Events {
fmt.Fprintf(b, " %3d %-9s side %d", j, ev.Type, ev.Seat)
if ev.Card != nil {
fmt.Fprintf(b, " %s", ev.Card.Name)
}
if len(ev.Dice) > 0 {
fmt.Fprintf(b, " dice=%v(%d)", ev.Dice, ev.Roll)
}
if len(ev.Damage) > 0 {
fmt.Fprintf(b, " dmg=%v died=%v", ev.Damage, ev.Died)
}
if ev.Count != 0 {
fmt.Fprintf(b, " count=%d", ev.Count)
}
if ev.Text != "" {
fmt.Fprintf(b, " · %s", ev.Text)
}
b.WriteString("\n")
}
}
// cardLine describes one card in full: what it is, what it does, and the id the
// log and battle events refer to it by.
func cardLine(c Card) string {
var b strings.Builder
b.WriteString(c.Name)
switch {
case c.IsPet():
fmt.Fprintf(&b, " (pet, power %d", c.Power)
if c.Suit != "" {
fmt.Fprintf(&b, ", %s", c.Suit)
}
case c.IsAilment():
fmt.Fprintf(&b, " (ailment %s", c.Ailment)
default:
b.WriteString(" (food")
if c.Food != "" {
fmt.Fprintf(&b, " %s", c.Food)
}
if c.Perk {
b.WriteString(", perk")
}
}
if c.Tier > 0 {
fmt.Fprintf(&b, ", tier %d", c.Tier)
}
if c.Temporary {
b.WriteString(", temporary")
}
fmt.Fprintf(&b, ", id %s)", c.ID)
if c.EffectText != "" {
fmt.Fprintf(&b, " — %s", c.EffectText)
}
return b.String()
}
// cardNames lists cards by name alone, for places where the detail would drown
// the point (deck order, discard piles).
func cardNames(cards []Card) string {
names := make([]string, len(cards))
for i, c := range cards {
names[i] = c.Name
}
return strings.Join(names, ", ")
}
func seatName(g *Game, seat int) string {
if seat < 0 || seat >= len(g.Players) {
return "?"
}
return g.Players[seat].Name
}
func seatList(g *Game, seats []int) string {
if len(seats) == 0 {
return "(none)"
}
names := make([]string, len(seats))
for i, s := range seats {
names[i] = fmt.Sprintf("seat %d (%s)", s, seatName(g, s))
}
return strings.Join(names, ", ")
}
// identifier makes a string safe to paste into a Go function name.
func identifier(s string) string {
var b strings.Builder
for _, r := range strings.ToUpper(s) {
if r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
if b.Len() == 0 || b.String()[0] >= '0' && b.String()[0] <= '9' {
return "Game" + b.String()
}
return b.String()
}
func playerName(g *Game, playerID string) string {
if p := g.PlayerByID(playerID); p != nil {
return fmt.Sprintf("seat %d (%s)", p.Seat, p.Name)
}
return playerID
}
+297
View File
@@ -0,0 +1,297 @@
package game
import (
"encoding/json"
"os"
"strings"
"testing"
)
// eventsJSON renders a result's events for comparison. Two battles that played
// out identically serialize identically, which is exactly the property a
// replayed report has to have.
func eventsJSON(t *testing.T, res *BattleResult) string {
t.Helper()
b, err := json.Marshal(res.Events)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// A report survives a trip through JSON and rebuilds the same game: same decks,
// same shop, same log.
func TestDebugReportRoundTrip(t *testing.T) {
g, p1, p2 := testGame(t)
forceBattle(t, g,
[]Card{g.realPet(t, "Mosquito"), g.pet("Tank", 4)},
[]Card{g.realPet(t, "Dolphin"), g.pet("Wall", 3)},
)
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
blob, err := rep.JSON()
if err != nil {
t.Fatal(err)
}
back, err := ParseDebugReport(blob)
if err != nil {
t.Fatal(err)
}
if back.Summary.Code != g.Code || back.Summary.Round != g.Round {
t.Fatalf("summary lost the game's identity: %+v", back.Summary)
}
if got, want := len(back.Summary.Seats), len(g.Players); got != want {
t.Fatalf("summary lists %d seats, the table has %d", got, want)
}
if back.Summary.LogEntries != len(g.Log) || back.Summary.LogEntries == 0 {
t.Fatalf("summary says %d log entries, the game has %d", back.Summary.LogEntries, len(g.Log))
}
restored, err := back.Game()
if err != nil {
t.Fatal(err)
}
for _, want := range []*Player{p1, p2} {
got := restored.PlayerByID(want.ID)
if got == nil {
t.Fatalf("seat %d didn't survive the round trip", want.Seat)
}
if len(got.Deck) != len(want.Deck) {
t.Fatalf("seat %d came back with %d cards, had %d", want.Seat, len(got.Deck), len(want.Deck))
}
for i := range want.Deck {
if got.Deck[i].ID != want.Deck[i].ID || got.Deck[i].Name != want.Deck[i].Name {
t.Fatalf("seat %d card %d came back as %+v, was %+v", want.Seat, i, got.Deck[i], want.Deck[i])
}
}
}
if len(restored.Battles) != len(g.Battles) {
t.Fatalf("restored %d battles, the round had %d", len(restored.Battles), len(g.Battles))
}
// Each Game() is a fresh copy: mutating one must not disturb the report.
restored.Players[0].Deck = nil
again, err := back.Game()
if err != nil {
t.Fatal(err)
}
if len(again.Players[0].Deck) == 0 {
t.Fatal("Game() handed out a shared copy — poking one restore emptied the next")
}
}
// Replaying a report's battle reproduces it exactly — the same dice, so the
// same events, in the same order, with the same text. This is the property the
// whole report rests on: whatever the player saw, we see again.
func TestDebugReportReplaysBattleExactly(t *testing.T) {
// No RollDie override: the rocks below roll for real, and only the recorded
// tape can make the replay land on the same faces.
g, _, _ := testGame(t)
original := forceBattle(t, g,
[]Card{g.realPet(t, "Dolphin"), g.realPet(t, "Mosquito"), g.pet("Tank", 4)},
[]Card{g.realPet(t, "Mosquito"), g.pet("Wall", 5), g.realPet(t, "Dolphin")},
)
if len(original.Draws) == 0 {
t.Fatal("a battle full of rocks recorded no dice at all")
}
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
replay, err := rep.ReplayBattle(0)
if err != nil {
t.Fatal(err)
}
if got, want := eventsJSON(t, replay), eventsJSON(t, original); got != want {
t.Fatalf("the replay diverged from the recording:\n got %s\nwant %s", got, want)
}
if replay.WinnerSeat != original.WinnerSeat {
t.Fatalf("replay winner seat %d, recorded %d", replay.WinnerSeat, original.WinnerSeat)
}
if len(replay.Draws) != len(original.Draws) {
t.Fatalf("replay rolled %d dice, the recording has %d", len(replay.Draws), len(original.Draws))
}
for i, d := range original.Draws {
if replay.Draws[i] != d {
t.Fatalf("draw %d replayed as %d, was %d", i, replay.Draws[i], d)
}
}
// ReplayResult works off the result alone, without the surrounding game —
// the shape of the fight is identical, only the player names differ.
loose := ReplayResult(original)
if loose == nil {
t.Fatal("ReplayResult refused a well-formed result")
}
if loose.WinnerSeat != original.WinnerSeat || len(loose.Events) != len(original.Events) {
t.Fatalf("ReplayResult diverged: winner %d (want %d), %d events (want %d)",
loose.WinnerSeat, original.WinnerSeat, len(loose.Events), len(original.Events))
}
}
// The workflow the report's own instructions describe: save the JSON, load it
// back from disk in a test, replay the battle.
func TestDebugReportFromFile(t *testing.T) {
g, _, _ := testGame(t)
original := forceBattle(t, g,
[]Card{g.realPet(t, "Dolphin"), g.pet("Tank", 4)},
[]Card{g.pet("Wall", 3), g.realPet(t, "Mosquito")},
)
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
blob, err := rep.JSON()
if err != nil {
t.Fatal(err)
}
path := t.TempDir() + "/" + rep.Filename()
if err := os.WriteFile(path, blob, 0o644); err != nil {
t.Fatal(err)
}
loaded, err := LoadDebugReportFile(path)
if err != nil {
t.Fatal(err)
}
replay, err := loaded.ReplayBattle(0)
if err != nil {
t.Fatal(err)
}
if got, want := eventsJSON(t, replay), eventsJSON(t, original); got != want {
t.Fatalf("a report off disk replayed differently:\n got %s\nwant %s", got, want)
}
if _, err := loaded.ReplayBattle(7); err == nil {
t.Fatal("replaying a battle that isn't in the report should fail, not panic")
}
if _, err := LoadDebugReportFile(path + ".nope"); err == nil {
t.Fatal("loading a missing file should fail")
}
}
// A battle's banked resources are cleared the moment the round ends, so a
// result has to carry them itself or a later replay fights a different battle.
func TestDebugReportReplayRestoresBankedResources(t *testing.T) {
g, p1, _ := testGame(t)
p1.PendingTrumpets = 2
p1.Mana = 3
p1.PendingApplesInPlay = 1
original := forceBattle(t, g,
[]Card{g.pet("Tank", 4)},
[]Card{g.pet("Wall", 3)},
)
if p1.PendingTrumpets != 0 || p1.PendingApplesInPlay != 0 {
// resolveBattles spends the banks; that's what makes recording them
// on the result necessary in the first place.
t.Log("banks cleared by the round, as expected")
}
if in := original.Inputs[0]; in.Trumpets != 2 || in.Mana != 3 || in.ApplesInPlay != 1 {
t.Fatalf("the result didn't record what side 0 brought in: %+v", in)
}
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
replay, err := rep.ReplayBattle(0)
if err != nil {
t.Fatal(err)
}
if got, want := eventsJSON(t, replay), eventsJSON(t, original); got != want {
t.Fatalf("the replay fought a different battle than the recording:\n got %s\nwant %s", got, want)
}
}
// Games saved before the engine recorded its dice are still in the database,
// and their battles can't be reproduced. Saying so beats handing back a battle
// that quietly differs from the one the player saw.
func TestDebugReportRefusesUnrecordedBattle(t *testing.T) {
g, _, _ := testGame(t)
forceBattle(t, g,
[]Card{g.realPet(t, "Dolphin")},
[]Card{g.pet("Wall", 3)},
)
// Strip the recording, the way a result written by an older build looks.
g.Battles[0].Draws = nil
g.Battles[0].StartCardID = 0
if g.Battles[0].Replayable() {
t.Fatal("a result with no recording claims to be replayable")
}
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
_, err = rep.ReplayBattle(0)
if err == nil {
t.Fatal("replaying an unrecorded battle should fail loudly, not roll fresh dice")
}
if !strings.Contains(err.Error(), "predates") {
t.Fatalf("the error should explain why it can't be replayed, got %q", err)
}
if !strings.Contains(rep.Text(), "NOT RECORDED") {
t.Fatal("the text report should flag a battle it can't replay")
}
// Re-fighting it is still on offer, with fresh dice and no promises.
if ReplayResult(g.Battles[0]) == nil {
t.Fatal("ReplayResult should still re-fight an unrecorded battle")
}
}
// The raw state blob out of the database is accepted as a report too, so
// anything game-shaped can be replayed.
func TestParseDebugReportAcceptsBareState(t *testing.T) {
g, _, _ := testGame(t)
blob, err := json.Marshal(g)
if err != nil {
t.Fatal(err)
}
rep, err := ParseDebugReport(blob)
if err != nil {
t.Fatal(err)
}
if rep.Summary.Code != g.Code {
t.Fatalf("bare state parsed as game %q, want %q", rep.Summary.Code, g.Code)
}
if _, err := ParseDebugReport([]byte(`{"nothing":"here"}`)); err == nil {
t.Fatal("a JSON object with no game in it should not parse as a report")
}
if _, err := ParseDebugReport([]byte(`not json`)); err == nil {
t.Fatal("garbage should not parse as a report")
}
}
// The text rendering is the artifact a human actually reads, so it has to name
// the cards in play, the log, and the battle — not just summarize.
func TestDebugReportTextCoversTheGame(t *testing.T) {
g, p1, _ := testGame(t)
forceBattle(t, g,
[]Card{g.realPet(t, "Mosquito")},
[]Card{g.pet("Wall", 9)},
)
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
rep.Note = "the mosquito's rock vanished"
text := rep.Text()
for _, want := range []string{
g.Code, // which game
p1.Name, // who was playing
"Mosquito", // what was on the table
"Play: throw 1 Rock", // and what it was supposed to do
"the mosquito's rock vanished", // the reporter's note
"=== Event log", // the log
"dice tape", // the recording that makes it replayable
"ReplayBattle(0)", // the paste-ready repro
} {
if !strings.Contains(text, want) {
t.Fatalf("the report never mentions %q:\n%s", want, text)
}
}
}
+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.
+48
View File
@@ -1,5 +1,7 @@
package game
import "fmt"
// SimulateBattle resolves a hypothetical battle between the given arranged
// decks (top of deck first) and returns the result. deckA sits at seat 0 and
// deckB at seat 1; firstSeat (0 or 1) is the one holding priority. It runs on
@@ -24,6 +26,52 @@ func SimulateBattle(round, firstSeat int, deckA, deckB []Card, rollDie func() in
return g.runBattle(0, 1)
}
// ReplayResult re-runs a recorded battle from the result it produced. A
// BattleResult carries everything the fight started from — both lineups, each
// side's banked Mana/Trumpets/apples, and the tape of every die it rolled — so
// replaying one reproduces it move for move. That is what makes a debug report
// reproducible: drop the report in a test, replay the battle, and step through
// the same fight the player saw.
//
// The two sides keep their real seat numbers, so WinnerSeat means what it did
// in the original. Player names are not part of a result, though, so they come
// back as "Seat N" and every event's Text reads accordingly — compare
// structure, not prose. DebugReport.ReplayBattle, which has the real game to
// replay against, reproduces the text too. Returns nil for a malformed result.
func ReplayResult(res *BattleResult) *BattleResult {
if res == nil || len(res.Seats) != 2 || len(res.Lineups) != 2 {
return nil
}
g := &Game{Round: res.Round, NextCardID: res.StartCardID,
drawReplay: append([]int(nil), res.Draws...)}
if g.NextCardID == 0 {
g.NextCardID = 1_000_000
}
// Seat the fighters where they really sat: at a bigger table the two sides
// of one battle are not seats 0 and 1, and WinnerSeat is a table seat.
for seat := range max(res.Seats[0], res.Seats[1]) + 1 {
g.Players = append(g.Players, &Player{Name: fmt.Sprintf("Seat %d", seat), Seat: seat})
}
applyBattleInputs(g, res)
return g.runBattle(res.Seats[0], res.Seats[1])
}
// applyBattleInputs stages a game's players for a replay of res: each side's
// lineup and the banked resources it fought with.
func applyBattleInputs(g *Game, res *BattleResult) {
for side, seat := range res.Seats {
if seat < 0 || seat >= len(g.Players) {
continue
}
p := g.Players[seat]
p.Deck = append([]Card(nil), res.Lineups[side]...)
if side < len(res.Inputs) {
in := res.Inputs[side]
p.Mana, p.PendingTrumpets, p.PendingApplesInPlay = in.Mana, in.Trumpets, in.ApplesInPlay
}
}
}
// TierContents returns the full printed contents of a tier's shop deck for the
// default pack. Cards carry placeholder IDs; they are reference data, not live
// instances.