Put the bug report behind an always-available button.

The debug report was only reachable in DEBUG mode, which is the one mode
a player hitting a real bug won't be running. Move it to a "🐛 Report a
bug" item in the game menu: a note field, a download, and a copy-as-text,
on any server.

That knowingly shows the reporter their opponents' hands and the shop
deck order. Credentials are still required, so a report only ever reaches
someone seated at that table. The buy-any-card panel stays DEBUG-only —
it changes the game; a report only reads it.
This commit is contained in:
Greyson Parrelli
2026-08-10 13:42:31 -04:00
parent 8ccde03023
commit 6ae4d41976
9 changed files with 167 additions and 123 deletions
+14 -10
View File
@@ -43,14 +43,23 @@ Set via environment or a `.env` file (see `.env.example`):
| `DATA_DIR` | `data` | Directory holding the SQLite DB | | `DATA_DIR` | `data` | Directory holding the SQLite DB |
| `PORT` | `8080` | HTTP port | | `PORT` | `8080` | HTTP port |
| `STATIC_DIR` | `web/dist` | Built frontend to serve | | `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 ## Debugging a game that went wrong
Any saved game can be dumped as a **debug report**: the full state (every deck Any game can be dumped as a **debug report**: the full state (every deck card by
card by card, the shop row and the order of the remaining tier decks, discards, card, the shop row and the order of the remaining tier decks, discards, pending
pending choices, each player's banked Mana/Trumpets/apples), the round's battles choices, each player's banked Mana/Trumpets/apples), the round's battles with
with their lineups and *the dice they rolled*, and the entire event log. 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 ```sh
mise run report # list recent games 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 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 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 `internal/game/testdata/` and the battle re-runs exactly — same lineups, same
dice, same events, right down to the log text: dice, same events, right down to the log text:
+4 -3
View File
@@ -19,9 +19,10 @@ import (
// work out which effect misfired. Report.Text() ends with a paste-ready test. // work out which effect misfired. Report.Text() ends with a paste-ready test.
// //
// Both forms contain hidden information — opponents' decks, the order of the // 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 // shop decks — and that is an accepted trade: the in-game "Report a bug" button
// server only serves one in DEBUG mode; `go run ./cmd/report` pulls one out of // hands a player their own report on any server, DEBUG or not, because a bug
// the database with no such gate, because by then you are the operator. // 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 // DebugReportVersion is the report format's version, so an old report found on
// disk can be recognized for what it is. // disk can be recognized for what it is.
+16 -15
View File
@@ -12,9 +12,10 @@ import (
"github.com/greyson/super-auto-pets-board-game/internal/store" "github.com/greyson/super-auto-pets-board-game/internal/store"
) )
// reportServer stands up a server holding one started two-player game, and // reportServer stands up an ordinary server — DEBUG off, the way it runs in
// returns it with the credentials for seat 0. // production — holding one started two-player game, and returns it with the
func reportServer(t *testing.T, debug bool) (*httptest.Server, *game.Game, *game.Player) { // credentials for seat 0.
func reportServer(t *testing.T) (*httptest.Server, *game.Game, *game.Player) {
t.Helper() t.Helper()
st, err := store.Open(t.TempDir()) st, err := store.Open(t.TempDir())
if err != nil { 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 { if err := st.Save(g); err != nil {
t.Fatal(err) t.Fatal(err)
} }
srv := New(st, "", debug) srv := New(st, "", false)
srv.rooms[g.ID] = &room{game: g, conns: map[*client]struct{}{}, debug: debug} srv.rooms[g.ID] = &room{game: g, conns: map[*client]struct{}{}}
ts := httptest.NewServer(srv.Handler()) ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
return ts, g, p1 return ts, g, p1
@@ -52,10 +53,10 @@ func get(t *testing.T, url string) (int, string) {
return res.StatusCode, string(body) return res.StatusCode, string(body)
} }
// In DEBUG mode a seated player can pull the report, in both forms, and the // A seated player can pull the report in both forms, and the JSON one restores
// JSON one restores to the game the server is actually holding. // to the game the server is actually holding.
func TestDebugReportEndpoint(t *testing.T) { 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 url := ts.URL + "/api/debug/report?game=" + g.ID + "&player=" + p1.ID + "&token=" + p1.Token
status, body := get(t, url+"&note=shop+row+looked+wrong") status, body := get(t, url+"&note=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 // The in-game bug report button has to work on a normal server, so the endpoint
// be reachable without DEBUG, and never on someone else's credentials. // is not DEBUG-gated — but it is still a seated player's own artifact, and never
func TestDebugReportIsGated(t *testing.T) { // reachable on someone else's credentials.
ts, g, p1 := reportServer(t, false) 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) status, _ := get(t, ts.URL+"/api/debug/report?game="+g.ID+"&player="+p1.ID+"&token="+p1.Token)
if status != http.StatusNotFound { if status != http.StatusOK {
t.Fatalf("without DEBUG the endpoint should 404, got %d", status) 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") status, _ = get(t, ts.URL+"/api/debug/report?game="+g.ID+"&player="+p1.ID+"&token=wrong")
if status != http.StatusForbidden { if status != http.StatusForbidden {
t.Fatalf("a bad token should 403, got %d", status) t.Fatalf("a bad token should 403, got %d", status)
+8 -8
View File
@@ -59,15 +59,15 @@ func (s *Server) Handler() http.Handler {
// `format=text` renders it for reading instead of as JSON, and `note=` records // `format=text` renders it for reading instead of as JSON, and `note=` records
// what looked wrong. // what looked wrong.
// //
// A report holds hidden information — the opponents' decks, the order of the // This backs the in-game "Report a bug" button, so it is deliberately not
// shop decks — so a seated player must not be able to pull one mid-game. It is // DEBUG-gated: a reproducible bug report is worth more than the hidden
// DEBUG-only for that reason; on a live server, `go run ./cmd/report` reads the // information a report gives away. It does hand a seated player their
// same report straight out of the database instead. // 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) { 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() q := req.URL.Query()
r, err := s.getRoom(q.Get("game")) r, err := s.getRoom(q.Get("game"))
if err != nil { if err != nil {
+66
View File
@@ -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(
<div className="modal-backdrop" onClick={onClose}>
<div className="modal bug-modal" onClick={(e) => e.stopPropagation()}>
<h2 className="bug-title">🐛 Report a bug</h2>
<p className="bug-blurb">
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.
</p>
<textarea
className="bug-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="What went wrong? (optional, but it helps a lot)"
rows={3}
autoFocus
/>
<div className="bug-actions">
<a
className="btn"
href={debugReportURL(session, 'json', note)}
onClick={() => setStatus('Downloaded — attach that file to the bug.')}
download
>
Download report
</a>
<button className="btn btn-ghost" onClick={copyText}>
📋 Copy as text
</button>
</div>
{status && <div className="muted">{status}</div>}
<button className="btn btn-ghost btn-sm" onClick={onClose}>
Close
</button>
</div>
</div>,
document.body,
)
}
+6 -46
View File
@@ -1,28 +1,20 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { Card, ClientMessage, Session } from '../types' import type { Card, ClientMessage } from '../types'
import { debugReportURL, fetchCatalog, fetchDebugReportText } from '../api' import { fetchCatalog } from '../api'
import { CardView } from './CardView' import { CardView } from './CardView'
interface Props { interface Props {
canGrant: boolean // shop phase — grants only land then canGrant: boolean // shop phase — grants only land then
packs: string[] // packs in play, so the catalog matches the game packs: string[] // packs in play, so the catalog matches the game
session: Session // credentials for the debug report endpoint
send: (msg: ClientMessage) => void send: (msg: ClientMessage) => void
} }
// DebugPanel is a testing aid (server DEBUG mode only). Two things live here: // DebugPanel is a testing aid (server DEBUG mode only): a collapsible drawer
// a grab-any-card list of every card in the packs in play, tier by tier, and // listing every card in the packs in play, tier by tier. Clicking one drops it
// the debug report — a dump of the whole game (both decks, the battles and their // into your deck for free, off-turn.
// dice, the event log) that can be replayed in a test. export function DebugPanel({ canGrant, packs, send }: Props) {
export function DebugPanel({ canGrant, packs, session, send }: Props) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [catalog, setCatalog] = useState<Card[]>([]) 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. // Joined into a stable key so a fresh array identity each render doesn't refetch.
const packKey = packs.join(',') const packKey = packs.join(',')
@@ -34,17 +26,6 @@ export function DebugPanel({ canGrant, packs, session, send }: Props) {
const tiers = [...new Set(catalog.map((c) => c.tier ?? 0))].sort((a, b) => a - b) 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 ( return (
<div className={`debug-panel ${open ? 'is-open' : ''}`}> <div className={`debug-panel ${open ? 'is-open' : ''}`}>
<button className="debug-toggle" onClick={() => setOpen((o) => !o)}> <button className="debug-toggle" onClick={() => setOpen((o) => !o)}>
@@ -52,27 +33,6 @@ export function DebugPanel({ canGrant, packs, session, send }: Props) {
</button> </button>
{open && ( {open && (
<div className="debug-body"> <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"> <div className="debug-head">
Buy any card Buy any card
{!canGrant && <span className="muted"> · only in the shop</span>} {!canGrant && <span className="muted"> · only in the shop</span>}
+17 -8
View File
@@ -11,6 +11,7 @@ import { ArrangePhase } from './ArrangePhase'
import { BattlePhase } from './BattlePhase' import { BattlePhase } from './BattlePhase'
import { GameOver } from './GameOver' import { GameOver } from './GameOver'
import { DebugPanel } from './DebugPanel' import { DebugPanel } from './DebugPanel'
import { BugReport } from './BugReport'
import { EventLog, battleLogLines } from './EventLog' import { EventLog, battleLogLines } from './EventLog'
// Table connects to the game and routes to the right phase screen. // Table connects to the game and routes to the right phase screen.
@@ -61,9 +62,10 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
// this open flag is simply ignored. // this open flag is simply ignored.
const [logOpen, setLogOpen] = useState(false) const [logOpen, setLogOpen] = useState(false)
// The room code and Leave button live behind a "⋯" menu in the topbar rail, // The room code, the bug reporter and Leave live behind a "⋯" menu in the
// keeping the rail short enough for one row on a phone. // topbar rail, keeping the rail short enough for one row on a phone.
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [bugOpen, setBugOpen] = useState(false)
// The battle outcome is known before the replay plays out, so we hold its // The battle outcome is known before the replay plays out, so we hold its
// "result" log entry back: it's dropped from the persistent list during the // "result" log entry back: it's dropped from the persistent list during the
@@ -257,6 +259,16 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{view.code} {view.code}
</span> </span>
</div> </div>
<button
className="btn btn-ghost btn-sm"
role="menuitem"
onClick={() => {
setMenuOpen(false)
setBugOpen(true)
}}
>
🐛 Report a bug
</button>
<button <button
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
role="menuitem" role="menuitem"
@@ -323,14 +335,11 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
)} )}
{error && <div className="toast">{error}</div>} {error && <div className="toast">{error}</div>}
{view.debug && ( {view.debug && (
<DebugPanel <DebugPanel canGrant={view.phase === 'shop'} packs={view.packs} send={send} />
canGrant={view.phase === 'shop'}
packs={view.packs}
session={session}
send={send}
/>
)} )}
{bugOpen && <BugReport session={session} onClose={() => setBugOpen(false)} />}
{deckPeek && {deckPeek &&
view.phase === 'shop' && view.phase === 'shop' &&
(() => { (() => {
+35 -32
View File
@@ -3536,47 +3536,50 @@ h3 {
margin-bottom: 8px; margin-bottom: 8px;
} }
/* The report buttons sit above the card list, small and out of the way — they /* --- bug report dialog (always available, not a DEBUG affordance) --- */
are used once a session, when something has already gone wrong. */
.debug-report { .bug-modal {
display: flex; width: min(460px, 92vw);
flex-wrap: wrap; gap: 14px;
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 { .bug-title {
background: rgba(255, 255, 255, 0.1); font-family: var(--font-display);
color: var(--cream); font-size: 1.3rem;
border: 1px solid var(--cocoa); margin: 0;
border-radius: 6px;
padding: 5px 8px;
font: inherit;
font-weight: 700;
text-decoration: none;
cursor: pointer;
} }
.debug-report-btn:hover { .bug-blurb {
background: rgba(255, 255, 255, 0.2); margin: 0;
font-size: 0.85rem;
line-height: 1.5;
opacity: 0.8;
} }
.debug-report .muted { .bug-note {
flex-basis: 100%;
}
.debug-note {
flex-basis: 100%;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
color: var(--cream); color: var(--cream);
border: 1px solid var(--cocoa); border: 1px solid rgba(253, 243, 220, 0.25);
border-radius: 6px; border-radius: 10px;
padding: 5px 8px; padding: 8px 10px;
font: inherit; font: inherit;
font-size: 0.9rem;
resize: vertical;
}
.bug-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
/* The download is an <a> so the browser saves the file; make it sit level with
the button beside it. */
.bug-actions a.btn {
text-decoration: none;
display: inline-flex;
align-items: center;
} }
.debug-tier-label { .debug-tier-label {
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/anim.ts","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useBattleSpeed.ts","./src/useCatalog.ts","./src/useGame.ts","./src/useMediaQuery.ts","./src/vite-env.d.ts","./src/components/ArrangePhase.tsx","./src/components/BattlePhase.tsx","./src/components/CardView.tsx","./src/components/DebugPanel.tsx","./src/components/DiceRoll.tsx","./src/components/EventLog.tsx","./src/components/GameOver.tsx","./src/components/Home.tsx","./src/components/Lobby.tsx","./src/components/ShopPhase.tsx","./src/components/Table.tsx"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/anim.ts","./src/api.ts","./src/main.tsx","./src/petArt.ts","./src/types.ts","./src/useBattleSpeed.ts","./src/useCatalog.ts","./src/useGame.ts","./src/useMediaQuery.ts","./src/vite-env.d.ts","./src/components/ArrangePhase.tsx","./src/components/BattlePhase.tsx","./src/components/BugReport.tsx","./src/components/CardView.tsx","./src/components/DebugPanel.tsx","./src/components/DiceRoll.tsx","./src/components/EventLog.tsx","./src/components/GameOver.tsx","./src/components/Home.tsx","./src/components/Lobby.tsx","./src/components/ShopPhase.tsx","./src/components/Table.tsx"],"version":"5.9.3"}