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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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] }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ./..."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user