1
0
Fork 0

Compare commits

..

No commits in common. "b2bdbb9ea6f99b2ed75911e3eab03ebec602df97" and "225434d8fc66fd05ff3f810dbeb703e03fe3dd9d" have entirely different histories.

6 changed files with 140 additions and 310 deletions

View file

@ -1,6 +1,6 @@
{
"name": "polaris-task-force",
"version": "0.2.17",
"version": "0.2.16",
"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",
"type": "module",

View file

@ -5,12 +5,10 @@ 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";
@ -42,6 +40,8 @@ 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,16 +71,14 @@ 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 < minTimeMs ||
timeMs > maxTimeMs
timeMs < 0 ||
timeMs > MAX_TIME_MS
) {
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: "4 questions · 45s recall" },
{ value: "veteran", label: "Veteran", detail: "6 questions · 60s recall" },
{ value: "elite", label: "Elite", detail: "8 questions · 75s recall" },
{ 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" },
] 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>
Study the order for as long as you like. Once the recall quiz starts, the clock is
unforgiving.
The briefing stays visible for a limited window. After it is withdrawn, answer every
question from memory.
</CardDescription>
</CardHeader>
<CardContent>
@ -131,19 +131,15 @@ 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. 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.
callsign, and radio frequency. You can end the memorize window early with "Ready
early".
</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, 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.
Questions come one at a time with no going back. Answers score{" "}
{OPORD_DIFFICULTIES[difficulty].correctPoints} points each on this tier.
</span>
</div>
<div className="flex flex-col gap-1">
@ -151,7 +147,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. Screenshots exist, but consulting one burns quiz time you need.
best score.
</span>
</div>
</CardContent>

View file

@ -27,214 +27,6 @@ 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">
@ -244,6 +36,82 @@ 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,
@ -265,6 +133,7 @@ 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");
@ -272,7 +141,7 @@ export function OpordGame({
const [questions, setQuestions] = useState<readonly OpordQuestion[]>([]);
const [questionIndex, setQuestionIndex] = useState(0);
const [typedAnswer, setTypedAnswer] = useState("");
const [quizRemainingMs, setQuizRemainingMs] = useState(tier.quizSeconds * 1000);
const [memoRemainingMs, setMemoRemainingMs] = useState(0);
const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState<boolean | null>(null);
const [submitting, setSubmitting] = useState(false);
const [lastRun, setLastRun] = useState<{
@ -356,11 +225,7 @@ export function OpordGame({
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;
const seed = Date.now() % 2147483647;
scenarioSeedRef.current = seed;
const nextScenario = generateOpordScenario(seed);
setScenario(nextScenario);
@ -371,13 +236,14 @@ export function OpordGame({
lastAnswerAtRef.current = 0;
setLastAnswerWasCorrect(null);
setLastRun(null);
setQuizRemainingMs(tier.quizSeconds * 1000);
setMemoRemainingMs(tier.memoSeconds * 1000);
setPhaseSafe("briefing");
}, [difficulty, expiredRef, setPhaseSafe, tier.quizSeconds]);
}, [difficulty, expiredRef, setPhaseSafe, tier.memoSeconds]);
const beginQuiz = useCallback(() => {
if (phaseRef.current !== "briefing") return;
quizStartedAtRef.current = Date.now();
questionShownAtRef.current = Date.now();
setPhaseSafe("quiz");
}, [setPhaseSafe]);
@ -391,7 +257,6 @@ 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(
@ -403,35 +268,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, quizRemainingMs],
[finishRun, questionIndex, questions],
);
useEffect(() => {
if (phase !== "quiz") return;
const deadline = quizStartedAtRef.current + tier.quizSeconds * 1000;
if (phase !== "briefing") return;
const deadline = Date.now() + memoRemainingMs;
const interval = window.setInterval(() => {
const remaining = deadline - Date.now();
if (remaining <= 0) {
window.clearInterval(interval);
setQuizRemainingMs(0);
void finishRun();
setMemoRemainingMs(0);
beginQuiz();
return;
}
setQuizRemainingMs(remaining);
setMemoRemainingMs(remaining);
}, 200);
return () => window.clearInterval(interval);
}, [phase, tier.quizSeconds, finishRun]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase]);
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">
@ -441,14 +306,12 @@ 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">
Study the operations order at your own pace, then take a timed recall quiz:{" "}
{tier.quizSeconds} seconds for {tier.questionCount} questions.
A live operations order will be on screen for {tier.memoSeconds} seconds. Memorize
the details, then answer {tier.questionCount} questions from memory.
</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}>
@ -462,18 +325,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" />
Study at your own pace
Memorize the order
</span>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
Recall quiz: {tier.quizSeconds}s once started
<span className="font-mono text-lg font-bold tabular-nums">
{Math.ceil(memoRemainingMs / 1000)}s
</span>
<Button type="button" size="sm" onClick={beginQuiz}>
Begin recall
Ready early
</Button>
</div>
</div>
<OpordBriefingCanvas scenario={scenario} />
<BriefingDocument scenario={scenario} />
</div>
)}
@ -483,14 +346,9 @@ export function OpordGame({
<span>
Question {questionIndex + 1} of {questions.length}
</span>
<span
className={cn(
"flex items-center gap-1 font-mono text-sm font-bold tabular-nums",
quizRemainingMs <= 10_000 && "text-red-400",
)}
>
<span className="flex items-center gap-1">
<ClipboardCheck aria-hidden="true" className="size-3.5" />
{Math.ceil(quizRemainingMs / 1000)}s
From memory
</span>
</div>
<div
@ -512,7 +370,7 @@ export function OpordGame({
type="button"
variant="outline"
className="h-auto min-h-11 justify-center font-mono"
disabled={answersLocked}
disabled={lastAnswerWasCorrect !== null}
onClick={() => answerCurrent(String(index))}
>
{option}
@ -524,7 +382,7 @@ export function OpordGame({
className="flex w-full max-w-xs flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
if (typedAnswer.trim().length === 0 || answersLocked) return;
if (typedAnswer.trim().length === 0 || lastAnswerWasCorrect !== null) return;
answerCurrent(typedAnswer);
}}
>
@ -537,9 +395,8 @@ export function OpordGame({
maxLength={12}
placeholder="Type your answer"
aria-label="Answer"
disabled={answersLocked}
/>
<Button type="submit" disabled={answersLocked}>
<Button type="submit" disabled={typedAnswer.trim().length === 0}>
Submit
</Button>
</form>
@ -551,10 +408,7 @@ 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 {
/** Suggested study time shown to the player (briefing itself is self-paced). */
readonly studyHintSeconds: number;
/** Seconds the briefing stays on screen. */
readonly memoSeconds: number;
/** Number of quiz questions asked after the briefing. */
readonly questionCount: number;
/** Hard recall time limit in seconds; server rejects runs slower than this. */
readonly quizSeconds: number;
/** Options shown for multiple-choice questions. */
readonly choiceCount: number;
/** Points awarded per correct answer. */
readonly correctPoints: number;
/** Total answer time under which a perfect run earns the speed bonus. */
@ -26,45 +26,33 @@ 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: {
studyHintSeconds: 25,
memoSeconds: 25,
questionCount: 4,
quizSeconds: 45,
choiceCount: 3,
correctPoints: 100,
parTimeMs: 32_000,
parTimeMs: 45_000,
perfectBonus: 150,
},
veteran: {
studyHintSeconds: 18,
memoSeconds: 18,
questionCount: 6,
quizSeconds: 60,
choiceCount: 4,
correctPoints: 150,
parTimeMs: 45_000,
parTimeMs: 70_000,
perfectBonus: 300,
},
elite: {
studyHintSeconds: 12,
memoSeconds: 12,
questionCount: 8,
quizSeconds: 75,
choiceCount: 4,
correctPoints: 200,
parTimeMs: 58_000,
parTimeMs: 100_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,8 +1,5 @@
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> {
@ -14,17 +11,18 @@ async function login(page: Page): Promise<void> {
}
async function playOpordRound(page: Page): Promise<void> {
await page.goto(`/opord?seed=${FIXED_SEED}`);
await page.goto("/opord");
await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible();
await page.getByRole("button", { name: "Receive briefing" }).click();
await expect(page.getByText("Study at your own pace")).toBeVisible();
await expect(page.getByText("Memorize the order")).toBeVisible();
// The briefing is drawn on canvas, so derive the expected grid from the lib.
const scenario = generateOpordScenario(FIXED_SEED);
const grid = formatOpordGrid(scenario.grid);
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]}`;
await page.getByRole("button", { name: "Begin recall" }).click();
await page.getByRole("button", { name: "Ready early" }).click();
const questionCounter = page.getByText(/Question \d+ of \d+/);
await expect(questionCounter).toBeVisible();
@ -52,10 +50,6 @@ 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 });