diff --git a/README.md b/README.md index ac6f912..3d9584c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/report/main.go b/cmd/report/main.go new file mode 100644 index 0000000..15f44c7 --- /dev/null +++ b/cmd/report/main.go @@ -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 ` for any of these\n") +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "report: "+format+"\n", args...) + os.Exit(1) +} diff --git a/internal/ai/debug_report_test.go b/internal/ai/debug_report_test.go new file mode 100644 index 0000000..4d95759 --- /dev/null +++ b/internal/ai/debug_report_test.go @@ -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) +} diff --git a/internal/game/battle.go b/internal/game/battle.go index 808ce9a..3f6c67a 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -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] } diff --git a/internal/game/debug.go b/internal/game/debug.go new file mode 100644 index 0000000..38ea26a --- /dev/null +++ b/internal/game/debug.go @@ -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 +} diff --git a/internal/game/debug_test.go b/internal/game/debug_test.go new file mode 100644 index 0000000..9f19d99 --- /dev/null +++ b/internal/game/debug_test.go @@ -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) + } + } +} diff --git a/internal/game/game.go b/internal/game/game.go index 2005516..3b6eaf9 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -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. diff --git a/internal/game/sim.go b/internal/game/sim.go index 1bbec55..6071314 100644 --- a/internal/game/sim.go +++ b/internal/game/sim.go @@ -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. diff --git a/internal/server/debug_report_test.go b/internal/server/debug_report_test.go new file mode 100644 index 0000000..9b4e8c0 --- /dev/null +++ b/internal/server/debug_report_test.go @@ -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+"¬e=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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 57e976d..3e9d269 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/store/store.go b/internal/store/store.go index 3ea8c62..91626f7 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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) diff --git a/mise.toml b/mise.toml index 5d307d2..10a1dfd 100644 --- a/mise.toml +++ b/mise.toml @@ -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 ./..." diff --git a/web/src/api.ts b/web/src/api.ts index b3d78bc..09a1446 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -32,6 +32,27 @@ export async function fetchCatalog(packs?: string[]): Promise { 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 { + 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) diff --git a/web/src/components/DebugPanel.tsx b/web/src/components/DebugPanel.tsx index 4e402b0..1da80ed 100644 --- a/web/src/components/DebugPanel.tsx +++ b/web/src/components/DebugPanel.tsx @@ -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([]) + // 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 (
{open && (
+
Debug report
+
+ setNote(e.target.value)} + placeholder="What went wrong? (saved in the report)" + /> + setReportMsg('downloaded โ€” replay it with rep.ReplayBattle(0)')} + download + > + โฌ‡ Download JSON + + + {reportMsg &&
{reportMsg}
} +
Buy any card {!canGrant && ยท only in the shop} diff --git a/web/src/components/Table.tsx b/web/src/components/Table.tsx index fd050c9..e649eab 100644 --- a/web/src/components/Table.tsx +++ b/web/src/components/Table.tsx @@ -323,7 +323,12 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v )} {error &&
{error}
} {view.debug && ( - + )} {deckPeek && diff --git a/web/src/styles.css b/web/src/styles.css index 4849c72..00f3505 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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;