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:
parent
225434d8fc
commit
95663924ea
5 changed files with 309 additions and 139 deletions
|
|
@ -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." };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue