Add replayable debug reports.

A report dumps everything needed to understand a game that went wrong:
the full state (decks card by card, shop row and tier deck order,
discards, pending choices, banked Mana/Trumpets/apples), the round's
battles, and the whole event log. Available as JSON, which replays, or
text, which reads — and the text ends with a paste-ready repro test.

Battles now record the randomness they consume alongside what they
started from, so a result can be replayed long after the round cleared
those banks: same lineups, same dice, same events, down to the log text.
Results predating the recording say so rather than quietly re-rolling.

Three ways in: the 🐛 panel's download/copy buttons, GET
/api/debug/report (DEBUG-only, since a report holds both players' hands
and the shop deck order), and `mise run report`, which reads the
database directly so a live game can be dumped without DEBUG.
This commit is contained in:
Greyson Parrelli
2026-08-10 10:06:06 -04:00
parent a4f5f6910d
commit 8ccde03023
16 changed files with 1480 additions and 19 deletions
+55
View File
@@ -13,6 +13,7 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/greyson/super-auto-pets-board-game/internal/game"
"github.com/greyson/super-auto-pets-board-game/internal/store"
@@ -47,10 +48,64 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/join", s.handleJoin)
mux.HandleFunc("GET /api/ws", s.handleWS)
mux.HandleFunc("GET /api/catalog", s.handleCatalog)
mux.HandleFunc("GET /api/debug/report", s.handleDebugReport)
mux.HandleFunc("/", s.handleStatic)
return mux
}
// handleDebugReport dumps a game's full state, battles and event log as a
// debug report (see game.DebugReport) — everything needed to replay a situation
// that went wrong in a test. Params match the WebSocket's: game, player, token.
// `format=text` renders it for reading instead of as JSON, and `note=` records
// what looked wrong.
//
// A report holds hidden information — the opponents' decks, the order of the
// shop decks — so a seated player must not be able to pull one mid-game. It is
// DEBUG-only for that reason; on a live server, `go run ./cmd/report` reads the
// same report straight out of the database instead.
func (s *Server) handleDebugReport(w http.ResponseWriter, req *http.Request) {
if !s.debug {
httpError(w, http.StatusNotFound, "debug reports are only served in DEBUG mode")
return
}
q := req.URL.Query()
r, err := s.getRoom(q.Get("game"))
if err != nil {
httpError(w, http.StatusNotFound, "game not found")
return
}
r.mu.Lock()
p := r.game.PlayerByID(q.Get("player"))
if p == nil || p.Token != q.Get("token") {
r.mu.Unlock()
httpError(w, http.StatusForbidden, "bad player credentials")
return
}
rep, err := r.game.DebugReport()
r.mu.Unlock()
if err != nil {
httpError(w, http.StatusInternalServerError, "failed to capture the game: "+err.Error())
return
}
rep.CapturedAt = time.Now().UTC().Format(time.RFC3339)
rep.Note = q.Get("note")
if q.Get("format") == "text" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(rep.Text()))
return
}
blob, err := rep.JSON()
if err != nil {
httpError(w, http.StatusInternalServerError, "failed to render the report")
return
}
w.Header().Set("Content-Type", "application/json")
// Named so a browser download lands as a file you can drop into testdata.
w.Header().Set("Content-Disposition", `attachment; filename="`+rep.Filename()+`"`)
w.Write(blob)
}
// handleCatalog returns every card in the requested packs, for the debug panel
// and the event log's card previews. Packs come as a repeated or
// comma-separated ?pack= parameter and default to Turtle; unknown ids fall back