Files
Greyson Parrelli 8ccde03023 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.
2026-08-10 10:06:06 -04:00

298 lines
9.4 KiB
Go

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)
}
}
}