diff --git a/README.md b/README.md index 3d9584c..9bb94f7 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,23 @@ Set via environment or a `.env` file (see `.env.example`): | `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) | +| `DEBUG` | off | Unlocks the in-game buy-any-card panel | ## 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. +Any 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. + +**In game**, the `β‹―` menu has **πŸ› Report a bug**: a note field, a +**Download report** button, and **Copy as text**. It works on any server, DEBUG +or not, so a bug hit in a real game can actually be reported. That does hand the +player their opponents' hands and the shop deck order β€” an accepted trade, since +a report only ever goes to someone seated at that table (`GET +/api/debug/report`, same credentials as the WebSocket). + +**From the command line**, against the server's database: ```sh mise run report # list recent games @@ -58,11 +67,6 @@ 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: diff --git a/internal/game/debug.go b/internal/game/debug.go index 38ea26a..f1177a7 100644 --- a/internal/game/debug.go +++ b/internal/game/debug.go @@ -19,9 +19,10 @@ import ( // 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. +// shop decks β€” and that is an accepted trade: the in-game "Report a bug" button +// hands a player their own report on any server, DEBUG or not, because a bug +// nobody can reproduce costs more than the little a cheat would gain. +// `go run ./cmd/report` reads the same report out of the database. // DebugReportVersion is the report format's version, so an old report found on // disk can be recognized for what it is. diff --git a/internal/server/debug_report_test.go b/internal/server/debug_report_test.go index 9b4e8c0..689cbf4 100644 --- a/internal/server/debug_report_test.go +++ b/internal/server/debug_report_test.go @@ -12,9 +12,10 @@ import ( "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) { +// reportServer stands up an ordinary server β€” DEBUG off, the way it runs in +// production β€” holding one started two-player game, and returns it with the +// credentials for seat 0. +func reportServer(t *testing.T) (*httptest.Server, *game.Game, *game.Player) { t.Helper() st, err := store.Open(t.TempDir()) if err != nil { @@ -31,8 +32,8 @@ func reportServer(t *testing.T, debug bool) (*httptest.Server, *game.Game, *game 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} + srv := New(st, "", false) + srv.rooms[g.ID] = &room{game: g, conns: map[*client]struct{}{}} ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) return ts, g, p1 @@ -52,10 +53,10 @@ func get(t *testing.T, url string) (int, string) { 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. +// 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) + ts, g, p1 := reportServer(t) 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") @@ -90,16 +91,16 @@ func TestDebugReportEndpoint(t *testing.T) { } } -// 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) +// The in-game bug report button has to work on a normal server, so the endpoint +// is not DEBUG-gated β€” but it is still a seated player's own artifact, and never +// reachable on someone else's credentials. +func TestDebugReportNeedsCredentialsNotDebugMode(t *testing.T) { + ts, g, p1 := reportServer(t) 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) + if status != http.StatusOK { + t.Fatalf("a player should get their report without DEBUG, 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) diff --git a/internal/server/server.go b/internal/server/server.go index 3e9d269..e49f057 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -59,15 +59,15 @@ func (s *Server) Handler() http.Handler { // `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. +// This backs the in-game "Report a bug" button, so it is deliberately not +// DEBUG-gated: a reproducible bug report is worth more than the hidden +// information a report gives away. It does hand a seated player their +// opponents' hands and the order of the shop decks, which a determined one +// could read mid-game β€” the trade accepted here is that a player who wants to +// cheat gains little and a player who hits a bug can actually report it. +// Credentials are still required, so a report only ever goes to someone at that +// table. 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 { diff --git a/web/src/components/BugReport.tsx b/web/src/components/BugReport.tsx new file mode 100644 index 0000000..ed6f938 --- /dev/null +++ b/web/src/components/BugReport.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { createPortal } from 'react-dom' +import type { Session } from '../types' +import { debugReportURL, fetchDebugReportText } from '../api' + +// BugReport is the dialog behind the game menu's "Report a bug": it hands the +// player the game's debug report β€” the whole state, both hands, every battle +// with the dice it rolled, and the event log β€” to attach to a bug. Downloading +// the JSON is the useful one, since that form replays in a test. +// +// Unlike the debug card panel this is always available, not DEBUG-gated: a bug +// is worth more than the hidden information the report gives away, and it only +// ever goes to the player who asked for it. +export function BugReport({ session, onClose }: { session: Session; onClose: () => void }) { + const [note, setNote] = useState('') + const [status, setStatus] = useState('') + + const copyText = async () => { + setStatus('Fetching…') + try { + await navigator.clipboard.writeText(await fetchDebugReportText(session, note)) + setStatus('Copied to the clipboard.') + } catch { + setStatus('Could not copy it β€” try the download instead.') + } + } + + return createPortal( +
+
e.stopPropagation()}> +

πŸ› Report a bug

+

+ This saves everything about the game as it stands β€” both decks, the shop, every + battle with the dice it rolled, and the full event log β€” so the situation can be + replayed exactly and fixed. +

+