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 |
| `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:
+4 -3
View File
@@ -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.
+16 -15
View File
@@ -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+"&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
// 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)
+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
// 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 {
+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 type { Card, ClientMessage, Session } from '../types'
import { debugReportURL, fetchCatalog, fetchDebugReportText } from '../api'
import type { Card, ClientMessage } from '../types'
import { fetchCatalog } 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). 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) {
// 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) {
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(',')
@@ -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 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)}>
@@ -52,27 +33,6 @@ export function DebugPanel({ canGrant, packs, session, 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>}
+17 -8
View File
@@ -11,6 +11,7 @@ import { ArrangePhase } from './ArrangePhase'
import { BattlePhase } from './BattlePhase'
import { GameOver } from './GameOver'
import { DebugPanel } from './DebugPanel'
import { BugReport } from './BugReport'
import { EventLog, battleLogLines } from './EventLog'
// 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.
const [logOpen, setLogOpen] = useState(false)
// The room code and Leave button live behind a "⋯" menu in the topbar rail,
// keeping the rail short enough for one row on a phone.
// The room code, the bug reporter and Leave live behind a "⋯" menu in the
// topbar rail, keeping the rail short enough for one row on a phone.
const [menuOpen, setMenuOpen] = useState(false)
const [bugOpen, setBugOpen] = useState(false)
// 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
@@ -257,6 +259,16 @@ export function Table({ session, onLeave }: { session: Session; onLeave: () => v
{view.code}
</span>
</div>
<button
className="btn btn-ghost btn-sm"
role="menuitem"
onClick={() => {
setMenuOpen(false)
setBugOpen(true)
}}
>
🐛 Report a bug
</button>
<button
className="btn btn-ghost btn-sm"
role="menuitem"
@@ -323,14 +335,11 @@ 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}
session={session}
send={send}
/>
<DebugPanel canGrant={view.phase === 'shop'} packs={view.packs} send={send} />
)}
{bugOpen && <BugReport session={session} onClose={() => setBugOpen(false)} />}
{deckPeek &&
view.phase === 'shop' &&
(() => {
+35 -32
View File
@@ -3536,47 +3536,50 @@ 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;
/* --- bug report dialog (always available, not a DEBUG affordance) --- */
.bug-modal {
width: min(460px, 92vw);
gap: 14px;
}
.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;
.bug-title {
font-family: var(--font-display);
font-size: 1.3rem;
margin: 0;
}
.debug-report-btn:hover {
background: rgba(255, 255, 255, 0.2);
.bug-blurb {
margin: 0;
font-size: 0.85rem;
line-height: 1.5;
opacity: 0.8;
}
.debug-report .muted {
flex-basis: 100%;
}
.debug-note {
flex-basis: 100%;
.bug-note {
background: rgba(0, 0, 0, 0.3);
color: var(--cream);
border: 1px solid var(--cocoa);
border-radius: 6px;
padding: 5px 8px;
border: 1px solid rgba(253, 243, 220, 0.25);
border-radius: 10px;
padding: 8px 10px;
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 {
+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"}