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