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 committed 2026-08-10 13:42:31 -04:00
1 parent 8ccde03023
commit 6ae4d41976
9 files changed
+167 -123

No files matched your search

+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"}