1
0
Fork 0

Compare commits

...

2 commits

Author SHA1 Message Date
225434d8fc v0.2.16 - add OPORD Recall minigame 2026-09-08 11:26:44 -04:00
eaa0325b6d feat(minigames): add OPORD Recall briefing-recall minigame
- seeded OPORD generator (grid, azimuth, H-HOUR, phase lines, codewords, callsign, frequency) in pure lib src/lib/opord-recall.ts with per-tier memo windows (25/18/12s) and quiz sizes (4/6/8)
- server action submitOpordRecallResult validates input, recomputes score server-side, persists to Profiles progression.minigames.opordRecall, broadcasts XP, emits minigame:opord-recall event
- UI at /opord: briefing countdown, one-at-a-time quiz, debrief metres, tier radio group, SSE presence, REST leaderboard per tier
- profiles migration 20260908_152021 (7 additive columns) + XP hook wiring
- nav entries in command palette and NavUser Exercises submenu
- e2e spec plays a full round and asserts debrief + leaderboard row
2026-09-08 11:26:18 -04:00
17 changed files with 25486 additions and 2 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "0.2.9", "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.", "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

@ -0,0 +1,213 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import {
OPORD_DIFFICULTIES,
calculateOpordAccuracy,
calculateOpordScore,
calculateOpordXp,
isOpordDifficulty,
type OpordDifficulty,
} from "@/lib/opord-recall";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { broadcastToUser } from "@/lib/realtime/bus";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
export interface OpordStats {
gamesPlayed: number;
bestScore: number;
bestAccuracy: number;
recruitBestScore: number;
veteranBestScore: number;
eliteBestScore: number;
opordRecallXp: number;
xpEarned: number;
score: number;
accuracy: number;
}
export type OpordRunInput = {
difficulty: OpordDifficulty;
correct: number;
timeMs: number;
};
const MAX_TIME_MS = 600_000;
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!user) {
throw new Error("Unauthorized");
}
return { payload, user };
}
export async function submitOpordRecallResult(
input: OpordRunInput,
): Promise<ActionResult<OpordStats>> {
try {
const { payload, user } = await authenticate();
if (!isOpordDifficulty(input.difficulty)) {
return { success: false, error: "Invalid difficulty." };
}
const tier = OPORD_DIFFICULTIES[input.difficulty];
const { correct, timeMs } = input;
if (
!Number.isInteger(correct) ||
correct < 0 ||
correct > tier.questionCount ||
typeof timeMs !== "number" ||
!Number.isFinite(timeMs) ||
timeMs < 0 ||
timeMs > MAX_TIME_MS
) {
return { success: false, error: "Invalid result." };
}
const profileRes = await payload.find({
collection: "profiles",
where: { user: { equals: user.id as number } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const profile = profileRes.docs[0];
if (!profile) {
return { success: false, error: "No profile found for this user." };
}
const progression = (profile.progression ?? {}) as {
xp?: unknown;
minigames?: {
opordRecall?: {
gamesPlayed?: number;
bestScore?: number;
bestAccuracy?: number;
recruitBestScore?: number;
veteranBestScore?: number;
eliteBestScore?: number;
xpEarned?: number;
};
};
};
const current = progression.minigames?.opordRecall ?? {};
const score = calculateOpordScore(input.difficulty, correct, timeMs);
const accuracy = calculateOpordAccuracy(correct, tier.questionCount);
const tierBestScoreKey = `${input.difficulty}BestScore` as
| "recruitBestScore"
| "veteranBestScore"
| "eliteBestScore";
const isNewTierBest = score > (current[tierBestScoreKey] ?? 0);
const xp = calculateOpordXp(score, isNewTierBest);
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
const bestScore = Math.max(current.bestScore ?? 0, score);
const bestAccuracy =
Math.round(Math.max(current.bestAccuracy ?? 0, accuracy) * 10000) / 10000;
const tierBestScore = Math.max(current[tierBestScoreKey] ?? 0, score);
const opordRecallXp = (current.xpEarned ?? 0) + xp;
const recruitBestScore =
input.difficulty === "recruit" ? tierBestScore : (current.recruitBestScore ?? 0);
const veteranBestScore =
input.difficulty === "veteran" ? tierBestScore : (current.veteranBestScore ?? 0);
const eliteBestScore =
input.difficulty === "elite" ? tierBestScore : (current.eliteBestScore ?? 0);
const { xp: _xp, ...progressionRest } = progression;
await payload.update({
collection: "profiles",
id: profile.id,
data: {
progression: {
...progressionRest,
minigames: {
...(progressionRest.minigames ?? {}),
opordRecall: {
gamesPlayed,
bestScore,
bestAccuracy,
recruitBestScore,
veteranBestScore,
eliteBestScore,
xpEarned: opordRecallXp,
},
},
},
},
overrideAccess: true,
depth: 0,
});
broadcastToUser("profile-update", Number(user.id), {
reason: "xp",
xpGained: xp,
profileId: profile.id,
});
if (score > 0) {
const pct = Math.round(accuracy * 100);
await emitGameEvent(payload, {
type: EventTypes.MinigameOpordRecall,
message: `recalled an ${input.difficulty} briefing with ${correct}/${tier.questionCount} details correct (${pct}% accuracy, +${xp} XP)`,
actor: user.id as number,
targetCollection: "users",
targetId: user.id as number,
data: {
difficulty: input.difficulty,
correct,
total: tier.questionCount,
score,
accuracy,
timeMs: Math.round(timeMs),
xpEarned: xp,
bestScore,
tierBestScore,
opordRecallXp,
},
});
}
return {
success: true,
data: {
gamesPlayed,
bestScore,
bestAccuracy,
recruitBestScore,
veteranBestScore,
eliteBestScore,
opordRecallXp,
xpEarned: xp,
score,
accuracy,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}

View file

@ -0,0 +1,66 @@
import type { Metadata } from "next";
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { ScrollText } from "lucide-react";
import { OpordArena } from "@/components/frontend/opord/OpordArena";
export const metadata: Metadata = {
title: "OPORD Recall (Polaris Task Force)",
description: "Study a live operations order, then recall the details under pressure.",
};
export default async function OpordPage() {
const headers = await nextHeaders();
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect(`/login?returnTo=${encodeURIComponent("/opord")}`);
}
const profileRes = await payload.find({
collection: "profiles",
where: { user: { equals: user.id as number } },
limit: 1,
depth: 0,
overrideAccess: true,
select: {
progression: {
minigames: true,
},
},
});
const opordRecall = profileRes.docs[0]?.progression?.minigames?.opordRecall ?? {};
const stats = {
gamesPlayed: opordRecall.gamesPlayed ?? 0,
bestScore: opordRecall.bestScore ?? 0,
bestAccuracy: opordRecall.bestAccuracy ?? 0,
recruitBestScore: opordRecall.recruitBestScore ?? 0,
veteranBestScore: opordRecall.veteranBestScore ?? 0,
eliteBestScore: opordRecall.eliteBestScore ?? 0,
opordRecallXp: opordRecall.xpEarned ?? 0,
xpEarned: 0,
score: 0,
accuracy: 0,
};
return (
<div className="flex min-w-0 flex-col gap-6 p-5">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ScrollText className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">OPORD Recall</h1>
</div>
<p className="text-sm text-muted-foreground">
Study a live operations order, then recall the details under pressure.
</p>
</div>
<OpordArena initialStats={stats} currentUserId={user.id as number} />
</div>
);
}

View file

@ -131,6 +131,7 @@ export const Profiles: CollectionConfig = {
eodMinesweeper?: { xpEarned?: number | null }; eodMinesweeper?: { xpEarned?: number | null };
radioTraffic?: { xpEarned?: number | null }; radioTraffic?: { xpEarned?: number | null };
aimTrainer?: { xpEarned?: number | null }; aimTrainer?: { xpEarned?: number | null };
opordRecall?: { xpEarned?: number | null };
}; };
voiceover?: { xpEarned?: number | null }; voiceover?: { xpEarned?: number | null };
}; };
@ -146,6 +147,7 @@ export const Profiles: CollectionConfig = {
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0; const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0; const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0; const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
const opordRecallXp = progressionData?.minigames?.opordRecall?.xpEarned ?? 0;
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0; const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
if (awardIds.length > 0) { if (awardIds.length > 0) {
@ -188,6 +190,7 @@ export const Profiles: CollectionConfig = {
xpAcc += eodXp; xpAcc += eodXp;
xpAcc += radioTrafficXp; xpAcc += radioTrafficXp;
xpAcc += aimTrainerXp; xpAcc += aimTrainerXp;
xpAcc += opordRecallXp;
// Voiceover submissions: XP awarded per accepted submission // Voiceover submissions: XP awarded per accepted submission
xpAcc += voiceoverXp; xpAcc += voiceoverXp;
@ -565,6 +568,54 @@ export const Profiles: CollectionConfig = {
}, },
], ],
}, },
{
name: "opordRecall",
label: "OPORD Recall",
type: "group",
fields: [
{
name: "gamesPlayed",
type: "number",
defaultValue: 0,
},
{
name: "bestScore",
label: "Best Score",
type: "number",
defaultValue: 0,
},
{
name: "bestAccuracy",
label: "Best Accuracy",
type: "number",
defaultValue: 0,
},
{
name: "recruitBestScore",
label: "Recruit Best Score",
type: "number",
defaultValue: 0,
},
{
name: "veteranBestScore",
label: "Veteran Best Score",
type: "number",
defaultValue: 0,
},
{
name: "eliteBestScore",
label: "Elite Best Score",
type: "number",
defaultValue: 0,
},
{
name: "xpEarned",
label: "XP Earned",
type: "number",
defaultValue: 0,
},
],
},
], ],
}, },
{ {

View file

@ -24,6 +24,7 @@ import {
PlaneTakeoff, PlaneTakeoff,
Radio, Radio,
Bomb, Bomb,
ScrollText,
} from "lucide-react"; } from "lucide-react";
export interface NavigationCommand { export interface NavigationCommand {
@ -247,6 +248,13 @@ export const navigationCommands: NavigationCommand[] = [
href: "/eod", href: "/eod",
keywords: ["minigame", "eod", "mines", "training"], keywords: ["minigame", "eod", "mines", "training"],
}, },
{
id: "nav:opord-recall",
label: "OPORD Recall",
icon: ScrollText,
href: "/opord",
keywords: ["minigame", "briefing", "memory", "operations order", "training"],
},
{ {
id: "nav:account", id: "nav:account",
label: "Account", label: "Account",

View file

@ -167,6 +167,12 @@ export function NavUser({
Qualification Course Qualification Course
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/opord">
<FileTextIcon />
OPORD Recall
</Link>
</DropdownMenuItem>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
</DropdownMenuGroup> </DropdownMenuGroup>

View file

@ -0,0 +1,164 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { OPORD_DIFFICULTIES, type OpordDifficulty } from "@/lib/opord-recall";
import type { OpordStats } from "@/app/(frontend)/opord/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { useSidebar } from "@/components/ui/sidebar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/utils";
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" },
] as const satisfies ReadonlyArray<{
readonly value: OpordDifficulty;
readonly label: string;
readonly detail: string;
}>;
const TIER_STAT_KEYS = {
recruit: "recruitBestScore",
veteran: "veteranBestScore",
elite: "eliteBestScore",
} as const satisfies Record<OpordDifficulty, "recruitBestScore" | "veteranBestScore" | "eliteBestScore">;
export function OpordArena({
initialStats,
currentUserId,
}: {
initialStats: OpordStats;
currentUserId: number | string;
}) {
const [stats, setStats] = useState<OpordStats>(initialStats);
const [refreshKey, setRefreshKey] = useState(0);
const [difficulty, setDifficulty] = useState<OpordDifficulty>("recruit");
const [playing, setPlaying] = useState(false);
const { isMobile, setOpenMobile } = useSidebar();
const activeOperators = useRealtimePresence("opord", playing, true);
const handlePhaseChange = useCallback((nextPhase: "ready" | "briefing" | "quiz" | "over") => {
setPlaying(nextPhase === "briefing" || nextPhase === "quiz");
}, []);
const handleGameRecorded = useCallback((nextStats: OpordStats) => {
setStats(nextStats);
setRefreshKey((key) => key + 1);
}, []);
useEffect(() => {
if (playing && isMobile) setOpenMobile(false);
}, [isMobile, playing, setOpenMobile]);
return (
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-6">
<Card className="w-full">
<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.
</CardDescription>
</CardHeader>
<CardContent>
<fieldset
disabled={playing}
className={cn(
"m-0 min-w-0 border-0 p-0 transition-opacity duration-200 motion-reduce:transition-none",
playing && "opacity-60",
)}
>
<RadioGroup
value={difficulty}
onValueChange={(value) => {
if (value === "recruit" || value === "veteran" || value === "elite") {
setDifficulty(value);
}
}}
aria-label="OPORD Recall difficulty"
className="grid gap-2 md:grid-cols-3"
>
{TIER_OPTIONS.map((option) => (
<label
key={option.value}
htmlFor={`opord-tier-${option.value}`}
className={cn(
"flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-background px-3 py-3 transition-colors focus-within:ring-2 focus-within:ring-ring motion-reduce:transition-none",
difficulty === option.value && "border-primary bg-accent",
playing && "cursor-not-allowed",
)}
>
<RadioGroupItem
id={`opord-tier-${option.value}`}
value={option.value}
aria-label={`${option.label} briefing tier`}
/>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-center justify-between gap-2">
<span className="font-medium">{option.label}</span>
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{stats[TIER_STAT_KEYS[option.value]]} pts
</span>
</span>
<span className="text-xs text-muted-foreground">{option.detail}</span>
</span>
</label>
))}
</RadioGroup>
</fieldset>
</CardContent>
</Card>
<OpordGame
key={difficulty}
initialStats={stats}
difficulty={difficulty}
onGameRecordedAction={handleGameRecorded}
onPhaseChangeAction={handlePhaseChange}
/>
<Card className="w-full max-w-3xl">
<CardHeader className="pb-3">
<CardTitle className="text-sm">Recall protocol</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-xs text-muted-foreground sm:grid-cols-3">
<div className="flex flex-col gap-1">
<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".
</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.
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium text-foreground">Debrief</span>
<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.
</span>
</div>
</CardContent>
</Card>
<OpordLeaderboard
refreshKey={refreshKey}
difficulty={difficulty}
currentUserId={currentUserId}
activeOperators={activeOperators}
/>
</div>
);
}

View file

@ -0,0 +1,431 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { BookOpen, ClipboardCheck, Timer } from "lucide-react";
import {
OPORD_DIFFICULTIES,
buildOpordQuestions,
formatOpordGrid,
generateOpordScenario,
gradeOpordRun,
normalizeOpordAnswer,
type OpordDifficulty,
type OpordQuestion,
type OpordScenario,
} from "@/lib/opord-recall";
import type { OpordStats } from "@/app/(frontend)/opord/actions";
import { submitOpordRecallResult } from "@/app/(frontend)/opord/actions";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type Phase = "ready" | "briefing" | "quiz" | "over";
const ANSWER_FEEDBACK_MS = 900;
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">
<div className="text-xs uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-bold tabular-nums">{value}</div>
</div>
);
}
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,
onGameRecordedAction,
onPhaseChangeAction,
}: {
initialStats: OpordStats;
difficulty: OpordDifficulty;
onGameRecordedAction: (stats: OpordStats) => void;
onPhaseChangeAction: (phase: Phase) => void;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
const tier = OPORD_DIFFICULTIES[difficulty];
const phaseRef = useRef<Phase>("ready");
const statsRef = useRef<OpordStats>(initialStats);
const submittingRef = useRef(false);
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");
const [scenario, setScenario] = useState<OpordScenario | null>(null);
const [questions, setQuestions] = useState<readonly OpordQuestion[]>([]);
const [questionIndex, setQuestionIndex] = useState(0);
const [typedAnswer, setTypedAnswer] = useState("");
const [memoRemainingMs, setMemoRemainingMs] = useState(0);
const [lastAnswerWasCorrect, setLastAnswerWasCorrect] = useState<boolean | null>(null);
const [submitting, setSubmitting] = useState(false);
const [lastRun, setLastRun] = useState<{
correct: number;
total: number;
score: number;
xpEarned: number;
best: number;
error?: string;
} | null>(null);
const setPhaseSafe = useCallback(
(next: Phase) => {
phaseRef.current = next;
setPhase(next);
onPhaseChangeAction?.(next);
},
[onPhaseChangeAction],
);
const finishRun = useCallback(async () => {
if (submittingRef.current) return;
const timeMs = Math.max(1, Date.now() - quizStartedAtRef.current);
const correct = answersRef.current.filter(({ question, value }) => {
if (question.kind === "input") {
return normalizeOpordAnswer(value) === normalizeOpordAnswer(question.answer);
}
return Number(value) === question.correctIndex;
}).length;
const preview = gradeOpordRun(difficulty, correct, timeMs, true);
if (expiredRef.current) {
setLastRun({
correct,
total: preview.total,
score: preview.score,
xpEarned: 0,
best: statsRef.current.bestScore,
error: "Your session expired. Score not recorded.",
});
setPhaseSafe("over");
return;
}
submittingRef.current = true;
setSubmitting(true);
const res = await submitOpordRecallResult({ difficulty, correct, timeMs });
submittingRef.current = false;
setSubmitting(false);
if (res.success && res.data) {
statsRef.current = res.data;
onGameRecordedAction(res.data);
if (res.data.xpEarned > 0) {
window.dispatchEvent(
new CustomEvent(PROFILE_UPDATE_EVENT, {
detail: { reason: "xp", xpGained: res.data.xpEarned },
}),
);
window.setTimeout(() => router.refresh(), 1700);
}
setLastRun({
correct,
total: preview.total,
score: res.data.score,
xpEarned: res.data.xpEarned,
best: res.data.bestScore,
});
} else {
setLastRun({
correct,
total: preview.total,
score: preview.score,
xpEarned: 0,
best: statsRef.current.bestScore,
error: res.error ?? "Could not record score.",
});
}
setPhaseSafe("over");
}, [difficulty, expiredRef, onGameRecordedAction, router, setPhaseSafe]);
const startBriefing = useCallback(() => {
if (expiredRef.current) return;
const seed = Date.now() % 2147483647;
scenarioSeedRef.current = seed;
const nextScenario = generateOpordScenario(seed);
setScenario(nextScenario);
setQuestions(buildOpordQuestions(nextScenario, difficulty, seed));
setQuestionIndex(0);
setTypedAnswer("");
answersRef.current = [];
lastAnswerAtRef.current = 0;
setLastAnswerWasCorrect(null);
setLastRun(null);
setMemoRemainingMs(tier.memoSeconds * 1000);
setPhaseSafe("briefing");
}, [difficulty, expiredRef, setPhaseSafe, tier.memoSeconds]);
const beginQuiz = useCallback(() => {
if (phaseRef.current !== "briefing") return;
quizStartedAtRef.current = Date.now();
questionShownAtRef.current = Date.now();
setPhaseSafe("quiz");
}, [setPhaseSafe]);
const answerCurrent = useCallback(
(value: string) => {
if (phaseRef.current !== "quiz") return;
const question = questions[questionIndex];
if (!question) return;
const now = Date.now();
if (lastAnswerAtRef.current > 0 && now - lastAnswerAtRef.current < ANSWER_FEEDBACK_MS) {
return;
}
lastAnswerAtRef.current = now;
answersRef.current.push({ question, value });
setLastAnswerWasCorrect(
question.kind === "input"
? normalizeOpordAnswer(value) === normalizeOpordAnswer(question.answer)
: Number(value) === question.correctIndex,
);
window.setTimeout(() => {
setLastAnswerWasCorrect(null);
setTypedAnswer("");
if (questionIndex + 1 >= questions.length) {
void finishRun();
} else {
setQuestionIndex((index) => index + 1);
questionShownAtRef.current = Date.now();
}
}, ANSWER_FEEDBACK_MS);
},
[finishRun, questionIndex, questions],
);
useEffect(() => {
if (phase !== "briefing") return;
const deadline = Date.now() + memoRemainingMs;
const interval = window.setInterval(() => {
const remaining = deadline - Date.now();
if (remaining <= 0) {
window.clearInterval(interval);
setMemoRemainingMs(0);
beginQuiz();
return;
}
setMemoRemainingMs(remaining);
}, 200);
return () => window.clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase]);
const question = questions[questionIndex];
return (
<div className="flex w-full max-w-3xl flex-col items-center gap-4">
{phase === "ready" && (
<div className="flex w-full flex-col items-center gap-4 rounded-xl border border-border bg-background p-8 text-center">
<BookOpen aria-hidden="true" className="size-10 text-muted-foreground" />
<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.
</p>
<p className="text-xs text-muted-foreground">
Best score this tier:{" "}
{statsRef.current[`${difficulty}BestScore` as "recruitBestScore" | "veteranBestScore" | "eliteBestScore"] ?? 0}
</p>
</div>
<Button type="button" size="lg" onClick={startBriefing} disabled={expired}>
Receive briefing
</Button>
</div>
)}
{phase === "briefing" && scenario && (
<div className="flex w-full flex-col items-center gap-3">
<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
</span>
<div className="flex items-center gap-3">
<span className="font-mono text-lg font-bold tabular-nums">
{Math.ceil(memoRemainingMs / 1000)}s
</span>
<Button type="button" size="sm" onClick={beginQuiz}>
Ready early
</Button>
</div>
</div>
<BriefingDocument scenario={scenario} />
</div>
)}
{phase === "quiz" && question && (
<div className="flex w-full flex-col items-center gap-4 rounded-xl border border-border bg-background p-6">
<div className="flex w-full items-center justify-between text-xs uppercase tracking-wider text-muted-foreground">
<span>
Question {questionIndex + 1} of {questions.length}
</span>
<span className="flex items-center gap-1">
<ClipboardCheck aria-hidden="true" className="size-3.5" />
From memory
</span>
</div>
<div
className={cn(
"h-1 w-full overflow-hidden rounded-full bg-border transition-colors",
lastAnswerWasCorrect === true && "bg-emerald-500",
lastAnswerWasCorrect === false && "bg-red-500",
)}
aria-hidden="true"
/>
<p className="text-center text-lg font-semibold">{question.prompt}</p>
{question.kind === "choice" ? (
<div className="grid w-full gap-2 sm:grid-cols-2">
{question.options.map((option, index) => (
<Button
key={`${question.id}-${index}`}
type="button"
variant="outline"
className="h-auto min-h-11 justify-center font-mono"
disabled={lastAnswerWasCorrect !== null}
onClick={() => answerCurrent(String(index))}
>
{option}
</Button>
))}
</div>
) : (
<form
className="flex w-full max-w-xs flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
if (typedAnswer.trim().length === 0 || lastAnswerWasCorrect !== null) return;
answerCurrent(typedAnswer);
}}
>
<Input
value={typedAnswer}
onChange={(event) => setTypedAnswer(event.target.value)}
className="text-center font-mono"
autoComplete="off"
autoFocus
maxLength={12}
placeholder="Type your answer"
aria-label="Answer"
/>
<Button type="submit" disabled={typedAnswer.trim().length === 0}>
Submit
</Button>
</form>
)}
</div>
)}
{phase === "over" && (
<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="Score" value={lastRun?.score ?? 0} />
<Metre label="XP" value={`+${lastRun?.xpEarned ?? 0}`} />
<Metre label="Best" value={lastRun?.best ?? statsRef.current.bestScore} />
</div>
{lastRun?.error && <p className="text-sm text-red-400">{lastRun.error}</p>}
{submitting && <p className="text-xs text-muted-foreground">Recording score…</p>}
<Button type="button" size="lg" onClick={startBriefing} disabled={expired || submitting}>
New briefing
</Button>
</div>
)}
{expired && phase !== "ready" && (
<p className="text-xs text-amber-400">
Your session is about to end. The app will refresh when it expires.
</p>
)}
</div>
);
}

View file

@ -0,0 +1,219 @@
"use client";
import { useEffect, useState } from "react";
import { Trophy } from "lucide-react";
import type { OpordDifficulty } from "@/lib/opord-recall";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
import { cn } from "@/lib/utils";
import { OPORD_LEADERBOARD_FIELDS, readOpordProfiles } from "./opordLeaderboardData";
type LeaderboardRow = {
readonly id: number;
readonly userId: number | null;
readonly name: string;
readonly gamesPlayed: number;
readonly bestScore: number;
readonly bestAccuracy: number;
readonly isCurrentUser: boolean;
readonly isOutsideTopTen?: boolean;
};
function rankClass(index: number): string {
if (index === 0) return "font-bold text-amber-400";
if (index === 1) return "font-semibold text-slate-300";
if (index === 2) return "font-semibold text-amber-700";
return "text-muted-foreground";
}
export function OpordLeaderboard({
refreshKey = 0,
difficulty,
currentUserId,
activeOperators = [],
}: {
readonly refreshKey?: number;
readonly difficulty: OpordDifficulty;
readonly currentUserId?: number | string;
readonly activeOperators?: readonly PresenceParticipant[];
}) {
const [rows, setRows] = useState<readonly LeaderboardRow[] | null>(null);
const [failed, setFailed] = useState<string | null>(null);
const tier = OPORD_LEADERBOARD_FIELDS[difficulty];
useEffect(() => {
let cancelled = false;
setFailed(null);
setRows(null);
const field = `progression.minigames.opordRecall.${tier.scoreField}`;
const params = new URLSearchParams({
sort: `-${field}`,
limit: "100",
depth: "1",
});
fetch(`/api/profiles?${params.toString()}`)
.then(async (response) => {
if (!response.ok) throw new Error("leaderboard fetch failed");
const payload: unknown = await response.json();
return readOpordProfiles(payload);
})
.then((profiles) => {
if (cancelled) return;
const mapped: LeaderboardRow[] = profiles.map((profile) => {
const user =
typeof profile.user === "object" && profile.user !== null ? profile.user : null;
const userId = user?.id ?? (typeof profile.user === "number" ? profile.user : null);
const name =
user?.displayName ||
user?.payloadDisplayName ||
profile.profileTitle ||
user?.username ||
`Operator #${profile.id}`;
const opordRecall = profile.opordRecall;
return {
id: profile.id,
userId,
name,
gamesPlayed: opordRecall?.gamesPlayed ?? 0,
bestScore: opordRecall?.[tier.scoreField] ?? 0,
bestAccuracy: opordRecall?.bestAccuracy ?? 0,
isCurrentUser: userId !== null && String(userId) === String(currentUserId),
};
});
const scored = mapped
.filter((row) => row.bestScore > 0)
.sort(
(left, right) =>
right.bestScore - left.bestScore || right.bestAccuracy - left.bestAccuracy,
);
const visible = scored.slice(0, 10);
const current = mapped.find((row) => row.isCurrentUser);
if (current && !visible.some((row) => row.id === current.id)) {
visible.push({ ...current, isOutsideTopTen: true });
}
setRows(visible);
})
.catch((error: unknown) => {
if (cancelled) return;
setFailed(error instanceof Error ? error.message : "leaderboard fetch failed");
});
return () => {
cancelled = true;
};
}, [currentUserId, difficulty, refreshKey, tier.scoreField]);
return (
<Card className="w-full max-w-3xl overflow-hidden">
<CardHeader className="gap-1 border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<Trophy aria-hidden="true" className="size-4 text-amber-400" />
<CardTitle className="text-sm">OPORD Recall leaderboard</CardTitle>
<span className="ml-auto text-xs text-muted-foreground">{tier.label} · Top 10</span>
</div>
<CardDescription>Best score for the selected briefing tier.</CardDescription>
</CardHeader>
<div
className="border-b border-border px-4 py-2 text-xs text-muted-foreground"
aria-live="polite"
>
<span className="font-medium text-emerald-400">{activeOperators.length}</span>{" "}
{activeOperators.length === 1 ? "operator is" : "operators are"} studying briefings
{activeOperators.length > 0 && (
<span className="ml-1 text-foreground">
({activeOperators.map((operator) => operator.name).join(", ")})
</span>
)}
</div>
<CardContent className="p-0">
{failed !== null ? (
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
) : rows === null ? (
<div className="flex flex-col gap-2 px-4 py-4">
<Skeleton className="h-4 w-full motion-reduce:animate-none" />
<Skeleton className="h-4 w-3/4 motion-reduce:animate-none" />
<Skeleton className="h-4 w-1/2 motion-reduce:animate-none" />
</div>
) : rows.length === 0 ? (
<p className="px-4 py-4 text-sm text-muted-foreground">
No scores logged for this tier yet. Be the first to brief up.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-lg text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-4 py-2 font-medium">#</th>
<th className="px-2 py-2 font-medium">Operator</th>
<th className="px-2 py-2 text-right font-medium">Games</th>
<th className="px-2 py-2 text-right font-medium">Accuracy</th>
<th
aria-sort="descending"
className="px-4 py-2 text-right font-medium text-foreground"
>
Score
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((row, index) => (
<tr
key={row.id}
className={cn(
row.isCurrentUser && "bg-accent/40",
row.isOutsideTopTen && "text-muted-foreground opacity-70",
)}
title={
row.isOutsideTopTen
? "Operator status: not currently ranked in the top 10"
: undefined
}
>
<td className="px-4 py-2">
<span className={rankClass(index)}>
{row.isOutsideTopTen ? "-" : index + 1}
</span>
</td>
<td className="px-2 py-2 font-medium">
<span className="flex items-center gap-2">
<span className="max-w-48 truncate">{row.name}</span>
{row.isCurrentUser && (
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
)}
{row.isOutsideTopTen && (
<span className="shrink-0 text-xs font-normal uppercase tracking-wide text-muted-foreground">
unranked
</span>
)}
{row.userId !== null && activeOperators.some((operator) => operator.id === row.userId) && (
<span
className="size-2 rounded-full bg-emerald-400"
title="Playing now"
aria-label="Playing now"
/>
)}
</span>
</td>
<td className="px-2 py-2 text-right font-mono tabular-nums text-muted-foreground">
{row.gamesPlayed}
</td>
<td className="px-2 py-2 text-right font-mono tabular-nums text-muted-foreground">
{Math.round(row.bestAccuracy * 100)}%
</td>
<td className="px-4 py-2 text-right font-mono font-semibold tabular-nums">
{row.bestScore}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,95 @@
import type { OpordDifficulty } from "@/lib/opord-recall";
export type OpordLeaderboardFields = {
readonly label: string;
readonly scoreField: keyof OpordApiStats;
};
export type OpordApiStats = {
readonly gamesPlayed: number | null;
readonly bestScore: number | null;
readonly bestAccuracy: number | null;
readonly recruitBestScore: number | null;
readonly veteranBestScore: number | null;
readonly eliteBestScore: number | null;
};
type ApiUser = {
readonly id: number;
readonly username: string | null;
readonly displayName: string | null;
readonly payloadDisplayName: string | null;
};
type ApiProfile = {
readonly id: number;
readonly profileTitle: string | null;
readonly user: ApiUser | number | null;
readonly opordRecall: OpordApiStats | null;
};
export const OPORD_LEADERBOARD_FIELDS = {
recruit: { label: "Recruit", scoreField: "recruitBestScore" },
veteran: { label: "Veteran", scoreField: "veteranBestScore" },
elite: { label: "Elite", scoreField: "eliteBestScore" },
} as const satisfies Record<OpordDifficulty, OpordLeaderboardFields>;
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return typeof value === "object" && value !== null;
}
function readNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readUser(value: unknown): ApiUser | number | null {
const numericId = readNumber(value);
if (numericId !== null) return numericId;
if (!isRecord(value)) return null;
const id = readNumber(value.id);
if (id === null) return null;
return {
id,
username: readString(value.username),
displayName: readString(value.displayName),
payloadDisplayName: readString(value.payloadDisplayName),
};
}
function readOpordRecall(value: unknown): OpordApiStats | null {
if (!isRecord(value)) return null;
return {
gamesPlayed: readNumber(value.gamesPlayed),
bestScore: readNumber(value.bestScore),
bestAccuracy: readNumber(value.bestAccuracy),
recruitBestScore: readNumber(value.recruitBestScore),
veteranBestScore: readNumber(value.veteranBestScore),
eliteBestScore: readNumber(value.eliteBestScore),
};
}
function readProfile(value: unknown): ApiProfile | null {
if (!isRecord(value)) return null;
const id = readNumber(value.id);
if (id === null) return null;
const progression = isRecord(value.progression) ? value.progression : null;
const minigames = progression && isRecord(progression.minigames) ? progression.minigames : null;
return {
id,
profileTitle: readString(value.profileTitle),
user: readUser(value.user),
opordRecall: minigames ? readOpordRecall(minigames.opordRecall) : null,
};
}
export function readOpordProfiles(value: unknown): readonly ApiProfile[] {
if (!isRecord(value) || !Array.isArray(value.docs)) return [];
return value.docs.flatMap((item) => {
const profile = readProfile(item);
return profile ? [profile] : [];
});
}

433
src/lib/opord-recall.ts Normal file
View file

@ -0,0 +1,433 @@
/**
* OPORD Recall game rules. Pure module (no Payload imports), safe on client and server.
*
* A randomized operations order (OPORD) is generated from a numeric seed. The
* player studies the briefing for a limited "memo" window, then answers quiz
* questions about the details. Every function is deterministic for a given
* seed so scenarios are reproducible and testable.
*/
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;
/** Number of quiz questions asked after the briefing. */
readonly questionCount: 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. */
readonly parTimeMs: number;
/** Bonus for a perfect run inside the par time. */
readonly perfectBonus: number;
}
export const OPORD_DIFFICULTIES: Record<OpordDifficulty, OpordTierConfig> = {
recruit: {
memoSeconds: 25,
questionCount: 4,
choiceCount: 3,
correctPoints: 100,
parTimeMs: 45_000,
perfectBonus: 150,
},
veteran: {
memoSeconds: 18,
questionCount: 6,
choiceCount: 4,
correctPoints: 150,
parTimeMs: 70_000,
perfectBonus: 300,
},
elite: {
memoSeconds: 12,
questionCount: 8,
choiceCount: 4,
correctPoints: 200,
parTimeMs: 100_000,
perfectBonus: 500,
},
};
// XP tuning: modest flat rewards, replays motivated by score.
const COMPLETION_XP = 2;
const POINTS_PER_XP = 250;
const SCORE_XP_RATE = 1;
const NEW_TIER_RECORD_XP = 2;
const XP_CAP = 15;
export function isOpordDifficulty(value: unknown): value is OpordDifficulty {
return typeof value === "string" && (DIFFICULTY_VALUES as readonly string[]).includes(value);
}
export function calculateOpordAccuracy(correct: number, total: number): number {
if (total <= 0) return 0;
return Math.min(1, Math.max(0, correct / total));
}
export function calculateOpordScore(
difficulty: OpordDifficulty,
correct: number,
timeMs: number,
): number {
const tier = OPORD_DIFFICULTIES[difficulty];
const perfect = correct >= tier.questionCount && timeMs <= tier.parTimeMs;
return Math.max(0, correct * tier.correctPoints + (perfect ? tier.perfectBonus : 0));
}
export function calculateOpordXp(score: number, isNewTierBest: boolean): number {
const xp =
COMPLETION_XP +
Math.floor(score / POINTS_PER_XP) * SCORE_XP_RATE +
(isNewTierBest ? NEW_TIER_RECORD_XP : 0);
return Math.min(XP_CAP, xp);
}
/** Deterministic 32-bit RNG (same family the economy tick uses). */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function pick<T>(rng: () => number, values: readonly T[]): T {
return values[Math.floor(rng() * values.length) % values.length];
}
function pickInt(rng: () => number, min: number, max: number): number {
return min + Math.floor(rng() * (max - min + 1));
}
function shuffle<T>(rng: () => number, values: readonly T[]): T[] {
const result = [...values];
for (let i = result.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = result[i];
result[i] = result[j];
result[j] = tmp;
}
return result;
}
/** Normalizes a typed answer for comparison: uppercase, alphanumerics only. */
export function normalizeOpordAnswer(value: string): string {
return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
}
const OPERATION_ADJECTIVES = [
"IRON", "CRIMSON", "SILENT", "BROKEN", "AMBER", "HOLLOW", "STONE", "BLACK",
"SCARLET", "FROZEN", "BURNING", "SHADOW", "GRIM", "SABLE", "RED", "LAST",
] as const;
const OPERATION_NOUNS = [
"VIGIL", "HAMMER", "ANVIL", "LANCE", "SABRE", "FURY", "GUARDIAN", "SENTINEL",
"THUNDER", "ARROW", "BLADE", "SHIELD", "TALON", "SPIRE", "CIPHER", "PILGRIM",
] as const;
const OBJECTIVE_NAMES = [
"TRIDENT", "BEACON", "FOOTHILL", "ANVIL", "RELAY", "CITADEL", "OUTPOST", "GATEWAY",
"SPUR", "BASION", "LIGHTHOUSE", "CROSSING",
] as const;
const PHASE_LINE_NAMES = [
"AMBER", "CRIMSON", "COBALT", "JADE", "BURNT", "SLATE", "RUST", "IVORY",
] as const;
const CODEWORDS = [
"HAMMER", "VESPER", "TALISMAN", "NIGHTFALL", "WAYPOINT", "KEYSTONE", "MIRAGE", "TOTEM",
"LANTERN", "PENDULUM",
] as const;
const CALLSIGNS = [
"VIPER", "RAPTOR", "NOMAD", "WOLFPACK", "HAVOC", "WARDEN", "REAPER", "FALCON",
] as const;
const FRIENDLY_UNITS = [
"2nd Rifle Platoon", "3rd Rifle Platoon", "Weapons Squad", "1st Fire Team", "Recon Detachment",
] as const;
const TRANSPORT_OPTIONS = [
"hardened GMG trucks", "unarmored transports", "M-ATVs", "tracked APCs",
] as const;
export interface OpordScenario {
readonly seed: number;
/** e.g. "IRON VIGIL" */
readonly operationName: string;
/** e.g. "TRIDENT" */
readonly objective: string;
/** Eight-digit grid as digits only, e.g. "03467219". */
readonly grid: string;
/** Three-digit degrees, e.g. 47. */
readonly azimuth: number;
readonly distanceKm: number;
/** Zero-padded Zulu time, e.g. "0430Z". */
readonly hHour: string;
readonly ldPhaseLine: string;
readonly otherPhaseLines: readonly string[];
readonly codeword: string;
readonly abortCodeword: string;
/** e.g. "VIPER 6" */
readonly callsign: string;
/** e.g. "32.450" */
readonly frequency: string;
readonly enemyVehicles: number;
readonly enemySquads: number;
readonly friendlyUnit: string;
readonly transport: string;
}
/** Formats an 8-digit grid as two 4-digit halves, e.g. "0346 7219". */
export function formatOpordGrid(grid: string): string {
return grid.length === 8 ? `${grid.slice(0, 4)} ${grid.slice(4)}` : grid;
}
export function generateOpordScenario(seed: number): OpordScenario {
const rng = mulberry32(seed);
const operationName = `${pick(rng, OPERATION_ADJECTIVES)} ${pick(rng, OPERATION_NOUNS)}`;
const objective = pick(rng, OBJECTIVE_NAMES);
const grid = Array.from({ length: 8 }, () => Math.floor(rng() * 10)).join("");
const azimuth = pickInt(rng, 1, 359);
const distanceKm = pickInt(rng, 4, 24);
const hHourMinutes = pickInt(rng, 0, 23) * 100 + pick(rng, [0, 15, 30, 45]);
const hHour = `${String(hHourMinutes).padStart(4, "0")}Z`;
const phaseLines = shuffle(rng, PHASE_LINE_NAMES);
const codeword = pick(rng, CODEWORDS);
const abortCodeword = pick(rng, CODEWORDS.filter((word) => word !== codeword));
const callsign = `${pick(rng, CALLSIGNS)} ${pick(rng, [1, 2, 3, 5, 6, 7])}`;
const frequency = `${pickInt(rng, 28, 45)}.${String(pickInt(rng, 0, 999)).padStart(3, "0")}`;
return {
seed,
operationName,
objective,
grid,
azimuth,
distanceKm,
hHour,
ldPhaseLine: phaseLines[0],
otherPhaseLines: phaseLines.slice(1, 3),
codeword,
abortCodeword,
callsign,
frequency,
enemyVehicles: pickInt(rng, 1, 4),
enemySquads: pickInt(rng, 1, 3),
friendlyUnit: pick(rng, FRIENDLY_UNITS),
transport: pick(rng, TRANSPORT_OPTIONS),
};
}
export type OpordQuestion =
| {
readonly kind: "choice";
readonly id: string;
readonly prompt: string;
readonly options: readonly string[];
readonly correctIndex: number;
}
| {
readonly kind: "input";
readonly id: string;
readonly prompt: string;
readonly answer: string;
};
function choiceQuestion(
id: string,
prompt: string,
correct: string,
distractors: readonly string[],
rng: () => number,
): Extract<OpordQuestion, { kind: "choice" }> {
// Deduplicate distractors so the graded index always matches the visible text.
const unique = distractors.filter((value, index) => value !== correct && distractors.indexOf(value) === index);
const options = shuffle(rng, [correct, ...unique]);
return {
kind: "choice",
id,
prompt,
options,
correctIndex: options.indexOf(correct),
};
}
function pad3(value: number): string {
return String(value).padStart(3, "0");
}
function zuluShift(hHour: string, shiftMinutes: number): string {
const hours = Number(hHour.slice(0, 2));
const minutes = Number(hHour.slice(2, 4));
const total = (((hours * 60 + minutes + shiftMinutes) % 1440) + 1440) % 1440;
return `${pad3(Math.floor(total / 60) * 100 + (total % 60)).padStart(4, "0")}Z`;
}
/** Builds the tier's quiz for a scenario. Deterministic for a given seed. */
export function buildOpordQuestions(
scenario: OpordScenario,
difficulty: OpordDifficulty,
seed: number,
): readonly OpordQuestion[] {
const rng = mulberry32(seed ^ 0x9e3779b9);
const azimuthDecoys = [scenario.azimuth + 10, scenario.azimuth + 100, scenario.azimuth + 5].map(
(value) => pad3(((value - 1 + 360) % 360) + 1),
);
const codewordDistractors = shuffle(
rng,
CODEWORDS.filter((word) => word !== scenario.codeword && word !== scenario.abortCodeword),
).slice(0, 3);
const builders: readonly (() => OpordQuestion)[] = [
// The objective grid is the core skill: always asked first.
() =>
choiceQuestion(
"grid",
"What is the 8-digit grid of the objective?",
formatOpordGrid(scenario.grid),
[
formatOpordGrid(scenario.grid.split("").reverse().join("")),
formatOpordGrid(scenario.grid.slice(4) + scenario.grid.slice(0, 4)),
formatOpordGrid(
scenario.grid
.split("")
.map((digit) => String((Number(digit) + 1) % 10))
.join(""),
),
],
rng,
),
() =>
choiceQuestion(
"azimuth",
"What azimuth (in degrees) is the main axis of advance?",
pad3(scenario.azimuth),
azimuthDecoys,
rng,
),
() =>
choiceQuestion(
"h-hour",
"What is H-HOUR?",
scenario.hHour,
[zuluShift(scenario.hHour, 30), zuluShift(scenario.hHour, -45), zuluShift(scenario.hHour, 75)],
rng,
),
() =>
choiceQuestion(
"codeword",
"What is the authentication codeword?",
scenario.codeword,
codewordDistractors,
rng,
),
() =>
choiceQuestion(
"abort",
"What is the abort codeword?",
scenario.abortCodeword,
codewordDistractors,
rng,
),
() =>
choiceQuestion(
"callsign",
"What is your element's callsign?",
scenario.callsign,
shuffle(rng, CALLSIGNS.filter((name) => !scenario.callsign.startsWith(name)))
.slice(0, 3)
.map((name) => `${name} ${pick(rng, [1, 2, 3, 5, 6, 7])}`),
rng,
),
() =>
choiceQuestion(
"frequency",
"What is the assigned radio frequency?",
scenario.frequency,
[0.25, -0.25, 0.5].map((delta) => (Number(scenario.frequency) + delta).toFixed(3)),
rng,
),
() =>
choiceQuestion(
"ld",
"Which phase line is the line of departure?",
scenario.ldPhaseLine,
scenario.otherPhaseLines.concat(
shuffle(rng, PHASE_LINE_NAMES.filter((name) => name !== scenario.ldPhaseLine)).filter(
(name) => !scenario.otherPhaseLines.includes(name),
)[0],
),
rng,
),
() =>
choiceQuestion(
"enemy",
"How many enemy vehicles are on the objective?",
`${scenario.enemyVehicles}`,
["1", "2", "3", "4", "5"]
.filter((count) => count !== String(scenario.enemyVehicles))
.slice(0, 3),
rng,
),
() =>
choiceQuestion(
"objective",
"What is the objective named?",
scenario.objective,
shuffle(rng, OBJECTIVE_NAMES.filter((name) => name !== scenario.objective)).slice(0, 3),
rng,
),
() =>
choiceQuestion(
"friendly",
"Which friendly unit is in support?",
scenario.friendlyUnit,
shuffle(rng, FRIENDLY_UNITS.filter((unit) => unit !== scenario.friendlyUnit)).slice(0, 3),
rng,
),
];
const [first, ...rest] = shuffle(rng, builders.slice(1));
const ordered = [builders[0], first, ...rest].map((build) => build());
return ordered.slice(0, OPORD_DIFFICULTIES[difficulty].questionCount);
}
export interface OpordRunResult {
readonly difficulty: OpordDifficulty;
readonly correct: number;
readonly total: number;
readonly timeMs: number;
readonly score: number;
readonly accuracy: number;
readonly xp: number;
}
/** Grades a completed run from raw counts (used by both UI preview and server). */
export function gradeOpordRun(
difficulty: OpordDifficulty,
correct: number,
timeMs: number,
isNewTierBest: boolean,
): OpordRunResult {
const tier = OPORD_DIFFICULTIES[difficulty];
const boundedCorrect = Math.min(Math.max(0, correct), tier.questionCount);
const score = calculateOpordScore(difficulty, boundedCorrect, timeMs);
return {
difficulty,
correct: boundedCorrect,
total: tier.questionCount,
timeMs,
score,
accuracy: calculateOpordAccuracy(boundedCorrect, tier.questionCount),
xp: calculateOpordXp(score, isNewTierBest),
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_games_played" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_best_score" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_best_accuracy" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_recruit_best_score" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_veteran_best_score" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_elite_best_score" numeric DEFAULT 0;
ALTER TABLE "profiles" ADD COLUMN "progression_minigames_opord_recall_xp_earned" numeric DEFAULT 0;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_games_played";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_best_score";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_best_accuracy";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_recruit_best_score";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_veteran_best_score";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_elite_best_score";
ALTER TABLE "profiles" DROP COLUMN "progression_minigames_opord_recall_xp_earned";`)
}

View file

@ -44,6 +44,7 @@ import * as migration_20260907_083010_add_voice_submission_description from './2
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields'; import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats'; import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats';
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical'; import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical';
import * as migration_20260908_152021_add_opord_recall_minigame_stats from './20260908_152021_add_opord_recall_minigame_stats';
export const migrations = [ export const migrations = [
{ {
@ -274,6 +275,11 @@ export const migrations = [
{ {
up: migration_20260907_124024_convert_story_beats_body_to_lexical.up, up: migration_20260907_124024_convert_story_beats_body_to_lexical.up,
down: migration_20260907_124024_convert_story_beats_body_to_lexical.down, down: migration_20260907_124024_convert_story_beats_body_to_lexical.down,
name: '20260907_124024_convert_story_beats_body_to_lexical' name: '20260907_124024_convert_story_beats_body_to_lexical',
},
{
up: migration_20260908_152021_add_opord_recall_minigame_stats.up,
down: migration_20260908_152021_add_opord_recall_minigame_stats.down,
name: '20260908_152021_add_opord_recall_minigame_stats'
}, },
]; ];

View file

@ -2037,6 +2037,15 @@ export interface Profile {
eliteBestAccuracy?: number | null; eliteBestAccuracy?: number | null;
xpEarned?: number | null; xpEarned?: number | null;
}; };
opordRecall?: {
gamesPlayed?: number | null;
bestScore?: number | null;
bestAccuracy?: number | null;
recruitBestScore?: number | null;
veteranBestScore?: number | null;
eliteBestScore?: number | null;
xpEarned?: number | null;
};
}; };
voiceover?: { voiceover?: {
xpEarned?: number | null; xpEarned?: number | null;
@ -4429,6 +4438,17 @@ export interface ProfilesSelect<T extends boolean = true> {
eliteBestAccuracy?: T; eliteBestAccuracy?: T;
xpEarned?: T; xpEarned?: T;
}; };
opordRecall?:
| T
| {
gamesPlayed?: T;
bestScore?: T;
bestAccuracy?: T;
recruitBestScore?: T;
veteranBestScore?: T;
eliteBestScore?: T;
xpEarned?: T;
};
}; };
voiceover?: voiceover?:
| T | T

View file

@ -76,6 +76,7 @@ export const EventTypes = {
MinigameEod: "minigame:eod", MinigameEod: "minigame:eod",
MinigameRadioTraffic: "minigame:radio-traffic", MinigameRadioTraffic: "minigame:radio-traffic",
MinigameAimTrainer: "minigame:aim-trainer", MinigameAimTrainer: "minigame:aim-trainer",
MinigameOpordRecall: "minigame:opord-recall",
// Voiceover // Voiceover
VoiceoverSubmit: "voiceover:submit", VoiceoverSubmit: "voiceover:submit",

View file

@ -0,0 +1,60 @@
import { expect, test, type Page } from "@playwright/test";
const QUIZ_OPTION_BUTTON = "button.font-mono";
async function login(page: Page): Promise<void> {
await page.goto("/login");
await page.fill("#username", "dev");
await page.fill("#password", "Test123");
await page.locator('button[type="submit"]').click();
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 });
}
async function playOpordRound(page: Page): Promise<void> {
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("Memorize the order")).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]}`;
await page.getByRole("button", { name: "Ready early" }).click();
const questionCounter = page.getByText(/Question \d+ of \d+/);
await expect(questionCounter).toBeVisible();
await expect(questionCounter).toContainText("Question 1 of 4");
// Question 1 is always the objective grid: answer it correctly.
await page.locator(QUIZ_OPTION_BUTTON).filter({ hasText: grid }).first().click();
for (let index = 1; index < 4; index += 1) {
await expect(questionCounter).toContainText(`Question ${index + 1} of 4`, {
timeout: 10_000,
});
await page.locator(QUIZ_OPTION_BUTTON).first().click();
}
await expect(page.getByRole("heading", { name: "Debrief" })).toBeVisible({ timeout: 15_000 });
}
test.describe("OPORD Recall", () => {
test("plays a full round and records the score", async ({ page }) => {
await login(page);
await playOpordRound(page);
// The grid answer alone scores 100; the XP metre shows a positive bonus.
await expect(page.locator("body")).toContainText(/Recalled/);
await expect(page.getByText(/^\+\d+$/).first()).toBeVisible();
// 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 });
const myRow = leaderboard.locator("tbody tr").filter({ hasText: "(you)" }).first();
await expect(myRow).toBeVisible({ timeout: 15_000 });
await expect(myRow.locator("td").last()).toContainText(/\d{3}/);
});
});