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:
@@ -32,6 +32,27 @@ export async function fetchCatalog(packs?: string[]): Promise<Card[]> {
|
||||
return (await res.json()) as Card[]
|
||||
}
|
||||
|
||||
// debugReportURL builds the link to a game's debug report — the full state,
|
||||
// battles and event log, enough to replay the situation in a test. Served only
|
||||
// when the server runs with DEBUG on. `text` renders it for reading; otherwise
|
||||
// it downloads as JSON, which is the form a test can replay.
|
||||
export function debugReportURL(session: Session, format: 'json' | 'text', note?: string): string {
|
||||
const params = new URLSearchParams({
|
||||
game: session.gameId,
|
||||
player: session.playerId,
|
||||
token: session.token,
|
||||
format,
|
||||
})
|
||||
if (note) params.set('note', note)
|
||||
return `/api/debug/report?${params}`
|
||||
}
|
||||
|
||||
export async function fetchDebugReportText(session: Session, note?: string): Promise<string> {
|
||||
const res = await fetch(debugReportURL(session, 'text', note))
|
||||
if (!res.ok) throw new Error('failed to fetch the debug report')
|
||||
return res.text()
|
||||
}
|
||||
|
||||
export function loadSession(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY)
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Card, ClientMessage } from '../types'
|
||||
import { fetchCatalog } from '../api'
|
||||
import type { Card, ClientMessage, Session } from '../types'
|
||||
import { debugReportURL, fetchCatalog, fetchDebugReportText } 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): 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) {
|
||||
// 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) {
|
||||
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(',')
|
||||
@@ -26,6 +34,17 @@ export function DebugPanel({ canGrant, packs, 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)}>
|
||||
@@ -33,6 +52,27 @@ export function DebugPanel({ canGrant, packs, 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>}
|
||||
|
||||
@@ -323,7 +323,12 @@ 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} send={send} />
|
||||
<DebugPanel
|
||||
canGrant={view.phase === 'shop'}
|
||||
packs={view.packs}
|
||||
session={session}
|
||||
send={send}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deckPeek &&
|
||||
|
||||
@@ -3536,6 +3536,49 @@ 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.debug-report-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.debug-report .muted {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.debug-note {
|
||||
flex-basis: 100%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: var(--cream);
|
||||
border: 1px solid var(--cocoa);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.debug-tier-label {
|
||||
color: var(--cream);
|
||||
font-size: 0.75rem;
|
||||
|
||||
Reference in New Issue
Block a user