1
0
Fork 0

Compare commits

..

2 commits

Author SHA1 Message Date
b2bdbb9ea6 v0.2.17 - OPORD Recall anti-cheat 2026-09-08 11:46:58 -04:00
95663924ea feat(minigames): anti-cheat pass for OPORD Recall
- briefing is now self-paced (player clicks Begin recall; no memo countdown)
- recall quiz has a hard server-validated time band per tier (45/60/75s limits, minimum plausible human time, 2.5s grace); scripted instant or over-limit submissions are rejected
- briefing is rendered on canvas: no selectable text in the DOM, copy/cut/context-menu/ctrl-a-c-x-s-p blocked; screenshots remain possible but burn quiz time
- ?seed=<n> URL param pins a deterministic scenario (no server meaning; used by the e2e spec to know the grid)
- e2e spec updated for the new flow and asserts no grid text leaks into the DOM
2026-09-08 11:46:53 -04:00
6 changed files with 310 additions and 140 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "0.2.16", "version": "0.2.17",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.", "description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View file

@ -5,10 +5,12 @@ import { getPayload } from "payload";
import { import {
OPORD_DIFFICULTIES, OPORD_DIFFICULTIES,
QUIZ_TIME_GRACE_MS,
calculateOpordAccuracy, calculateOpordAccuracy,
calculateOpordScore, calculateOpordScore,
calculateOpordXp, calculateOpordXp,
isOpordDifficulty, isOpordDifficulty,
minOpordRunTimeMs,
type OpordDifficulty, type OpordDifficulty,
} from "@/lib/opord-recall"; } from "@/lib/opord-recall";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
@ -40,8 +42,6 @@ export type OpordRunInput = {
timeMs: number; timeMs: number;
}; };
const MAX_TIME_MS = 600_000;
async function authenticate() { async function authenticate() {
const payloadConfig = await config; const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig }); const payload = await getPayload({ config: payloadConfig });
@ -71,14 +71,16 @@ export async function submitOpordRecallResult(
const tier = OPORD_DIFFICULTIES[input.difficulty]; const tier = OPORD_DIFFICULTIES[input.difficulty];
const { correct, timeMs } = input; const { correct, timeMs } = input;
const minTimeMs = minOpordRunTimeMs(input.difficulty);
const maxTimeMs = tier.quizSeconds * 1000 + QUIZ_TIME_GRACE_MS;
if ( if (
!Number.isInteger(correct) || !Number.isInteger(correct) ||
correct < 0 || correct < 0 ||
correct > tier.questionCount || correct > tier.questionCount ||
typeof timeMs !== "number" || typeof timeMs !== "number" ||
!Number.isFinite(timeMs) || !Number.isFinite(timeMs) ||
timeMs < 0 || timeMs < minTimeMs ||
timeMs > MAX_TIME_MS timeMs > maxTimeMs
) { ) {
return { success: false, error: "Invalid result." }; return { success: false, error: "Invalid result." };
} }

View file

@ -13,9 +13,9 @@ import { OpordGame } from "./OpordGame";
import { OpordLeaderboard } from "./OpordLeaderboard"; import { OpordLeaderboard } from "./OpordLeaderboard";
const TIER_OPTIONS = [ const TIER_OPTIONS = [
{ value: "recruit", label: "Recruit", detail: "25s briefing, 4 questions" }, { value: "recruit", label: "Recruit", detail: "4 questions · 45s recall" },
{ value: "veteran", label: "Veteran", detail: "18s briefing, 6 questions" }, { value: "veteran", label: "Veteran", detail: "6 questions · 60s recall" },
{ value: "elite", label: "Elite", detail: "12s briefing, 8 questions" }, { value: "elite", label: "Elite", detail: "8 questions · 75s recall" },
] as const satisfies ReadonlyArray<{ ] as const satisfies ReadonlyArray<{
readonly value: OpordDifficulty; readonly value: OpordDifficulty;
readonly label: string; readonly label: string;
@ -61,8 +61,8 @@ export function OpordArena({
<CardHeader> <CardHeader>
<CardTitle>Choose a briefing tier</CardTitle> <CardTitle>Choose a briefing tier</CardTitle>
<CardDescription> <CardDescription>
The briefing stays visible for a limited window. After it is withdrawn, answer every Study the order for as long as you like. Once the recall quiz starts, the clock is
question from memory. unforgiving.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -131,15 +131,19 @@ export function OpordArena({
<span className="font-medium text-foreground">Study</span> <span className="font-medium text-foreground">Study</span>
<span> <span>
The order covers the objective grid, azimuth, H-HOUR, phase lines, codewords, The order covers the objective grid, azimuth, H-HOUR, phase lines, codewords,
callsign, and radio frequency. You can end the memorize window early with "Ready callsign, and radio frequency. It is rendered as an image with copy and context
early". menus blocked: there is no text to select, and "Begin recall" starts the quiz
whenever you are ready.
</span> </span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="font-medium text-foreground">Recall</span> <span className="font-medium text-foreground">Recall</span>
<span> <span>
Questions come one at a time with no going back. Answers score{" "} Questions come one at a time with no going back, hard-capped at{" "}
{OPORD_DIFFICULTIES[difficulty].correctPoints} points each on this tier. {OPORD_DIFFICULTIES[difficulty].quizSeconds} seconds on this tier. The server only
accepts runs inside a plausible time band, so answers scored{" "}
{OPORD_DIFFICULTIES[difficulty].correctPoints} points each have to be earned against
the clock.
</span> </span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -147,7 +151,7 @@ export function OpordArena({
<span> <span>
A perfect run inside {OPORD_DIFFICULTIES[difficulty].parTimeMs / 1000}s earns a{" "} A perfect run inside {OPORD_DIFFICULTIES[difficulty].parTimeMs / 1000}s earns a{" "}
{OPORD_DIFFICULTIES[difficulty].perfectBonus}-point bonus. Each tier tracks its own {OPORD_DIFFICULTIES[difficulty].perfectBonus}-point bonus. Each tier tracks its own
best score. best score. Screenshots exist, but consulting one burns quiz time you need.
</span> </span>
</div> </div>
</CardContent> </CardContent>

View file

@ -27,6 +27,214 @@ type Phase = "ready" | "briefing" | "quiz" | "over";
const ANSWER_FEEDBACK_MS = 900; 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,
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");
push(
rows,
"text",
`Enemy: ${scenario.enemyVehicles} vehicle(s) and ${scenario.enemySquads} infantry squad(s) 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"}.`,
);
push(rows, "header", "4. SERVICE SUPPORT");
push(rows, "text", `Element callsign ${scenario.callsign} monitors ${scenario.frequency} MHz.`);
push(rows, "header", "5. COMMAND & SIGNAL");
rows.push({ kind: "kv", label: "AUTHENTICATION CODEWORD:", value: scenario.codeword });
rows.push({ kind: "kv", label: "ABORT CODEWORD:", value: scenario.abortCodeword });
rows.push({ kind: "gap" });
return rows;
}
function drawBriefing(canvas: HTMLCanvasElement, scenario: OpordScenario): 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, 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 }: { scenario: OpordScenario }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
drawBriefing(canvas, scenario);
}, [scenario]);
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 Metre({ label, value }: { label: string; value: string | number }) { function Metre({ label, value }: { label: string; value: string | number }) {
return ( return (
<div className="rounded-lg border border-border bg-background px-4 py-3 text-center"> <div className="rounded-lg border border-border bg-background px-4 py-3 text-center">
@ -36,82 +244,6 @@ function Metre({ label, value }: { label: string; value: string | number }) {
); );
} }
function BriefingDocument({ scenario }: { scenario: OpordScenario }) {
return (
<div className="w-full rounded-xl border border-border bg-background p-5 font-mono text-sm leading-relaxed">
<div className="border-b border-border pb-3 text-center">
<div className="text-xs uppercase tracking-widest text-muted-foreground">
Copy 1 of 1 · Polaris Task Force
</div>
<h3 className="mt-1 text-lg font-bold uppercase tracking-widest">
Operation {scenario.operationName}
</h3>
<div className="text-xs uppercase tracking-widest text-muted-foreground">
Operations Order No. {String(scenario.seed).slice(-4).padStart(4, "0")}
</div>
</div>
<div className="grid gap-4 pt-3 sm:grid-cols-2">
<section>
<h4 className="text-xs font-bold uppercase tracking-widest text-amber-400">
1. Situation
</h4>
<p className="mt-1 text-muted-foreground">
Enemy force: {scenario.enemyVehicles} vehicle(s) and {scenario.enemySquads} infantry
squad(s) dug in on objective {scenario.objective} at grid {formatOpordGrid(scenario.grid)}.
</p>
<p className="mt-1 text-muted-foreground">
Friendly: {scenario.friendlyUnit} in support aboard {scenario.transport}.
</p>
</section>
<section>
<h4 className="text-xs font-bold uppercase tracking-widest text-amber-400">
2. Mission
</h4>
<p className="mt-1 text-muted-foreground">
At H-HOUR {scenario.hHour}, seize objective {scenario.objective} (grid{" "}
{formatOpordGrid(scenario.grid)}), {scenario.distanceKm} km on azimuth{" "}
{String(scenario.azimuth).padStart(3, "0")}.
</p>
</section>
<section>
<h4 className="text-xs font-bold uppercase tracking-widest text-amber-400">
3. Execution
</h4>
<p className="mt-1 text-muted-foreground">
Line of departure is phase line {scenario.ldPhaseLine}. Subsequent phase lines:{" "}
{scenario.otherPhaseLines.length > 0
? scenario.otherPhaseLines.join(", ")
: "none assigned"}
.
</p>
</section>
<section>
<h4 className="text-xs font-bold uppercase tracking-widest text-amber-400">
4. Service Support
</h4>
<p className="mt-1 text-muted-foreground">
Element callsign {scenario.callsign} monitors {scenario.frequency} MHz.
</p>
</section>
<section className="sm:col-span-2">
<h4 className="text-xs font-bold uppercase tracking-widest text-amber-400">
5. Command & Signal
</h4>
<p className="mt-1 text-muted-foreground">
Authentication codeword: <span className="font-bold text-foreground">{scenario.codeword}</span>.
Abort codeword: <span className="font-bold text-foreground">{scenario.abortCodeword}</span>.
</p>
</section>
</div>
</div>
);
}
export function OpordGame({ export function OpordGame({
initialStats, initialStats,
difficulty, difficulty,
@ -133,7 +265,6 @@ export function OpordGame({
const scenarioSeedRef = useRef(0); const scenarioSeedRef = useRef(0);
const quizStartedAtRef = useRef(0); const quizStartedAtRef = useRef(0);
const answersRef = useRef<Array<{ question: OpordQuestion; value: string }>>([]); const answersRef = useRef<Array<{ question: OpordQuestion; value: string }>>([]);
const questionShownAtRef = useRef(0);
const lastAnswerAtRef = useRef(0); const lastAnswerAtRef = useRef(0);
const [phase, setPhase] = useState<Phase>("ready"); const [phase, setPhase] = useState<Phase>("ready");
@ -141,7 +272,7 @@ export function OpordGame({
const [questions, setQuestions] = useState<readonly OpordQuestion[]>([]); const [questions, setQuestions] = useState<readonly OpordQuestion[]>([]);
const [questionIndex, setQuestionIndex] = useState(0); const [questionIndex, setQuestionIndex] = useState(0);
const [typedAnswer, setTypedAnswer] = useState(""); const [typedAnswer, setTypedAnswer] = useState("");
const [memoRemainingMs, setMemoRemainingMs] = useState(0); const [quizRemainingMs, setQuizRemainingMs] = useState(tier.quizSeconds * 1000);
const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState<boolean | null>(null); const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState<boolean | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [lastRun, setLastRun] = useState<{ const [lastRun, setLastRun] = useState<{
@ -225,7 +356,11 @@ export function OpordGame({
const startBriefing = useCallback(() => { const startBriefing = useCallback(() => {
if (expiredRef.current) return; if (expiredRef.current) return;
const seed = Date.now() % 2147483647; 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; scenarioSeedRef.current = seed;
const nextScenario = generateOpordScenario(seed); const nextScenario = generateOpordScenario(seed);
setScenario(nextScenario); setScenario(nextScenario);
@ -236,14 +371,13 @@ export function OpordGame({
lastAnswerAtRef.current = 0; lastAnswerAtRef.current = 0;
setLastAnswerWasCorrect(null); setLastAnswerWasCorrect(null);
setLastRun(null); setLastRun(null);
setMemoRemainingMs(tier.memoSeconds * 1000); setQuizRemainingMs(tier.quizSeconds * 1000);
setPhaseSafe("briefing"); setPhaseSafe("briefing");
}, [difficulty, expiredRef, setPhaseSafe, tier.memoSeconds]); }, [difficulty, expiredRef, setPhaseSafe, tier.quizSeconds]);
const beginQuiz = useCallback(() => { const beginQuiz = useCallback(() => {
if (phaseRef.current !== "briefing") return; if (phaseRef.current !== "briefing") return;
quizStartedAtRef.current = Date.now(); quizStartedAtRef.current = Date.now();
questionShownAtRef.current = Date.now();
setPhaseSafe("quiz"); setPhaseSafe("quiz");
}, [setPhaseSafe]); }, [setPhaseSafe]);
@ -257,6 +391,7 @@ export function OpordGame({
if (lastAnswerAtRef.current > 0 && now - lastAnswerAtRef.current < ANSWER_FEEDBACK_MS) { if (lastAnswerAtRef.current > 0 && now - lastAnswerAtRef.current < ANSWER_FEEDBACK_MS) {
return; return;
} }
if (quizRemainingMs <= 0) return;
lastAnswerAtRef.current = now; lastAnswerAtRef.current = now;
answersRef.current.push({ question, value }); answersRef.current.push({ question, value });
setLastAnswerWasCorrect( setLastAnswerWasCorrect(
@ -268,35 +403,35 @@ export function OpordGame({
window.setTimeout(() => { window.setTimeout(() => {
setLastAnswerWasCorrect(null); setLastAnswerWasCorrect(null);
setTypedAnswer(""); setTypedAnswer("");
if (phaseRef.current !== "quiz") return;
if (questionIndex + 1 >= questions.length) { if (questionIndex + 1 >= questions.length) {
void finishRun(); void finishRun();
} else { } else {
setQuestionIndex((index) => index + 1); setQuestionIndex((index) => index + 1);
questionShownAtRef.current = Date.now();
} }
}, ANSWER_FEEDBACK_MS); }, ANSWER_FEEDBACK_MS);
}, },
[finishRun, questionIndex, questions], [finishRun, questionIndex, questions, quizRemainingMs],
); );
useEffect(() => { useEffect(() => {
if (phase !== "briefing") return; if (phase !== "quiz") return;
const deadline = Date.now() + memoRemainingMs; const deadline = quizStartedAtRef.current + tier.quizSeconds * 1000;
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
const remaining = deadline - Date.now(); const remaining = deadline - Date.now();
if (remaining <= 0) { if (remaining <= 0) {
window.clearInterval(interval); window.clearInterval(interval);
setMemoRemainingMs(0); setQuizRemainingMs(0);
beginQuiz(); void finishRun();
return; return;
} }
setMemoRemainingMs(remaining); setQuizRemainingMs(remaining);
}, 200); }, 200);
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps }, [phase, tier.quizSeconds, finishRun]);
}, [phase]);
const question = questions[questionIndex]; const question = questions[questionIndex];
const answersLocked = lastAnswerWasCorrect !== null || quizRemainingMs <= 0;
return ( return (
<div className="flex w-full max-w-3xl flex-col items-center gap-4"> <div className="flex w-full max-w-3xl flex-col items-center gap-4">
@ -306,12 +441,14 @@ export function OpordGame({
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-bold uppercase tracking-widest">OPORD Recall</h2> <h2 className="text-xl font-bold uppercase tracking-widest">OPORD Recall</h2>
<p className="max-w-md text-sm text-muted-foreground"> <p className="max-w-md text-sm text-muted-foreground">
A live operations order will be on screen for {tier.memoSeconds} seconds. Memorize Study the operations order at your own pace, then take a timed recall quiz:{" "}
the details, then answer {tier.questionCount} questions from memory. {tier.quizSeconds} seconds for {tier.questionCount} questions.
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Best score this tier:{" "} Best score this tier:{" "}
{statsRef.current[`${difficulty}BestScore` as "recruitBestScore" | "veteranBestScore" | "eliteBestScore"] ?? 0} {statsRef.current[
`${difficulty}BestScore` as "recruitBestScore" | "veteranBestScore" | "eliteBestScore"
] ?? 0}
</p> </p>
</div> </div>
<Button type="button" size="lg" onClick={startBriefing} disabled={expired}> <Button type="button" size="lg" onClick={startBriefing} disabled={expired}>
@ -325,18 +462,18 @@ export function OpordGame({
<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"> <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"> <span className="flex items-center gap-2 text-sm font-medium">
<Timer aria-hidden="true" className="size-4" /> <Timer aria-hidden="true" className="size-4" />
Memorize the order Study at your own pace
</span> </span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="font-mono text-lg font-bold tabular-nums"> <span className="text-xs text-muted-foreground">
{Math.ceil(memoRemainingMs / 1000)}s Recall quiz: {tier.quizSeconds}s once started
</span> </span>
<Button type="button" size="sm" onClick={beginQuiz}> <Button type="button" size="sm" onClick={beginQuiz}>
Ready early Begin recall
</Button> </Button>
</div> </div>
</div> </div>
<BriefingDocument scenario={scenario} /> <OpordBriefingCanvas scenario={scenario} />
</div> </div>
)} )}
@ -346,9 +483,14 @@ export function OpordGame({
<span> <span>
Question {questionIndex + 1} of {questions.length} Question {questionIndex + 1} of {questions.length}
</span> </span>
<span className="flex items-center gap-1"> <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" /> <ClipboardCheck aria-hidden="true" className="size-3.5" />
From memory {Math.ceil(quizRemainingMs / 1000)}s
</span> </span>
</div> </div>
<div <div
@ -370,7 +512,7 @@ export function OpordGame({
type="button" type="button"
variant="outline" variant="outline"
className="h-auto min-h-11 justify-center font-mono" className="h-auto min-h-11 justify-center font-mono"
disabled={lastAnswerWasCorrect !== null} disabled={answersLocked}
onClick={() => answerCurrent(String(index))} onClick={() => answerCurrent(String(index))}
> >
{option} {option}
@ -382,7 +524,7 @@ export function OpordGame({
className="flex w-full max-w-xs flex-col gap-2" className="flex w-full max-w-xs flex-col gap-2"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
if (typedAnswer.trim().length === 0 || lastAnswerWasCorrect !== null) return; if (typedAnswer.trim().length === 0 || answersLocked) return;
answerCurrent(typedAnswer); answerCurrent(typedAnswer);
}} }}
> >
@ -395,8 +537,9 @@ export function OpordGame({
maxLength={12} maxLength={12}
placeholder="Type your answer" placeholder="Type your answer"
aria-label="Answer" aria-label="Answer"
disabled={answersLocked}
/> />
<Button type="submit" disabled={typedAnswer.trim().length === 0}> <Button type="submit" disabled={answersLocked}>
Submit Submit
</Button> </Button>
</form> </form>
@ -408,7 +551,10 @@ export function OpordGame({
<div className="flex w-full flex-col items-center gap-4 rounded-xl border border-border bg-background p-8 text-center"> <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> <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"> <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="Recalled"
value={`${lastRun?.correct ?? 0}/${lastRun?.total ?? tier.questionCount}`}
/>
<Metre label="Score" value={lastRun?.score ?? 0} /> <Metre label="Score" value={lastRun?.score ?? 0} />
<Metre label="XP" value={`+${lastRun?.xpEarned ?? 0}`} /> <Metre label="XP" value={`+${lastRun?.xpEarned ?? 0}`} />
<Metre label="Best" value={lastRun?.best ?? statsRef.current.bestScore} /> <Metre label="Best" value={lastRun?.best ?? statsRef.current.bestScore} />

View file

@ -12,12 +12,12 @@ export type OpordDifficulty = "recruit" | "veteran" | "elite";
const DIFFICULTY_VALUES: readonly OpordDifficulty[] = ["recruit", "veteran", "elite"]; const DIFFICULTY_VALUES: readonly OpordDifficulty[] = ["recruit", "veteran", "elite"];
export interface OpordTierConfig { export interface OpordTierConfig {
/** Seconds the briefing stays on screen. */ /** Suggested study time shown to the player (briefing itself is self-paced). */
readonly memoSeconds: number; readonly studyHintSeconds: number;
/** Number of quiz questions asked after the briefing. */ /** Number of quiz questions asked after the briefing. */
readonly questionCount: number; readonly questionCount: number;
/** Options shown for multiple-choice questions. */ /** Hard recall time limit in seconds; server rejects runs slower than this. */
readonly choiceCount: number; readonly quizSeconds: number;
/** Points awarded per correct answer. */ /** Points awarded per correct answer. */
readonly correctPoints: number; readonly correctPoints: number;
/** Total answer time under which a perfect run earns the speed bonus. */ /** Total answer time under which a perfect run earns the speed bonus. */
@ -26,33 +26,45 @@ export interface OpordTierConfig {
readonly perfectBonus: number; readonly perfectBonus: number;
} }
/** Grace added to the quiz limit for network/rounding before the server rejects. */
export const QUIZ_TIME_GRACE_MS = 2_500;
export const OPORD_DIFFICULTIES: Record<OpordDifficulty, OpordTierConfig> = { export const OPORD_DIFFICULTIES: Record<OpordDifficulty, OpordTierConfig> = {
recruit: { recruit: {
memoSeconds: 25, studyHintSeconds: 25,
questionCount: 4, questionCount: 4,
choiceCount: 3, quizSeconds: 45,
correctPoints: 100, correctPoints: 100,
parTimeMs: 45_000, parTimeMs: 32_000,
perfectBonus: 150, perfectBonus: 150,
}, },
veteran: { veteran: {
memoSeconds: 18, studyHintSeconds: 18,
questionCount: 6, questionCount: 6,
choiceCount: 4, quizSeconds: 60,
correctPoints: 150, correctPoints: 150,
parTimeMs: 70_000, parTimeMs: 45_000,
perfectBonus: 300, perfectBonus: 300,
}, },
elite: { elite: {
memoSeconds: 12, studyHintSeconds: 12,
questionCount: 8, questionCount: 8,
choiceCount: 4, quizSeconds: 75,
correctPoints: 200, correctPoints: 200,
parTimeMs: 100_000, parTimeMs: 58_000,
perfectBonus: 500, perfectBonus: 500,
}, },
}; };
/**
* Fastest total quiz time a human could plausibly post: every question except
* the last needs reading + click, plus the fixed answer-feedback delay. The
* server rejects submissions below this as scripted.
*/
export function minOpordRunTimeMs(difficulty: OpordDifficulty): number {
return (OPORD_DIFFICULTIES[difficulty].questionCount - 1) * 1_500 + 800;
}
// XP tuning: modest flat rewards, replays motivated by score. // XP tuning: modest flat rewards, replays motivated by score.
const COMPLETION_XP = 2; const COMPLETION_XP = 2;
const POINTS_PER_XP = 250; const POINTS_PER_XP = 250;

View file

@ -1,5 +1,8 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { formatOpordGrid, generateOpordScenario } from "../../src/lib/opord-recall";
const FIXED_SEED = 424242;
const QUIZ_OPTION_BUTTON = "button.font-mono"; const QUIZ_OPTION_BUTTON = "button.font-mono";
async function login(page: Page): Promise<void> { async function login(page: Page): Promise<void> {
@ -11,18 +14,17 @@ async function login(page: Page): Promise<void> {
} }
async function playOpordRound(page: Page): Promise<void> { async function playOpordRound(page: Page): Promise<void> {
await page.goto("/opord"); await page.goto(`/opord?seed=${FIXED_SEED}`);
await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible(); await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible();
await page.getByRole("button", { name: "Receive briefing" }).click(); await page.getByRole("button", { name: "Receive briefing" }).click();
await expect(page.getByText("Memorize the order")).toBeVisible(); await expect(page.getByText("Study at your own pace")).toBeVisible();
const briefingText = await page.locator("body").innerText(); // The briefing is drawn on canvas, so derive the expected grid from the lib.
const gridMatch = briefingText.match(/grid (\d{4}) (\d{4})/); const scenario = generateOpordScenario(FIXED_SEED);
if (!gridMatch) throw new Error("Objective grid not found in briefing text"); const grid = formatOpordGrid(scenario.grid);
const grid = `${gridMatch[1]} ${gridMatch[2]}`;
await page.getByRole("button", { name: "Ready early" }).click(); await page.getByRole("button", { name: "Begin recall" }).click();
const questionCounter = page.getByText(/Question \d+ of \d+/); const questionCounter = page.getByText(/Question \d+ of \d+/);
await expect(questionCounter).toBeVisible(); await expect(questionCounter).toBeVisible();
@ -50,6 +52,10 @@ test.describe("OPORD Recall", () => {
await expect(page.locator("body")).toContainText(/Recalled/); await expect(page.locator("body")).toContainText(/Recalled/);
await expect(page.getByText(/^\+\d+$/).first()).toBeVisible(); await expect(page.getByText(/^\+\d+$/).first()).toBeVisible();
// The briefing must not expose selectable text to copy.
const bodyText = await page.locator("body").innerText();
expect(bodyText).not.toContain(formatOpordGrid(generateOpordScenario(FIXED_SEED).grid));
// The leaderboard refetches after the run and shows the recorded run for this user. // The leaderboard refetches after the run and shows the recorded run for this user.
const leaderboard = page.getByRole("table"); const leaderboard = page.getByRole("table");
await expect(leaderboard).toBeVisible({ timeout: 15_000 }); await expect(leaderboard).toBeVisible({ timeout: 15_000 });