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.
298 lines
9.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|