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
+44 -5
View File
@@ -38,11 +38,49 @@ During development open the Vite URL (http://localhost:5173); it proxies
Set via environment or a `.env` file (see `.env.example`):
| Variable | Default | Purpose |
| ------------ | ---------- | ------------------------------------ |
| `DATA_DIR` | `data` | Directory holding the SQLite DB |
| `PORT` | `8080` | HTTP port |
| `STATIC_DIR` | `web/dist` | Built frontend to serve |
| Variable | Default | Purpose |
| ------------ | ---------- | ------------------------------------------- |
| `DATA_DIR` | `data` | Directory holding the SQLite DB |
| `PORT` | `8080` | HTTP port |
| `STATIC_DIR` | `web/dist` | Built frontend to serve |
| `DEBUG` | off | Unlocks the in-game debug panel (see below) |
## Debugging a game that went wrong
Any saved game can be dumped as a **debug report**: the full state (every deck
card by card, the shop row and the order of the remaining tier decks, discards,
pending choices, each player's banked Mana/Trumpets/apples), the round's battles
with their lineups and *the dice they rolled*, and the entire event log.
```sh
mise run report # list recent games
mise run report -- QWERT # the report, as text, for reading
mise run report -- -json -out internal/game/testdata/bug.json QWERT
```
With `DEBUG=1` the in-game 🐛 panel grows two buttons — **Download JSON** and
**Copy as text** — that pull the same report over `GET /api/debug/report`. A
report exposes both players' hands and the shop deck order, so that endpoint is
DEBUG-only; on a live server use `cmd/report`, which reads the database.
The JSON form is the useful one, because it replays. Drop it in
`internal/game/testdata/` and the battle re-runs exactly — same lineups, same
dice, same events, right down to the log text:
```go
rep, err := LoadDebugReportFile("testdata/bug.json")
res, err := rep.ReplayBattle(0) // 0 = the round's first pairing
if res.WinnerSeat != 1 { // assert what *should* have happened
t.Fatalf("expected seat 1 to win, got %d", res.WinnerSeat)
}
```
That works because every battle records the randomness it consumed
(`BattleResult.Draws`) alongside what it started from (`Lineups`, `Inputs`), so
a result is replayable long after the round cleared those banks. `rep.Game()`
hands back the whole game if the bug wasn't in the battle — carry on from the
shop, replay a different pairing, or read a deck card by card. The text form of
the report ends with a paste-ready version of the test above.
## Rules implemented
@@ -184,6 +222,7 @@ The Unicorn pack adds two mechanics the others don't have:
```
cmd/server/ entrypoint
cmd/report/ debug report dumper (reads the DB directly)
internal/game/ rules engine (pure, fully tested)
internal/ai/ computer opponent (decides from a player View only)
internal/server/ HTTP + WebSocket rooms; drives bot turns
+115
View File
@@ -0,0 +1,115 @@
// Command report prints a debug report for a saved game: the full state, every
// deck card by card, the round's battles with the dice they rolled, and the
// whole event log — enough to replay the situation in a test (see
// game.DebugReport).
//
// It reads the server's database directly, so it works against a live game
// without the server's DEBUG flag, and it works after the fact — the state is
// persisted on every action.
//
// go run ./cmd/report # list recent games
// go run ./cmd/report QWERT # the report, as text
// go run ./cmd/report -json QWERT # the report, as JSON
// go run ./cmd/report -json -out internal/game/testdata/bug.json QWERT
package main
import (
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/greyson/super-auto-pets-board-game/internal/env"
"github.com/greyson/super-auto-pets-board-game/internal/store"
)
func main() {
asJSON := flag.Bool("json", false, "emit the JSON report (replayable) instead of the text one (readable)")
out := flag.String("out", "", "write to this file instead of stdout")
note := flag.String("note", "", "what looked wrong, recorded in the report")
dataDir := flag.String("data", "", "data directory holding games.db (default: $DATA_DIR or ./data)")
limit := flag.Int("limit", 20, "how many games to list")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: report [flags] [game-code-or-id]\n\n"+
"With no game, lists the most recently played ones.\n\nFlags:\n")
flag.PrintDefaults()
}
flag.Parse()
env.Load(".env")
dir := *dataDir
if dir == "" {
dir = env.Get("DATA_DIR", "data")
}
st, err := store.Open(dir)
if err != nil {
fail("open %s: %v", dir, err)
}
defer st.Close()
if flag.NArg() == 0 {
list(st, *limit)
return
}
g, err := st.LoadAny(flag.Arg(0))
if err != nil {
fail("load %s: %v", flag.Arg(0), err)
}
rep, err := g.DebugReport()
if err != nil {
fail("capture %s: %v", g.Code, err)
}
rep.CapturedAt = time.Now().UTC().Format(time.RFC3339)
rep.Note = *note
var body []byte
if *asJSON {
if body, err = rep.JSON(); err != nil {
fail("render: %v", err)
}
} else {
body = []byte(rep.Text())
}
if *out == "" {
os.Stdout.Write(body)
return
}
if err := os.WriteFile(*out, body, 0o644); err != nil {
fail("write %s: %v", *out, err)
}
fmt.Fprintf(os.Stderr, "wrote %s (%d bytes)\n", *out, len(body))
}
// list prints the games on hand, so you can find the one you mean without
// knowing its code.
func list(st *store.Store, limit int) {
games, err := st.Recent(limit)
if err != nil {
fail("list games: %v", err)
}
if len(games) == 0 {
fmt.Println("no games saved yet")
return
}
fmt.Printf("%-6s %-8s %-5s %-7s %s\n", "CODE", "PHASE", "ROUND", "PLAYERS", "SEATS")
for _, g := range games {
var seats []string
for _, p := range g.Players {
name := p.Name
if p.IsBot {
name += " (bot)"
}
seats = append(seats, name)
}
fmt.Printf("%-6s %-8s %-5d %-7d %s\n",
g.Code, g.Phase, g.Round, len(g.Players), strings.Join(seats, ", "))
}
fmt.Printf("\nrun `report <code>` for any of these\n")
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "report: "+format+"\n", args...)
os.Exit(1)
}
+59
View File
@@ -0,0 +1,59 @@
package ai
import (
"encoding/json"
"testing"
"github.com/greyson/super-auto-pets-board-game/internal/game"
)
// A debug report is only worth taking if replaying it reproduces the battle it
// captured, and the situations worth reporting are the messy ones — six seats,
// three packs shuffled together, Mana and Trumpets banked, Komodo shuffling
// apples into a deck mid-fight. This test lives in the ai package because that
// is where full games get played: it hands the bots a table, then replays every
// battle of the last round out of the report and demands the identical result.
//
// If it ever fails, the engine has grown a source of randomness (or a piece of
// battle input) that the result doesn't record, and reports of that battle are
// no longer reproducible.
func TestDebugReportReplaysRealGames(t *testing.T) {
for _, tc := range []struct {
name string
packs []string
levels []float64
}{
{"two seats, one pack", []string{"turtle"}, []float64{0.9, 0.9}},
{"six seats, three packs", []string{"turtle", "golden", "unicorn"},
[]float64{0.9, 0.9, 0.9, 0.9, 0.9, 0.9}},
} {
t.Run(tc.name, func(t *testing.T) {
g := playBotTable(t, tc.packs, tc.levels...)
rep, err := g.DebugReport()
if err != nil {
t.Fatal(err)
}
if len(g.Battles) == 0 {
t.Fatal("a finished game should have battles to replay")
}
for i, recorded := range g.Battles {
replayed, err := rep.ReplayBattle(i)
if err != nil {
t.Fatalf("battle %d: %v", i, err)
}
if got, want := marshal(t, replayed), marshal(t, recorded); got != want {
t.Fatalf("battle %d replayed differently:\nrecorded %s\nreplayed %s", i, want, got)
}
}
})
}
}
func marshal(t *testing.T, res *game.BattleResult) string {
t.Helper()
b, err := json.Marshal(res)
if err != nil {
t.Fatal(err)
}
return string(b)
}
+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.
+115
View File
@@ -0,0 +1,115 @@
package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/greyson/super-auto-pets-board-game/internal/game"
"github.com/greyson/super-auto-pets-board-game/internal/store"
)
// reportServer stands up a server holding one started two-player game, and
// returns it with the credentials for seat 0.
func reportServer(t *testing.T, debug bool) (*httptest.Server, *game.Game, *game.Player) {
t.Helper()
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
g := game.New()
p1, _ := g.AddPlayer("Alice")
g.AddPlayer("Bob")
if err := g.StartGame(); err != nil {
t.Fatal(err)
}
if err := st.Save(g); err != nil {
t.Fatal(err)
}
srv := New(st, "", debug)
srv.rooms[g.ID] = &room{game: g, conns: map[*client]struct{}{}, debug: debug}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, g, p1
}
func get(t *testing.T, url string) (int, string) {
t.Helper()
res, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
return res.StatusCode, string(body)
}
// In DEBUG mode a seated player can pull the report, in both forms, and the
// JSON one restores to the game the server is actually holding.
func TestDebugReportEndpoint(t *testing.T) {
ts, g, p1 := reportServer(t, true)
url := ts.URL + "/api/debug/report?game=" + g.ID + "&player=" + p1.ID + "&token=" + p1.Token
status, body := get(t, url+"&note=shop+row+looked+wrong")
if status != http.StatusOK {
t.Fatalf("json report: status %d, body %s", status, body)
}
var rep game.DebugReport
if err := json.Unmarshal([]byte(body), &rep); err != nil {
t.Fatalf("the report isn't valid JSON: %v", err)
}
if rep.Note != "shop row looked wrong" {
t.Fatalf("the note didn't make it into the report: %q", rep.Note)
}
if rep.CapturedAt == "" {
t.Fatal("the server should stamp the report with a capture time")
}
restored, err := rep.Game()
if err != nil {
t.Fatal(err)
}
if restored.Code != g.Code || len(restored.Players) != len(g.Players) {
t.Fatalf("restored game %s with %d players, want %s with %d",
restored.Code, len(restored.Players), g.Code, len(g.Players))
}
status, text := get(t, url+"&format=text")
if status != http.StatusOK {
t.Fatalf("text report: status %d", status)
}
if !strings.Contains(text, g.Code) || !strings.Contains(text, "=== Players ===") {
t.Fatalf("the text report doesn't look like a report:\n%s", text)
}
}
// A report exposes both players' decks and the shop deck order, so it must not
// be reachable without DEBUG, and never on someone else's credentials.
func TestDebugReportIsGated(t *testing.T) {
ts, g, p1 := reportServer(t, false)
status, _ := get(t, ts.URL+"/api/debug/report?game="+g.ID+"&player="+p1.ID+"&token="+p1.Token)
if status != http.StatusNotFound {
t.Fatalf("without DEBUG the endpoint should 404, got %d", status)
}
ts, g, p1 = reportServer(t, true)
status, _ = get(t, ts.URL+"/api/debug/report?game="+g.ID+"&player="+p1.ID+"&token=wrong")
if status != http.StatusForbidden {
t.Fatalf("a bad token should 403, got %d", status)
}
status, _ = get(t, ts.URL+"/api/debug/report?game="+g.ID+"&player=nobody&token="+p1.Token)
if status != http.StatusForbidden {
t.Fatalf("an unknown player should 403, got %d", status)
}
status, _ = get(t, ts.URL+"/api/debug/report?game=nosuchgame&player="+p1.ID+"&token="+p1.Token)
if status != http.StatusNotFound {
t.Fatalf("an unknown game should 404, got %d", status)
}
}
+55
View File
@@ -13,6 +13,7 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/greyson/super-auto-pets-board-game/internal/game"
"github.com/greyson/super-auto-pets-board-game/internal/store"
@@ -47,10 +48,64 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/join", s.handleJoin)
mux.HandleFunc("GET /api/ws", s.handleWS)
mux.HandleFunc("GET /api/catalog", s.handleCatalog)
mux.HandleFunc("GET /api/debug/report", s.handleDebugReport)
mux.HandleFunc("/", s.handleStatic)
return mux
}
// handleDebugReport dumps a game's full state, battles and event log as a
// debug report (see game.DebugReport) — everything needed to replay a situation
// that went wrong in a test. Params match the WebSocket's: game, player, token.
// `format=text` renders it for reading instead of as JSON, and `note=` records
// what looked wrong.
//
// A report holds hidden information — the opponents' decks, the order of the
// shop decks — so a seated player must not be able to pull one mid-game. It is
// DEBUG-only for that reason; on a live server, `go run ./cmd/report` reads the
// same report straight out of the database instead.
func (s *Server) handleDebugReport(w http.ResponseWriter, req *http.Request) {
if !s.debug {
httpError(w, http.StatusNotFound, "debug reports are only served in DEBUG mode")
return
}
q := req.URL.Query()
r, err := s.getRoom(q.Get("game"))
if err != nil {
httpError(w, http.StatusNotFound, "game not found")
return
}
r.mu.Lock()
p := r.game.PlayerByID(q.Get("player"))
if p == nil || p.Token != q.Get("token") {
r.mu.Unlock()
httpError(w, http.StatusForbidden, "bad player credentials")
return
}
rep, err := r.game.DebugReport()
r.mu.Unlock()
if err != nil {
httpError(w, http.StatusInternalServerError, "failed to capture the game: "+err.Error())
return
}
rep.CapturedAt = time.Now().UTC().Format(time.RFC3339)
rep.Note = q.Get("note")
if q.Get("format") == "text" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(rep.Text()))
return
}
blob, err := rep.JSON()
if err != nil {
httpError(w, http.StatusInternalServerError, "failed to render the report")
return
}
w.Header().Set("Content-Type", "application/json")
// Named so a browser download lands as a file you can drop into testdata.
w.Header().Set("Content-Disposition", `attachment; filename="`+rep.Filename()+`"`)
w.Write(blob)
}
// handleCatalog returns every card in the requested packs, for the debug panel
// and the event log's card previews. Packs come as a repeated or
// comma-separated ?pack= parameter and default to Turtle; unknown ids fall back
+39
View File
@@ -79,6 +79,45 @@ func (s *Store) LoadByCode(code string) (*game.Game, error) {
return s.loadWhere(`code = ?`, code)
}
// LoadAny fetches a game by join code or by ID, whichever the argument looks
// like — the convenience the report command wants, since a bug report quotes
// whichever of the two the reporter had to hand.
func (s *Store) LoadAny(idOrCode string) (*game.Game, error) {
if g, err := s.LoadByCode(idOrCode); err == nil {
return g, nil
} else if !errors.Is(err, ErrNotFound) {
return nil, err
}
return s.Load(idOrCode)
}
// Recent returns the most recently updated games, newest first, for picking one
// out by hand. Each is fully loaded, so callers can report on it directly.
func (s *Store) Recent(limit int) ([]*game.Game, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.db.Query(`SELECT state FROM games ORDER BY updated_at DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var games []*game.Game
for rows.Next() {
var blob string
if err := rows.Scan(&blob); err != nil {
return nil, err
}
var g game.Game
if err := json.Unmarshal([]byte(blob), &g); err != nil {
// One corrupt row shouldn't hide the rest of the list.
continue
}
games = append(games, &g)
}
return games, rows.Err()
}
func (s *Store) loadWhere(cond string, arg any) (*game.Game, error) {
var blob string
err := s.db.QueryRow(`SELECT state FROM games WHERE `+cond, arg).Scan(&blob)
+4
View File
@@ -30,6 +30,10 @@ description = "Build everything: frontend + server binary (bin/server)"
depends = ["build-web"]
run = "go build -o bin/server ./cmd/server"
[tasks.report]
description = "Dump a game's debug report (no args: list recent games)"
run = "go run ./cmd/report"
[tasks.test]
description = "Run all Go tests"
run = "go test ./..."
+21
View File
@@ -32,6 +32,27 @@ export async function fetchCatalog(packs?: string[]): Promise<Card[]> {
return (await res.json()) as Card[]
}
// debugReportURL builds the link to a game's debug report — the full state,
// battles and event log, enough to replay the situation in a test. Served only
// when the server runs with DEBUG on. `text` renders it for reading; otherwise
// it downloads as JSON, which is the form a test can replay.
export function debugReportURL(session: Session, format: 'json' | 'text', note?: string): string {
const params = new URLSearchParams({
game: session.gameId,
player: session.playerId,
token: session.token,
format,
})
if (note) params.set('note', note)
return `/api/debug/report?${params}`
}
export async function fetchDebugReportText(session: Session, note?: string): Promise<string> {
const res = await fetch(debugReportURL(session, 'text', note))
if (!res.ok) throw new Error('failed to fetch the debug report')
return res.text()
}
export function loadSession(): Session | null {
try {
const raw = localStorage.getItem(SESSION_KEY)
+46 -6
View File
@@ -1,20 +1,28 @@
import { useEffect, useState } from 'react'
import type { Card, ClientMessage } from '../types'
import { fetchCatalog } from '../api'
import type { Card, ClientMessage, Session } from '../types'
import { debugReportURL, fetchCatalog, fetchDebugReportText } from '../api'
import { CardView } from './CardView'
interface Props {
canGrant: boolean // shop phase — grants only land then
packs: string[] // packs in play, so the catalog matches the game
session: Session // credentials for the debug report endpoint
send: (msg: ClientMessage) => void
}
// DebugPanel is a testing aid (server DEBUG mode only): a collapsible drawer
// listing every card in the packs in play, tier by tier. Clicking one drops it
// into your deck for free, off-turn.
export function DebugPanel({ canGrant, packs, send }: Props) {
// DebugPanel is a testing aid (server DEBUG mode only). Two things live here:
// a grab-any-card list of every card in the packs in play, tier by tier, and
// the debug report — a dump of the whole game (both decks, the battles and their
// dice, the event log) that can be replayed in a test.
export function DebugPanel({ canGrant, packs, session, send }: Props) {
const [open, setOpen] = useState(false)
const [catalog, setCatalog] = useState<Card[]>([])
// What the report button last did, shown next to it and cleared on the next
// click — a report is silent otherwise, and copying especially so.
const [reportMsg, setReportMsg] = useState('')
// Travels with the report, so the dump itself says what looked wrong. Shared
// by both buttons: the download is a plain link and can't ask for it later.
const [note, setNote] = useState('')
// Joined into a stable key so a fresh array identity each render doesn't refetch.
const packKey = packs.join(',')
@@ -26,6 +34,17 @@ export function DebugPanel({ canGrant, packs, send }: Props) {
const tiers = [...new Set(catalog.map((c) => c.tier ?? 0))].sort((a, b) => a - b)
const copyReport = async () => {
setReportMsg('…')
try {
const text = await fetchDebugReportText(session, note)
await navigator.clipboard.writeText(text)
setReportMsg('copied to the clipboard')
} catch {
setReportMsg('failed — is the server in DEBUG mode?')
}
}
return (
<div className={`debug-panel ${open ? 'is-open' : ''}`}>
<button className="debug-toggle" onClick={() => setOpen((o) => !o)}>
@@ -33,6 +52,27 @@ export function DebugPanel({ canGrant, packs, send }: Props) {
</button>
{open && (
<div className="debug-body">
<div className="debug-head">Debug report</div>
<div className="debug-report">
<input
className="debug-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="What went wrong? (saved in the report)"
/>
<a
className="debug-report-btn"
href={debugReportURL(session, 'json', note)}
onClick={() => setReportMsg('downloaded — replay it with rep.ReplayBattle(0)')}
download
>
Download JSON
</a>
<button className="debug-report-btn" onClick={copyReport}>
📋 Copy as text
</button>
{reportMsg && <div className="muted">{reportMsg}</div>}
</div>
<div className="debug-head">
Buy any card
{!canGrant && <span className="muted"> · only in the shop</span>}
+6 -1
View File
@@ -323,7 +323,12 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)}
{error && <div className="toast">{error}</div>}
{view.debug && (
<DebugPanel canGrant={view.phase === 'shop'} packs={view.packs} send={send} />
<DebugPanel
canGrant={view.phase === 'shop'}
packs={view.packs}
session={session}
send={send}
/>
)}
{deckPeek &&
+43
View File
@@ -3536,6 +3536,49 @@ h3 {
margin-bottom: 8px;
}
/* The report buttons sit above the card list, small and out of the way — they
are used once a session, when something has already gone wrong. */
.debug-report {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
font-size: 0.75rem;
}
.debug-report-btn {
background: rgba(255, 255, 255, 0.1);
color: var(--cream);
border: 1px solid var(--cocoa);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.debug-report-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.debug-report .muted {
flex-basis: 100%;
}
.debug-note {
flex-basis: 100%;
background: rgba(0, 0, 0, 0.3);
color: var(--cream);
border: 1px solid var(--cocoa);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
}
.debug-tier-label {
color: var(--cream);
font-size: 0.75rem;