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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user