680 lines
24 KiB
TypeScript
680 lines
24 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,
|
|
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[] = [];
|
|
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" });
|
|
push(rows, "header", "1. 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)}.`,
|
|
);
|
|
push(rows, "text", `Friendly: ${scenario.friendlyUnit} in support aboard ${scenario.transport}.`);
|
|
push(rows, "header", "2. 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")}.`,
|
|
);
|
|
push(rows, "header", "3. 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, "header", "4. SERVICE SUPPORT");
|
|
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
|
|
} else {
|
|
push(rows, "header", "4. 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") {
|
|
push(rows, "header", "5. TIMING & CONTINGENCY");
|
|
push(
|
|
rows,
|
|
"text",
|
|
`Movement from the line of departure to the objective takes ${scenario.enRouteMin} minutes.`,
|
|
);
|
|
push(rows, "text", `On abort, return to ${scenario.rallyPoint} and await further orders.`);
|
|
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}.`);
|
|
}
|
|
push(rows, "header", difficulty === "elite" ? "6. COMMAND & SIGNAL" : "5. 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 [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) 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],
|
|
);
|
|
|
|
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]);
|
|
|
|
const question = questions[questionIndex];
|
|
const answersLocked = lastAnswerWasCorrect !== null || quizRemainingMs <= 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"
|
|
/>
|
|
|
|
<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 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>
|
|
);
|
|
}
|