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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user