"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, 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, string> = { title: "#fafafa", subtitle: "#71717a", header: "#fbbf24", text: "#a1a1aa", }; const ROW_HEIGHT: Record, 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(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; drawBriefing(canvas, scenario, difficulty); }, [scenario, difficulty]); return (
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(); } }} >
); } 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 (

Press{" "} Esc or R at any point to reset the exercise.

); } function Metre({ label, value }: { label: string; value: string | number }) { return (
{label}
{value}
); } 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("ready"); const statsRef = useRef(initialStats); const submittingRef = useRef(false); const scenarioSeedRef = useRef(0); const quizStartedAtRef = useRef(0); const answersRef = useRef>([]); const lastAnswerAtRef = useRef(0); const [phase, setPhase] = useState("ready"); const [scenario, setScenario] = useState(null); const [questions, setQuestions] = useState([]); const [questionIndex, setQuestionIndex] = useState(0); const [typedAnswer, setTypedAnswer] = useState(""); const [quizRemainingMs, setQuizRemainingMs] = useState(tier.quizSeconds * 1000); const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState(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 (
{phase === "ready" && (
)} {phase === "briefing" && scenario && (
Recall quiz: {tier.quizSeconds}s once started

For fairness, please don't take pictures of or reference the order while playing. Thank you.

)} {phase === "quiz" && question && (
Question {questionIndex + 1} of {questions.length}
)} {phase === "over" && (

Debrief

{lastRun?.error &&

{lastRun.error}

} {submitting &&

Recording score…

}
)} {expired && phase !== "ready" && (

Your session is about to end. The app will refresh when it expires.

)} {phase !== "ready" && }
); }