1
0
Fork 0
polaris-task-force/src/components/frontend/opord/OpordGame.tsx

745 lines
26 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { BookOpen, ClipboardCheck, Timer } from "lucide-react";
import {
OPORD_DIFFICULTIES,
QUESTION_UNLOCK_DELAY_MS,
buildOpordQuestions,
formatOpordGrid,
generateOpordScenario,
gradeOpordRun,
normalizeOpordAnswer,
type OpordDifficulty,
type OpordQuestion,
type OpordScenario,
} from "@/lib/opord-recall";
import type { OpordStats } from "@/app/(frontend)/opord/actions";
import { submitOpordRecallResult } from "@/app/(frontend)/opord/actions";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type Phase = "ready" | "briefing" | "quiz" | "over";
const ANSWER_FEEDBACK_MS = 900;
const CANVAS_PAD_X = 18;
const CANVAS_MAX_W = 660;
type BriefingRow =
| { kind: "title" | "subtitle" | "header" | "text"; text: string }
| { kind: "kv"; label: string; value: string }
| { kind: "rule" }
| { kind: "gap" };
const ROW_FONT: Record<Exclude<BriefingRow["kind"], "rule" | "gap" | "kv">, string> = {
title: "bold 17px ui-monospace, Menlo, monospace",
subtitle: "10px ui-monospace, Menlo, monospace",
header: "bold 12px ui-monospace, Menlo, monospace",
text: "12px ui-monospace, Menlo, monospace",
};
const ROW_COLOR: Record<Exclude<BriefingRow["kind"], "rule" | "gap" | "kv">, string> = {
title: "#fafafa",
subtitle: "#71717a",
header: "#fbbf24",
text: "#a1a1aa",
};
const ROW_HEIGHT: Record<Exclude<BriefingRow["kind"], "rule" | "gap" | "kv">, number> = {
title: 26,
subtitle: 14,
header: 24,
text: 18,
};
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const words = text.split(" ");
const lines: string[] = [];
let current = "";
for (const word of words) {
const candidate = current.length === 0 ? word : `${current} ${word}`;
if (ctx.measureText(candidate).width > maxWidth && current.length > 0) {
lines.push(current);
current = word;
} else {
current = candidate;
}
}
if (current.length > 0) lines.push(current);
return lines;
}
function buildBriefingRows(
measureCtx: CanvasRenderingContext2D,
scenario: OpordScenario,
difficulty: OpordDifficulty,
maxWidth: number,
): BriefingRow[] {
const push = (rows: BriefingRow[], kind: "title" | "subtitle" | "header" | "text", text: string) => {
const font = ROW_FONT[kind];
measureCtx.font = font;
for (const line of wrapText(measureCtx, text, maxWidth)) {
rows.push({ kind, text: line });
}
};
const rows: BriefingRow[] = [];
let sectionNo = 0;
const header = (label: string) => {
sectionNo += 1;
push(rows, "header", `${sectionNo}. ${label}`);
};
push(rows, "subtitle", "COPY 1 OF 1 · POLARIS TASK FORCE");
push(rows, "title", `OPERATION ${scenario.operationName}`);
push(rows, "subtitle", `OPERATIONS ORDER NO. ${String(scenario.seed).slice(-4).padStart(4, "0")}`);
rows.push({ kind: "rule" });
header("SITUATION");
const enemyStrength =
difficulty === "recruit"
? `${scenario.enemyVehicles} vehicle(s) and ${scenario.enemySquads} infantry squad(s)`
: `${scenario.enemyVehicles} vehicle(s), ${scenario.enemySquads} infantry squad(s), and ${scenario.enemyMortars} mortar team(s)`;
push(
rows,
"text",
`Enemy: ${enemyStrength} dug in on objective ${scenario.objective} at grid ${formatOpordGrid(scenario.grid)}.`,
);
if (difficulty !== "recruit") {
push(
rows,
"text",
`Enemy garrison: ${scenario.garrisonCount} personnel reported across Buildings ${scenario.garrisonBuildings[0]} and ${scenario.garrisonBuildings[1]}.`,
);
}
push(rows, "text", `Friendly: ${scenario.friendlyUnit} in support aboard ${scenario.transport}.`);
header("MISSION");
push(
rows,
"text",
`At H-HOUR ${scenario.hHour}, seize objective ${scenario.objective} (grid ${formatOpordGrid(scenario.grid)}), ${scenario.distanceKm} km on azimuth ${String(scenario.azimuth).padStart(3, "0")}.`,
);
header("EXECUTION");
push(
rows,
"text",
`Line of departure is phase line ${scenario.ldPhaseLine}. Subsequent phase lines: ${scenario.otherPhaseLines.length > 0 ? scenario.otherPhaseLines.join(", ") : "none assigned"}.`,
);
if (difficulty !== "recruit") {
push(rows, "text", `${scenario.maneuverElement} moves in and secures Building ${scenario.securedBuilding}.`);
push(
rows,
"text",
`${scenario.overwatchCallsign} moves to ${scenario.overwatchPosition} and assumes overwatch of ${scenario.overwatchTarget}.`,
);
push(rows, "text", `Secondary objective: neutralize ${scenario.secondaryObjective}.`);
}
if (difficulty === "elite") {
push(rows, "text", `Tertiary objective: neutralize ${scenario.tertiaryObjective}.`);
}
if (difficulty !== "recruit") {
header("RULES OF ENGAGEMENT");
push(rows, "text", `Enemy uniforms mirror ours: positive ID requires ${scenario.enemyUniformMark}.`);
push(rows, "text", `Civilian presence ${scenario.civilianStatus}.`);
}
if (difficulty === "elite") {
header("INTELLIGENCE");
push(
rows,
"text",
`Priority collection target: ${scenario.intelItem}, held in Building ${scenario.intelBuilding}.`,
);
push(rows, "text", `Priority enemy asset identified: ${scenario.priorityAsset}.`);
}
if (difficulty === "recruit") {
header("SERVICE SUPPORT");
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
} else {
header("COORDINATION");
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
push(
rows,
"text",
`Close air support (${scenario.aircraftCallsign}) on station at H+${scenario.airSupportOffsetMin} minutes.`,
);
}
if (difficulty === "elite") {
header("TIMING & CONTINGENCY");
push(
rows,
"text",
`Movement from the line of departure to the objective takes ${scenario.enRouteMin} minutes.`,
);
push(rows, "text", `On abort, ${scenario.abortAction}.`);
push(rows, "text", `Resupply is scheduled at H+${scenario.resupplyOffsetMin} minutes.`);
push(rows, "text", `Exfiltration no later than H+${scenario.exfilOffsetMin} minutes.`);
push(
rows,
"text",
`Withdrawal route runs on azimuth ${String(scenario.withdrawalAzimuth).padStart(3, "0")}.`,
);
push(rows, "text", `Casualty evacuation lifts from ${scenario.casevacLz}.`);
}
header("COMMAND & SIGNAL");
rows.push({ kind: "kv", label: "AUTHENTICATION CODEWORD:", value: scenario.codeword });
rows.push({ kind: "kv", label: "ABORT CODEWORD:", value: scenario.abortCodeword });
if (difficulty === "elite") {
rows.push({
kind: "kv",
label: "STATUS CHECK-INS:",
value: `every ${scenario.checkInMinutes} minutes`,
});
}
rows.push({ kind: "gap" });
return rows;
}
function drawBriefing(
canvas: HTMLCanvasElement,
scenario: OpordScenario,
difficulty: OpordDifficulty,
): void {
const parent = canvas.parentElement;
const availableWidth = parent
? parent.clientWidth - CANVAS_PAD_X * 2
: CANVAS_MAX_W - CANVAS_PAD_X * 2;
const width = Math.min(CANVAS_MAX_W, Math.max(320, availableWidth));
const dpr = window.devicePixelRatio || 1;
const measure = canvas.getContext("2d");
if (!measure) return;
measure.setTransform(1, 0, 0, 1, 0, 0);
measure.scale(dpr, dpr);
const textWidth = width - CANVAS_PAD_X * 2;
const rows = buildBriefingRows(measure, scenario, difficulty, textWidth);
let height = 20;
for (const row of rows) {
if (row.kind === "rule") height += 14;
else if (row.kind === "gap") height += 8;
else if (row.kind === "kv") height += ROW_HEIGHT.text;
else height += ROW_HEIGHT[row.kind];
}
height += 20;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
ctx.fillStyle = "#0a0a0b";
ctx.fillRect(0, 0, width, height);
let y = 20;
for (const row of rows) {
if (row.kind === "rule") {
y += 7;
ctx.strokeStyle = "#27272a";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(CANVAS_PAD_X, y);
ctx.lineTo(width - CANVAS_PAD_X, y);
ctx.stroke();
y += 7;
continue;
}
if (row.kind === "gap") {
y += 8;
continue;
}
if (row.kind === "kv") {
ctx.font = ROW_FONT.text;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#a1a1aa";
ctx.fillText(row.label, CANVAS_PAD_X, y + ROW_HEIGHT.text - 5);
const labelWidth = ctx.measureText(`${row.label} `).width;
ctx.fillStyle = "#fafafa";
ctx.font = "bold 12px ui-monospace, Menlo, monospace";
ctx.fillText(row.value, CANVAS_PAD_X + labelWidth, y + ROW_HEIGHT.text - 5);
y += ROW_HEIGHT.text;
continue;
}
ctx.font = ROW_FONT[row.kind];
ctx.fillStyle = ROW_COLOR[row.kind];
ctx.textBaseline = "alphabetic";
if (row.kind === "title" || row.kind === "subtitle") {
ctx.textAlign = "center";
ctx.fillText(row.text, width / 2, y + ROW_HEIGHT[row.kind] - 8);
} else {
ctx.textAlign = "left";
ctx.fillText(row.text, CANVAS_PAD_X, y + ROW_HEIGHT[row.kind] - 6);
}
y += ROW_HEIGHT[row.kind];
}
}
function OpordBriefingCanvas({
scenario,
difficulty,
}: {
scenario: OpordScenario;
difficulty: OpordDifficulty;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
drawBriefing(canvas, scenario, difficulty);
}, [scenario, difficulty]);
return (
<div
className="w-full select-none"
onCopy={(event) => event.preventDefault()}
onCut={(event) => event.preventDefault()}
onContextMenu={(event) => event.preventDefault()}
onKeyDown={(event) => {
const key = event.key.toLowerCase();
if (
(event.ctrlKey || event.metaKey) &&
(key === "a" || key === "c" || key === "x" || key === "s" || key === "p")
) {
event.preventDefault();
}
}}
>
<canvas
ref={canvasRef}
className="block rounded-xl border border-border"
aria-label="Operations order briefing"
/>
</div>
);
}
function ResetHint({ className }: { className?: string }) {
const keyClass =
"rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px] leading-none";
return (
<p className={cn("text-xs text-muted-foreground", className)}>
Press{" "}
<kbd className={keyClass}>Esc</kbd> or <kbd className={keyClass}>R</kbd> at any point to
reset the exercise.
</p>
);
}
function Metre({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-border bg-background px-4 py-3 text-center">
<div className="text-xs uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-bold tabular-nums">{value}</div>
</div>
);
}
export function OpordGame({
initialStats,
difficulty,
onGameRecordedAction,
onPhaseChangeAction,
}: {
initialStats: OpordStats;
difficulty: OpordDifficulty;
onGameRecordedAction: (stats: OpordStats) => void;
onPhaseChangeAction: (phase: Phase) => void;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
const tier = OPORD_DIFFICULTIES[difficulty];
const phaseRef = useRef<Phase>("ready");
const statsRef = useRef<OpordStats>(initialStats);
const submittingRef = useRef(false);
const scenarioSeedRef = useRef(0);
const quizStartedAtRef = useRef(0);
const answersRef = useRef<Array<{ question: OpordQuestion; value: string }>>([]);
const lastAnswerAtRef = useRef(0);
const [phase, setPhase] = useState<Phase>("ready");
const [scenario, setScenario] = useState<OpordScenario | null>(null);
const [questions, setQuestions] = useState<readonly OpordQuestion[]>([]);
const [questionIndex, setQuestionIndex] = useState(0);
const [typedAnswer, setTypedAnswer] = useState("");
const [quizRemainingMs, setQuizRemainingMs] = useState(tier.quizSeconds * 1000);
const [unlockRemainingMs, setUnlockRemainingMs] = useState(0);
const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState<boolean | null>(null);
const [submitting, setSubmitting] = useState(false);
const [lastRun, setLastRun] = useState<{
correct: number;
total: number;
score: number;
xpEarned: number;
best: number;
error?: string;
} | null>(null);
const setPhaseSafe = useCallback(
(next: Phase) => {
phaseRef.current = next;
setPhase(next);
onPhaseChangeAction?.(next);
},
[onPhaseChangeAction],
);
const finishRun = useCallback(async () => {
if (submittingRef.current) return;
const timeMs = Math.max(1, Date.now() - quizStartedAtRef.current);
const correct = answersRef.current.filter(({ question, value }) => {
if (question.kind === "input") {
return normalizeOpordAnswer(value) === normalizeOpordAnswer(question.answer);
}
return Number(value) === question.correctIndex;
}).length;
const preview = gradeOpordRun(difficulty, correct, timeMs, true);
if (expiredRef.current) {
setLastRun({
correct,
total: preview.total,
score: preview.score,
xpEarned: 0,
best: statsRef.current.bestScore,
error: "Your session expired. Score not recorded.",
});
setPhaseSafe("over");
return;
}
submittingRef.current = true;
setSubmitting(true);
const res = await submitOpordRecallResult({ difficulty, correct, timeMs });
submittingRef.current = false;
setSubmitting(false);
// Esc/R can reset the run while the submission is in flight; global stats
// stay in sync, but the run-result UI is skipped for a cancelled run.
const cancelled = phaseRef.current !== "quiz";
if (res.success && res.data) {
statsRef.current = res.data;
onGameRecordedAction(res.data);
if (res.data.xpEarned > 0) {
window.dispatchEvent(
new CustomEvent(PROFILE_UPDATE_EVENT, {
detail: { reason: "xp", xpGained: res.data.xpEarned },
}),
);
window.setTimeout(() => router.refresh(), 1700);
}
if (!cancelled) {
setLastRun({
correct,
total: preview.total,
score: res.data.score,
xpEarned: res.data.xpEarned,
best: res.data.bestScore,
});
}
} else if (!cancelled) {
setLastRun({
correct,
total: preview.total,
score: preview.score,
xpEarned: 0,
best: statsRef.current.bestScore,
error: res.error ?? "Could not record score.",
});
}
if (!cancelled) setPhaseSafe("over");
}, [difficulty, expiredRef, onGameRecordedAction, router, setPhaseSafe]);
const startBriefing = useCallback(() => {
if (expiredRef.current) return;
const seedParam = Number(new URLSearchParams(window.location.search).get("seed"));
const seed =
Number.isInteger(seedParam) && seedParam > 0 && seedParam < 2147483647
? seedParam
: Date.now() % 2147483647;
scenarioSeedRef.current = seed;
const nextScenario = generateOpordScenario(seed);
setScenario(nextScenario);
setQuestions(buildOpordQuestions(nextScenario, difficulty, seed));
setQuestionIndex(0);
setTypedAnswer("");
answersRef.current = [];
lastAnswerAtRef.current = 0;
setLastAnswerWasCorrect(null);
setLastRun(null);
setQuizRemainingMs(tier.quizSeconds * 1000);
setPhaseSafe("briefing");
}, [difficulty, expiredRef, setPhaseSafe, tier.quizSeconds]);
const beginQuiz = useCallback(() => {
if (phaseRef.current !== "briefing") return;
quizStartedAtRef.current = Date.now();
setPhaseSafe("quiz");
}, [setPhaseSafe]);
const resetGame = useCallback(() => {
scenarioSeedRef.current = 0;
setScenario(null);
setQuestions([]);
setQuestionIndex(0);
setTypedAnswer("");
answersRef.current = [];
lastAnswerAtRef.current = 0;
setLastAnswerWasCorrect(null);
setLastRun(null);
setQuizRemainingMs(tier.quizSeconds * 1000);
setPhaseSafe("ready");
}, [setPhaseSafe, tier.quizSeconds]);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
const target = event.target;
const typing =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable);
const key = event.key.toLowerCase();
if (key === "escape" || (key === "r" && !typing)) resetGame();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [resetGame]);
const answerCurrent = useCallback(
(value: string) => {
if (phaseRef.current !== "quiz") return;
const question = questions[questionIndex];
if (!question) return;
const now = Date.now();
if (lastAnswerAtRef.current > 0 && now - lastAnswerAtRef.current < ANSWER_FEEDBACK_MS) {
return;
}
if (quizRemainingMs <= 0 || unlockRemainingMs > 0) return;
lastAnswerAtRef.current = now;
answersRef.current.push({ question, value });
setLastAnswerWasCorrect(
question.kind === "input"
? normalizeOpordAnswer(value) === normalizeOpordAnswer(question.answer)
: Number(value) === question.correctIndex,
);
window.setTimeout(() => {
setLastAnswerWasCorrect(null);
setTypedAnswer("");
if (phaseRef.current !== "quiz") return;
if (questionIndex + 1 >= questions.length) {
void finishRun();
} else {
setQuestionIndex((index) => index + 1);
}
}, ANSWER_FEEDBACK_MS);
},
[finishRun, questionIndex, questions, quizRemainingMs, unlockRemainingMs],
);
useEffect(() => {
if (phase !== "quiz") return;
const deadline = quizStartedAtRef.current + tier.quizSeconds * 1000;
const interval = window.setInterval(() => {
const remaining = deadline - Date.now();
if (remaining <= 0) {
window.clearInterval(interval);
setQuizRemainingMs(0);
void finishRun();
return;
}
setQuizRemainingMs(remaining);
}, 200);
return () => window.clearInterval(interval);
}, [phase, tier.quizSeconds, finishRun]);
useEffect(() => {
if (phase !== "quiz") return;
setUnlockRemainingMs(QUESTION_UNLOCK_DELAY_MS);
const interval = window.setInterval(() => {
setUnlockRemainingMs((remaining) => Math.max(0, remaining - 100));
}, 100);
return () => window.clearInterval(interval);
}, [phase, questionIndex]);
const question = questions[questionIndex];
const answersLocked =
lastAnswerWasCorrect !== null || quizRemainingMs <= 0 || unlockRemainingMs > 0;
return (
<div className="flex w-full max-w-3xl flex-col items-center gap-4">
{phase === "ready" && (
<div className="flex w-full flex-col items-center gap-4 rounded-xl border border-border bg-background p-8 text-center">
<BookOpen aria-hidden="true" className="size-10 text-muted-foreground" />
<div className="space-y-1">
<h2 className="text-xl font-bold uppercase tracking-widest">OPORD Recall</h2>
<p className="max-w-md text-sm text-muted-foreground">
Study the operations order at your own pace, then take a timed recall quiz:{" "}
{tier.quizSeconds} seconds for {tier.questionCount} questions.
</p>
<p className="text-xs text-muted-foreground">
Best score this tier:{" "}
{statsRef.current[
`${difficulty}BestScore` as "recruitBestScore" | "veteranBestScore" | "eliteBestScore"
] ?? 0}
</p>
</div>
<Button type="button" size="lg" onClick={startBriefing} disabled={expired}>
Receive briefing
</Button>
<ResetHint />
</div>
)}
{phase === "briefing" && scenario && (
<div className="flex w-full flex-col items-center gap-3">
<div className="flex w-full items-center justify-between gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-2">
<span className="flex items-center gap-2 text-sm font-medium">
<Timer aria-hidden="true" className="size-4" />
Study at your own pace
</span>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
Recall quiz: {tier.quizSeconds}s once started
</span>
<Button type="button" size="sm" onClick={beginQuiz}>
Begin recall
</Button>
</div>
</div>
<p className="max-w-2xl text-center text-xs text-muted-foreground">
For fairness, please don't take pictures of or reference the order while playing.
Thank you.
</p>
<OpordBriefingCanvas scenario={scenario} difficulty={difficulty} />
</div>
)}
{phase === "quiz" && question && (
<div className="flex w-full flex-col items-center gap-4 rounded-xl border border-border bg-background p-6">
<div className="flex w-full items-center justify-between text-xs uppercase tracking-wider text-muted-foreground">
<span>
Question {questionIndex + 1} of {questions.length}
</span>
<span
className={cn(
"flex items-center gap-1 font-mono text-sm font-bold tabular-nums",
quizRemainingMs <= 10_000 && "text-red-400",
)}
>
<ClipboardCheck aria-hidden="true" className="size-3.5" />
{Math.ceil(quizRemainingMs / 1000)}s
</span>
</div>
<div
className={cn(
"h-1 w-full overflow-hidden rounded-full bg-border transition-colors",
lastAnswerWasCorrect === true && "bg-emerald-500",
lastAnswerWasCorrect === false && "bg-red-500",
)}
aria-hidden="true"
/>
{unlockRemainingMs > 0 && lastAnswerWasCorrect === null && (
<div className="mx-auto w-full max-w-xs space-y-1">
<p className="text-center font-mono text-xs uppercase tracking-wider text-muted-foreground">
Answering opens in {Math.ceil(unlockRemainingMs / 1000)}s
</p>
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-amber-400 transition-[width] duration-100 ease-linear"
style={{ width: `${(unlockRemainingMs / QUESTION_UNLOCK_DELAY_MS) * 100}%` }}
/>
</div>
</div>
)}
<p className="text-center text-lg font-semibold">{question.prompt}</p>
{question.kind === "choice" ? (
<div className="grid w-full gap-2 sm:grid-cols-2">
{question.options.map((option, index) => (
<Button
key={`${question.id}-${index}`}
type="button"
variant="outline"
className="h-auto min-h-11 justify-center whitespace-normal break-words font-mono"
disabled={answersLocked}
onClick={() => answerCurrent(String(index))}
>
{option}
</Button>
))}
</div>
) : (
<form
className="flex w-full max-w-xs flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
if (typedAnswer.trim().length === 0 || answersLocked) return;
answerCurrent(typedAnswer);
}}
>
<Input
value={typedAnswer}
onChange={(event) => setTypedAnswer(event.target.value)}
className="text-center font-mono"
autoComplete="off"
autoFocus
maxLength={12}
placeholder="Type your answer"
aria-label="Answer"
disabled={answersLocked}
/>
<Button type="submit" disabled={answersLocked}>
Submit
</Button>
</form>
)}
</div>
)}
{phase === "over" && (
<div className="flex w-full flex-col items-center gap-4 rounded-xl border border-border bg-background p-8 text-center">
<h2 className="text-xl font-bold uppercase tracking-widest">Debrief</h2>
<div className="grid w-full max-w-md grid-cols-2 gap-3 sm:grid-cols-4">
<Metre
label="Recalled"
value={`${lastRun?.correct ?? 0}/${lastRun?.total ?? tier.questionCount}`}
/>
<Metre label="Score" value={lastRun?.score ?? 0} />
<Metre label="XP" value={`+${lastRun?.xpEarned ?? 0}`} />
<Metre label="Best" value={lastRun?.best ?? statsRef.current.bestScore} />
</div>
{lastRun?.error && <p className="text-sm text-red-400">{lastRun.error}</p>}
{submitting && <p className="text-xs text-muted-foreground">Recording score…</p>}
<Button type="button" size="lg" onClick={startBriefing} disabled={expired || submitting}>
New briefing
</Button>
</div>
)}
{expired && phase !== "ready" && (
<p className="text-xs text-amber-400">
Your session is about to end. The app will refresh when it expires.
</p>
)}
{phase !== "ready" && <ResetHint className="text-center" />}
</div>
);
}