1
0
Fork 0

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
This commit is contained in:
Jason Fraley 2026-09-08 11:46:53 -04:00
parent 225434d8fc
commit 95663924ea
5 changed files with 309 additions and 139 deletions

View file

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

View file

@ -13,9 +13,9 @@ import { OpordGame } from "./OpordGame";
import { OpordLeaderboard } from "./OpordLeaderboard";
const TIER_OPTIONS = [
{ value: "recruit", label: "Recruit", detail: "25s briefing, 4 questions" },
{ value: "veteran", label: "Veteran", detail: "18s briefing, 6 questions" },
{ value: "elite", label: "Elite", detail: "12s briefing, 8 questions" },
{ value: "recruit", label: "Recruit", detail: "4 questions · 45s recall" },
{ value: "veteran", label: "Veteran", detail: "6 questions · 60s recall" },
{ value: "elite", label: "Elite", detail: "8 questions · 75s recall" },
] as const satisfies ReadonlyArray<{
readonly value: OpordDifficulty;
readonly label: string;
@ -61,8 +61,8 @@ export function OpordArena({
<CardHeader>
<CardTitle>Choose a briefing tier</CardTitle>
<CardDescription>
The briefing stays visible for a limited window. After it is withdrawn, answer every
question from memory.
Study the order for as long as you like. Once the recall quiz starts, the clock is
unforgiving.
</CardDescription>
</CardHeader>
<CardContent>
@ -131,15 +131,19 @@ export function OpordArena({
<span className="font-medium text-foreground">Study</span>
<span>
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
early".
callsign, and radio frequency. It is rendered as an image with copy and context
menus blocked: there is no text to select, and "Begin recall" starts the quiz
whenever you are ready.
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium text-foreground">Recall</span>
<span>
Questions come one at a time with no going back. Answers score{" "}
{OPORD_DIFFICULTIES[difficulty].correctPoints} points each on this tier.
Questions come one at a time with no going back, hard-capped at{" "}
{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>
</div>
<div className="flex flex-col gap-1">
@ -147,7 +151,7 @@ export function OpordArena({
<span>
A perfect run inside {OPORD_DIFFICULTIES[difficulty].parTimeMs / 1000}s earns a{" "}
{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>
</div>
</CardContent>

View file

@ -27,6 +27,214 @@ 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,
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 }) {
return (
<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({
initialStats,
difficulty,
@ -133,7 +265,6 @@ export function OpordGame({
const scenarioSeedRef = useRef(0);
const quizStartedAtRef = useRef(0);
const answersRef = useRef<Array<{ question: OpordQuestion; value: string }>>([]);
const questionShownAtRef = useRef(0);
const lastAnswerAtRef = useRef(0);
const [phase, setPhase] = useState<Phase>("ready");
@ -141,7 +272,7 @@ export function OpordGame({
const [questions, setQuestions] = useState<readonly OpordQuestion[]>([]);
const [questionIndex, setQuestionIndex] = useState(0);
const [typedAnswer, setTypedAnswer] = useState("");
const [memoRemainingMs, setMemoRemainingMs] = useState(0);
const [quizRemainingMs, setQuizRemainingMs] = useState(tier.quizSeconds * 1000);
const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState<boolean | null>(null);
const [submitting, setSubmitting] = useState(false);
const [lastRun, setLastRun] = useState<{
@ -225,7 +356,11 @@ export function OpordGame({
const startBriefing = useCallback(() => {
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;
const nextScenario = generateOpordScenario(seed);
setScenario(nextScenario);
@ -236,14 +371,13 @@ export function OpordGame({
lastAnswerAtRef.current = 0;
setLastAnswerWasCorrect(null);
setLastRun(null);
setMemoRemainingMs(tier.memoSeconds * 1000);
setQuizRemainingMs(tier.quizSeconds * 1000);
setPhaseSafe("briefing");
}, [difficulty, expiredRef, setPhaseSafe, tier.memoSeconds]);
}, [difficulty, expiredRef, setPhaseSafe, tier.quizSeconds]);
const beginQuiz = useCallback(() => {
if (phaseRef.current !== "briefing") return;
quizStartedAtRef.current = Date.now();
questionShownAtRef.current = Date.now();
setPhaseSafe("quiz");
}, [setPhaseSafe]);
@ -257,6 +391,7 @@ export function OpordGame({
if (lastAnswerAtRef.current > 0 && now - lastAnswerAtRef.current < ANSWER_FEEDBACK_MS) {
return;
}
if (quizRemainingMs <= 0) return;
lastAnswerAtRef.current = now;
answersRef.current.push({ question, value });
setLastAnswerWasCorrect(
@ -268,35 +403,35 @@ export function OpordGame({
window.setTimeout(() => {
setLastAnswerWasCorrect(null);
setTypedAnswer("");
if (phaseRef.current !== "quiz") return;
if (questionIndex + 1 >= questions.length) {
void finishRun();
} else {
setQuestionIndex((index) => index + 1);
questionShownAtRef.current = Date.now();
}
}, ANSWER_FEEDBACK_MS);
},
[finishRun, questionIndex, questions],
[finishRun, questionIndex, questions, quizRemainingMs],
);
useEffect(() => {
if (phase !== "briefing") return;
const deadline = Date.now() + memoRemainingMs;
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);
setMemoRemainingMs(0);
beginQuiz();
setQuizRemainingMs(0);
void finishRun();
return;
}
setMemoRemainingMs(remaining);
setQuizRemainingMs(remaining);
}, 200);
return () => window.clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase]);
}, [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">
@ -306,12 +441,14 @@ export function OpordGame({
<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">
A live operations order will be on screen for {tier.memoSeconds} seconds. Memorize
the details, then answer {tier.questionCount} questions from memory.
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}
{statsRef.current[
`${difficulty}BestScore` as "recruitBestScore" | "veteranBestScore" | "eliteBestScore"
] ?? 0}
</p>
</div>
<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">
<span className="flex items-center gap-2 text-sm font-medium">
<Timer aria-hidden="true" className="size-4" />
Memorize the order
Study at your own pace
</span>
<div className="flex items-center gap-3">
<span className="font-mono text-lg font-bold tabular-nums">
{Math.ceil(memoRemainingMs / 1000)}s
<span className="text-xs text-muted-foreground">
Recall quiz: {tier.quizSeconds}s once started
</span>
<Button type="button" size="sm" onClick={beginQuiz}>
Ready early
Begin recall
</Button>
</div>
</div>
<BriefingDocument scenario={scenario} />
<OpordBriefingCanvas scenario={scenario} />
</div>
)}
@ -346,9 +483,14 @@ export function OpordGame({
<span>
Question {questionIndex + 1} of {questions.length}
</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" />
From memory
{Math.ceil(quizRemainingMs / 1000)}s
</span>
</div>
<div
@ -370,7 +512,7 @@ export function OpordGame({
type="button"
variant="outline"
className="h-auto min-h-11 justify-center font-mono"
disabled={lastAnswerWasCorrect !== null}
disabled={answersLocked}
onClick={() => answerCurrent(String(index))}
>
{option}
@ -382,7 +524,7 @@ export function OpordGame({
className="flex w-full max-w-xs flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
if (typedAnswer.trim().length === 0 || lastAnswerWasCorrect !== null) return;
if (typedAnswer.trim().length === 0 || answersLocked) return;
answerCurrent(typedAnswer);
}}
>
@ -395,8 +537,9 @@ export function OpordGame({
maxLength={12}
placeholder="Type your answer"
aria-label="Answer"
disabled={answersLocked}
/>
<Button type="submit" disabled={typedAnswer.trim().length === 0}>
<Button type="submit" disabled={answersLocked}>
Submit
</Button>
</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">
<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="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} />

View file

@ -12,12 +12,12 @@ export type OpordDifficulty = "recruit" | "veteran" | "elite";
const DIFFICULTY_VALUES: readonly OpordDifficulty[] = ["recruit", "veteran", "elite"];
export interface OpordTierConfig {
/** Seconds the briefing stays on screen. */
readonly memoSeconds: number;
/** Suggested study time shown to the player (briefing itself is self-paced). */
readonly studyHintSeconds: number;
/** Number of quiz questions asked after the briefing. */
readonly questionCount: number;
/** Options shown for multiple-choice questions. */
readonly choiceCount: number;
/** Hard recall time limit in seconds; server rejects runs slower than this. */
readonly quizSeconds: number;
/** Points awarded per correct answer. */
readonly correctPoints: number;
/** Total answer time under which a perfect run earns the speed bonus. */
@ -26,33 +26,45 @@ export interface OpordTierConfig {
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> = {
recruit: {
memoSeconds: 25,
studyHintSeconds: 25,
questionCount: 4,
choiceCount: 3,
quizSeconds: 45,
correctPoints: 100,
parTimeMs: 45_000,
parTimeMs: 32_000,
perfectBonus: 150,
},
veteran: {
memoSeconds: 18,
studyHintSeconds: 18,
questionCount: 6,
choiceCount: 4,
quizSeconds: 60,
correctPoints: 150,
parTimeMs: 70_000,
parTimeMs: 45_000,
perfectBonus: 300,
},
elite: {
memoSeconds: 12,
studyHintSeconds: 12,
questionCount: 8,
choiceCount: 4,
quizSeconds: 75,
correctPoints: 200,
parTimeMs: 100_000,
parTimeMs: 58_000,
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.
const COMPLETION_XP = 2;
const POINTS_PER_XP = 250;

View file

@ -1,5 +1,8 @@
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";
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> {
await page.goto("/opord");
await page.goto(`/opord?seed=${FIXED_SEED}`);
await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible();
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();
const gridMatch = briefingText.match(/grid (\d{4}) (\d{4})/);
if (!gridMatch) throw new Error("Objective grid not found in briefing text");
const grid = `${gridMatch[1]} ${gridMatch[2]}`;
// The briefing is drawn on canvas, so derive the expected grid from the lib.
const scenario = generateOpordScenario(FIXED_SEED);
const grid = formatOpordGrid(scenario.grid);
await page.getByRole("button", { name: "Ready early" }).click();
await page.getByRole("button", { name: "Begin recall" }).click();
const questionCounter = page.getByText(/Question \d+ of \d+/);
await expect(questionCounter).toBeVisible();
@ -50,6 +52,10 @@ test.describe("OPORD Recall", () => {
await expect(page.locator("body")).toContainText(/Recalled/);
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.
const leaderboard = page.getByRole("table");
await expect(leaderboard).toBeVisible({ timeout: 15_000 });