Compare commits
2 commits
caca11a60b
...
225434d8fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 225434d8fc | |||
| eaa0325b6d |
17 changed files with 25486 additions and 2 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
213
src/app/(frontend)/opord/actions.ts
Normal file
213
src/app/(frontend)/opord/actions.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
66
src/app/(frontend)/opord/page.tsx
Normal file
66
src/app/(frontend)/opord/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -131,6 +131,7 @@ export const Profiles: CollectionConfig = {
|
|||
eodMinesweeper?: { xpEarned?: number | null };
|
||||
radioTraffic?: { xpEarned?: number | null };
|
||||
aimTrainer?: { xpEarned?: number | null };
|
||||
opordRecall?: { xpEarned?: number | null };
|
||||
};
|
||||
voiceover?: { xpEarned?: number | null };
|
||||
};
|
||||
|
|
@ -146,6 +147,7 @@ export const Profiles: CollectionConfig = {
|
|||
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
|
||||
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
||||
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
|
||||
const opordRecallXp = progressionData?.minigames?.opordRecall?.xpEarned ?? 0;
|
||||
const voiceoverXp = progressionData?.voiceover?.xpEarned ?? 0;
|
||||
|
||||
if (awardIds.length > 0) {
|
||||
|
|
@ -188,6 +190,7 @@ export const Profiles: CollectionConfig = {
|
|||
xpAcc += eodXp;
|
||||
xpAcc += radioTrafficXp;
|
||||
xpAcc += aimTrainerXp;
|
||||
xpAcc += opordRecallXp;
|
||||
// Voiceover submissions: XP awarded per accepted submission
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
PlaneTakeoff,
|
||||
Radio,
|
||||
Bomb,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NavigationCommand {
|
||||
|
|
@ -247,6 +248,13 @@ export const navigationCommands: NavigationCommand[] = [
|
|||
href: "/eod",
|
||||
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",
|
||||
label: "Account",
|
||||
|
|
|
|||
|
|
@ -167,6 +167,12 @@ export function NavUser({
|
|||
Qualification Course
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/opord">
|
||||
<FileTextIcon />
|
||||
OPORD Recall
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
|
|
|
|||
164
src/components/frontend/opord/OpordArena.tsx
Normal file
164
src/components/frontend/opord/OpordArena.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
431
src/components/frontend/opord/OpordGame.tsx
Normal file
431
src/components/frontend/opord/OpordGame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
src/components/frontend/opord/OpordLeaderboard.tsx
Normal file
219
src/components/frontend/opord/OpordLeaderboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/components/frontend/opord/opordLeaderboardData.ts
Normal file
95
src/components/frontend/opord/opordLeaderboardData.ts
Normal 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
433
src/lib/opord-recall.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
23688
src/migrations/20260908_152021_add_opord_recall_minigame_stats.json
Normal file
23688
src/migrations/20260908_152021_add_opord_recall_minigame_stats.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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_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_20260908_152021_add_opord_recall_minigame_stats from './20260908_152021_add_opord_recall_minigame_stats';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -274,6 +275,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260907_124024_convert_story_beats_body_to_lexical.up,
|
||||
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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2037,6 +2037,15 @@ export interface Profile {
|
|||
eliteBestAccuracy?: 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?: {
|
||||
xpEarned?: number | null;
|
||||
|
|
@ -4429,6 +4438,17 @@ export interface ProfilesSelect<T extends boolean = true> {
|
|||
eliteBestAccuracy?: T;
|
||||
xpEarned?: T;
|
||||
};
|
||||
opordRecall?:
|
||||
| T
|
||||
| {
|
||||
gamesPlayed?: T;
|
||||
bestScore?: T;
|
||||
bestAccuracy?: T;
|
||||
recruitBestScore?: T;
|
||||
veteranBestScore?: T;
|
||||
eliteBestScore?: T;
|
||||
xpEarned?: T;
|
||||
};
|
||||
};
|
||||
voiceover?:
|
||||
| T
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export const EventTypes = {
|
|||
MinigameEod: "minigame:eod",
|
||||
MinigameRadioTraffic: "minigame:radio-traffic",
|
||||
MinigameAimTrainer: "minigame:aim-trainer",
|
||||
MinigameOpordRecall: "minigame:opord-recall",
|
||||
|
||||
// Voiceover
|
||||
VoiceoverSubmit: "voiceover:submit",
|
||||
|
|
|
|||
60
tests/e2e/opord-recall.e2e.spec.ts
Normal file
60
tests/e2e/opord-recall.e2e.spec.ts
Normal 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}/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue