1
0
Fork 0

Compare commits

...

8 commits

Author SHA1 Message Date
891e235397 fix(radio): correct helipad location phrase
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 23:36:22 -04:00
8e9262ca68 feat(navigation): add qualification course link
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 23:36:22 -04:00
d04b2d3ecb feat(qualification): add aim trainer leaderboard
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 23:36:22 -04:00
320fe4b36e feat(qualification): add aim trainer game interface
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 23:36:22 -04:00
e3e4760f49 feat(qualification): add qualification course page
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 23:36:22 -04:00
282e23edc2 feat(qualification): record aim trainer results
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 23:36:22 -04:00
a6a7a5639d feat(profiles): add aim trainer statistics schema
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 23:36:22 -04:00
73d689a680 feat(qualification): add aim trainer rules
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 23:36:22 -04:00
17 changed files with 2671 additions and 6 deletions

View file

@ -0,0 +1,239 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { broadcastToUser } from "@/lib/realtime/bus";
import {
isAimTrainerDifficulty,
calculateAimTrainerAccuracy,
calculateAimTrainerScore,
calculateAimTrainerXp,
MAX_SHOTS_FIRED,
MAX_HITS,
MAX_DESPAWNED_TARGETS,
} from "@/lib/aim-trainer";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
export interface AimTrainerStats {
gamesPlayed: number;
bestScore: number;
recruitBestScore: number;
veteranBestScore: number;
eliteBestScore: number;
recruitBestAccuracy: number;
veteranBestAccuracy: number;
eliteBestAccuracy: number;
aimTrainerXp: number;
xpEarned: number;
score: number;
accuracy: number;
}
export interface AimTrainerResultInput {
difficulty: string;
headshots: number;
torsoHits: number;
shotsFired: number;
despawnedUnhitTargets: number;
}
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 submitAimTrainerResult(
input: AimTrainerResultInput,
): Promise<ActionResult<AimTrainerStats>> {
try {
const { payload, user } = await authenticate();
if (!isAimTrainerDifficulty(input.difficulty)) {
return { success: false, error: "Invalid difficulty." };
}
const { headshots, torsoHits, shotsFired, despawnedUnhitTargets } = input;
if (
!Number.isInteger(headshots) ||
headshots < 0 ||
!Number.isInteger(torsoHits) ||
torsoHits < 0 ||
!Number.isInteger(shotsFired) ||
shotsFired < 0 ||
!Number.isInteger(despawnedUnhitTargets) ||
despawnedUnhitTargets < 0
) {
return { success: false, error: "Invalid result." };
}
if (shotsFired > MAX_SHOTS_FIRED) {
return { success: false, error: "Too many shots fired." };
}
const hits = headshots + torsoHits;
if (hits > MAX_HITS) {
return { success: false, error: "Too many hits." };
}
if (hits > shotsFired) {
return { success: false, error: "Hits cannot exceed shots fired." };
}
if (despawnedUnhitTargets > MAX_DESPAWNED_TARGETS) {
return { success: false, error: "Too many despawned targets." };
}
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?: {
aimTrainer?: {
gamesPlayed?: number;
bestScore?: number;
recruitBestScore?: number;
veteranBestScore?: number;
eliteBestScore?: number;
recruitBestAccuracy?: number;
veteranBestAccuracy?: number;
eliteBestAccuracy?: number;
xpEarned?: number;
};
};
};
const current = progression.minigames?.aimTrainer ?? {};
const accuracy = calculateAimTrainerAccuracy(hits, shotsFired, despawnedUnhitTargets);
const score = calculateAimTrainerScore(headshots, torsoHits, shotsFired, despawnedUnhitTargets);
const difficultyBestScoreKey = `${input.difficulty}BestScore` as
| "recruitBestScore"
| "veteranBestScore"
| "eliteBestScore";
const difficultyBestAccuracyKey = `${input.difficulty}BestAccuracy` as
| "recruitBestAccuracy"
| "veteranBestAccuracy"
| "eliteBestAccuracy";
const isNewTierBest = score > (current[difficultyBestScoreKey] ?? 0);
const xp = calculateAimTrainerXp(score, isNewTierBest);
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
const bestScore = Math.max(current.bestScore ?? 0, score);
const difficultyBestScore = Math.max(current[difficultyBestScoreKey] ?? 0, score);
const difficultyBestAccuracy = Math.max(current[difficultyBestAccuracyKey] ?? 0, accuracy);
const aimTrainerXp = (current.xpEarned ?? 0) + xp;
const { xp: _xp, ...progressionRest } = progression;
const recruitBestScore = input.difficulty === "recruit" ? difficultyBestScore : (current.recruitBestScore ?? 0);
const veteranBestScore = input.difficulty === "veteran" ? difficultyBestScore : (current.veteranBestScore ?? 0);
const eliteBestScore = input.difficulty === "elite" ? difficultyBestScore : (current.eliteBestScore ?? 0);
const recruitBestAccuracy =
input.difficulty === "recruit" ? difficultyBestAccuracy : (current.recruitBestAccuracy ?? 0);
const veteranBestAccuracy =
input.difficulty === "veteran" ? difficultyBestAccuracy : (current.veteranBestAccuracy ?? 0);
const eliteBestAccuracy =
input.difficulty === "elite" ? difficultyBestAccuracy : (current.eliteBestAccuracy ?? 0);
await payload.update({
collection: "profiles",
id: profile.id,
data: {
progression: {
...progressionRest,
minigames: {
...(progressionRest.minigames ?? {}),
aimTrainer: {
gamesPlayed,
bestScore,
recruitBestScore,
veteranBestScore,
eliteBestScore,
recruitBestAccuracy,
veteranBestAccuracy,
eliteBestAccuracy,
xpEarned: aimTrainerXp,
},
},
},
},
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.MinigameAimTrainer,
message: `completed the Qualification Course (${input.difficulty}) with a score of ${score} (${pct}% accuracy)`,
actor: user.id as number,
targetCollection: "users",
targetId: user.id as number,
data: {
difficulty: input.difficulty,
score,
accuracy,
xpEarned: xp,
bestScore,
difficultyBestScore,
aimTrainerXp,
},
});
}
return {
success: true,
data: {
gamesPlayed,
bestScore,
recruitBestScore,
veteranBestScore,
eliteBestScore,
recruitBestAccuracy,
veteranBestAccuracy,
eliteBestAccuracy,
aimTrainerXp,
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,70 @@
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 { Crosshair } from "lucide-react";
import type { AimTrainerStats } from "@/app/(frontend)/qualification/actions";
import { AimTrainerArena } from "@/components/frontend/qualification/AimTrainerArena";
export const metadata: Metadata = {
title: "Qualification Course — Polaris Task Force",
description: "Complete a timed qualification course to sharpen your marksmanship and earn XP.",
};
export default async function QualificationPage() {
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("/qualification")}`);
}
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 aimTrainer = profileRes.docs[0]?.progression?.minigames?.aimTrainer ?? {};
const stats: AimTrainerStats = {
gamesPlayed: aimTrainer.gamesPlayed ?? 0,
bestScore: aimTrainer.bestScore ?? 0,
recruitBestScore: aimTrainer.recruitBestScore ?? 0,
veteranBestScore: aimTrainer.veteranBestScore ?? 0,
eliteBestScore: aimTrainer.eliteBestScore ?? 0,
recruitBestAccuracy: aimTrainer.recruitBestAccuracy ?? 0,
veteranBestAccuracy: aimTrainer.veteranBestAccuracy ?? 0,
eliteBestAccuracy: aimTrainer.eliteBestAccuracy ?? 0,
aimTrainerXp: aimTrainer.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">
<Crosshair className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Qualification Course</h1>
</div>
<p className="text-sm text-muted-foreground">
Acquire targets, place your shots, and qualify under pressure.
</p>
</div>
<AimTrainerArena initialStats={stats} currentUserId={user.id} />
</div>
);
}

View file

@ -122,6 +122,7 @@ export const Profiles: CollectionConfig = {
flappyBird?: { xpEarned?: number | null };
eodMinesweeper?: { xpEarned?: number | null };
radioTraffic?: { xpEarned?: number | null };
aimTrainer?: { xpEarned?: number | null };
};
};
const awardsList = progressionData?.awards || [];
@ -135,6 +136,7 @@ export const Profiles: CollectionConfig = {
const flappyXp = progressionData?.minigames?.flappyBird?.xpEarned ?? 0;
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
if (awardIds.length > 0) {
await req.payload
@ -175,6 +177,7 @@ export const Profiles: CollectionConfig = {
// EOD Minesweeper: XP from cleared minefields
xpAcc += eodXp;
xpAcc += radioTrafficXp;
xpAcc += aimTrainerXp;
return xpAcc;
},
@ -482,6 +485,66 @@ export const Profiles: CollectionConfig = {
},
],
},
{
name: "aimTrainer",
label: "Aim Trainer",
type: "group",
fields: [
{
name: "gamesPlayed",
type: "number",
defaultValue: 0,
},
{
name: "bestScore",
label: "Best Score",
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: "recruitBestAccuracy",
label: "Recruit Best Accuracy",
type: "number",
defaultValue: 0,
},
{
name: "veteranBestAccuracy",
label: "Veteran Best Accuracy",
type: "number",
defaultValue: 0,
},
{
name: "eliteBestAccuracy",
label: "Elite Best Accuracy",
type: "number",
defaultValue: 0,
},
{
name: "xpEarned",
label: "XP Earned",
type: "number",
defaultValue: 0,
},
],
},
],
},
],

View file

@ -5,6 +5,7 @@ import {
Bird,
Bomb,
ChevronsUpDown,
Crosshair,
FileTextIcon,
LogOut,
Radio,
@ -30,7 +31,6 @@ import {
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import type { User } from "@/payload-types";
import { ImpersonationModal } from "@/components/frontend/impersonation/ImpersonationModal";
import { getImpersonationUsers } from "@/app/(frontend)/impersonate/actions";
@ -52,7 +52,6 @@ export function NavUser({
const [loggingOut, setLoggingOut] = useState(false);
const [showImpersonationModal, setShowImpersonationModal] = useState(false);
const [impersonationUsers, setImpersonationUsers] = useState<any[]>([]);
const [loadingImpersonationUsers, setLoadingImpersonationUsers] = useState(false);
const primaryLine = showCallsign ? user.username : user.displayName;
const secondaryLine = showCallsign ? user.displayName : user.username;
@ -71,14 +70,11 @@ export function NavUser({
};
const handleOpenImpersonationModal = async () => {
setLoadingImpersonationUsers(true);
try {
const users = await getImpersonationUsers();
setImpersonationUsers(users);
} catch (error) {
console.error("Failed to load impersonation users:", error);
} finally {
setLoadingImpersonationUsers(false);
}
setShowImpersonationModal(true);
};
@ -154,6 +150,12 @@ export function NavUser({
Radio Traffic
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/qualification">
<Crosshair />
Qualification Course
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>

View file

@ -0,0 +1,177 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
AIM_TRAINER_DIFFICULTIES,
isAimTrainerDifficulty,
type AimTrainerDifficulty,
} from "@/lib/aim-trainer";
import type { AimTrainerStats } from "@/app/(frontend)/qualification/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 { AimTrainerGame, type AimTrainerPhase } from "./AimTrainerGame";
import { AimTrainerLeaderboard } from "./AimTrainerLeaderboard";
const TIER_OPTIONS = [
{ value: "recruit", label: "Recruit", detail: "Steady silhouettes" },
{ value: "veteran", label: "Veteran", detail: "Moving range" },
{ value: "elite", label: "Elite", detail: "High-speed range" },
] as const satisfies ReadonlyArray<{
readonly value: AimTrainerDifficulty;
readonly label: string;
readonly detail: string;
}>;
const TIER_STAT_KEYS = {
recruit: { bestScore: "recruitBestScore", bestAccuracy: "recruitBestAccuracy" },
veteran: { bestScore: "veteranBestScore", bestAccuracy: "veteranBestAccuracy" },
elite: { bestScore: "eliteBestScore", bestAccuracy: "eliteBestAccuracy" },
} as const satisfies Record<
AimTrainerDifficulty,
{
readonly bestScore: "recruitBestScore" | "veteranBestScore" | "eliteBestScore";
readonly bestAccuracy: "recruitBestAccuracy" | "veteranBestAccuracy" | "eliteBestAccuracy";
}
>;
export function AimTrainerArena({
initialStats,
currentUserId,
}: {
readonly initialStats: AimTrainerStats;
readonly currentUserId: number | string;
}) {
const [stats, setStats] = useState<AimTrainerStats>(initialStats);
const [refreshKey, setRefreshKey] = useState(0);
const [difficulty, setDifficulty] = useState<AimTrainerDifficulty>("recruit");
const [playing, setPlaying] = useState(false);
const { isMobile, setOpenMobile } = useSidebar();
const activeTrainees = useRealtimePresence("qualification", playing, true);
const handlePhaseChange = useCallback((nextPhase: AimTrainerPhase) => {
setPlaying(nextPhase === "playing");
}, []);
const handleGameRecorded = useCallback((nextStats: AimTrainerStats) => {
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 qualification lane</CardTitle>
<CardDescription>
Each course lasts 30 seconds. Accuracy keeps your score climbing when the range gets
busy.
</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 (isAimTrainerDifficulty(value)) setDifficulty(value);
}}
aria-label="Qualification difficulty"
className="grid gap-2 md:grid-cols-3"
>
{TIER_OPTIONS.map((option) => {
const statKeys = TIER_STAT_KEYS[option.value];
const optionStats = {
bestScore: stats[statKeys.bestScore],
bestAccuracy: stats[statKeys.bestAccuracy],
};
return (
<label
key={option.value}
htmlFor={`qualification-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={`qualification-tier-${option.value}`}
value={option.value}
aria-label={`${option.label} qualification lane`}
/>
<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">
{optionStats.bestScore} pts
</span>
</span>
<span className="text-xs text-muted-foreground">{option.detail}</span>
<span className="font-mono text-xs tabular-nums text-muted-foreground">
Best accuracy {Math.round(optionStats.bestAccuracy * 100)}%
</span>
</span>
</label>
);
})}
</RadioGroup>
</fieldset>
</CardContent>
</Card>
<AimTrainerGame
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">Qualification 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">Acquire</span>
<span>
Up to {AIM_TRAINER_DIFFICULTIES[difficulty].maxConcurrentTargets} silhouettes are
active at once. Moving targets rebound inside the range.
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium text-foreground">Prioritize</span>
<span>Headshots score 250 points. Torso hits score 100 points.</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium text-foreground">Qualify</span>
<span>
The course adds an accuracy bonus and records the best score for each lane. Current
target window: {AIM_TRAINER_DIFFICULTIES[difficulty].targetLifetimeMs / 1000}s.
</span>
</div>
</CardContent>
</Card>
<AimTrainerLeaderboard
refreshKey={refreshKey}
difficulty={difficulty}
currentUserId={currentUserId}
activeTrainees={activeTrainees}
/>
</div>
);
}

View file

@ -0,0 +1,259 @@
import {
calculateAimTrainerAccuracy,
calculateAimTrainerScore,
COURSE_DURATION_MS,
type AimTrainerTarget,
} from "@/lib/aim-trainer";
export const AIM_TRAINER_CANVAS_WIDTH = 720;
export const AIM_TRAINER_CANVAS_HEIGHT = 480;
export const AIM_TRAINER_FEEDBACK_DURATION_MS = 700;
export const AIM_TRAINER_FLASH_DURATION_MS = 150;
export const AIM_TRAINER_TARGET_FADE_DURATION_MS = 250;
const COURSE_DURATION_SECONDS = COURSE_DURATION_MS / 1000;
export type AimTrainerPointFeedback = {
readonly x: number;
readonly y: number;
readonly text: string;
readonly color: string;
readonly createdAt: number;
};
export type AimTrainerActiveTarget = {
readonly target: AimTrainerTarget;
readonly spawnedAt: number;
};
type AimTrainerSceneFrame = {
readonly now: number;
readonly targetLifetimeMs: number;
};
export type AimTrainerGameState = {
targets: AimTrainerActiveTarget[];
pendingSpawnAts: number[];
startedAt: number;
headshots: number;
torsoHits: number;
shotsFired: number;
despawnedUnhitTargets: number;
feedback: AimTrainerPointFeedback[];
flashColor: string;
flashUntil: number;
lastDisplayedSecond: number;
};
export type AimTrainerLiveStats = {
readonly score: number;
readonly accuracy: number;
readonly shotsFired: number;
readonly headshots: number;
readonly torsoHits: number;
readonly despawnedUnhitTargets: number;
};
export function createAimTrainerGameState(): AimTrainerGameState {
return {
targets: [],
pendingSpawnAts: [],
startedAt: 0,
headshots: 0,
torsoHits: 0,
shotsFired: 0,
despawnedUnhitTargets: 0,
feedback: [],
flashColor: "#d9d19b",
flashUntil: 0,
lastDisplayedSecond: COURSE_DURATION_SECONDS,
};
}
export function emptyAimTrainerLiveStats(): AimTrainerLiveStats {
return {
score: 0,
accuracy: 0,
shotsFired: 0,
headshots: 0,
torsoHits: 0,
despawnedUnhitTargets: 0,
};
}
export function getAimTrainerLiveStats(game: AimTrainerGameState): AimTrainerLiveStats {
const hits = game.headshots + game.torsoHits;
return {
score: calculateAimTrainerScore(
game.headshots,
game.torsoHits,
game.shotsFired,
game.despawnedUnhitTargets,
),
accuracy: calculateAimTrainerAccuracy(hits, game.shotsFired, game.despawnedUnhitTargets),
shotsFired: game.shotsFired,
headshots: game.headshots,
torsoHits: game.torsoHits,
despawnedUnhitTargets: game.despawnedUnhitTargets,
};
}
function drawRange(ctx: CanvasRenderingContext2D): void {
const range = ctx.createLinearGradient(0, 0, 0, AIM_TRAINER_CANVAS_HEIGHT);
range.addColorStop(0, "#1a2422");
range.addColorStop(0.55, "#26332c");
range.addColorStop(1, "#111916");
ctx.fillStyle = range;
ctx.fillRect(0, 0, AIM_TRAINER_CANVAS_WIDTH, AIM_TRAINER_CANVAS_HEIGHT);
ctx.strokeStyle = "rgba(174, 167, 117, 0.1)";
ctx.lineWidth = 1;
for (let x = 0; x <= AIM_TRAINER_CANVAS_WIDTH; x += 40) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, AIM_TRAINER_CANVAS_HEIGHT);
ctx.stroke();
}
for (let y = 0; y <= AIM_TRAINER_CANVAS_HEIGHT; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(AIM_TRAINER_CANVAS_WIDTH, y);
ctx.stroke();
}
const horizon = AIM_TRAINER_CANVAS_HEIGHT * 0.68;
ctx.fillStyle = "rgba(8, 13, 11, 0.38)";
ctx.fillRect(0, horizon, AIM_TRAINER_CANVAS_WIDTH, AIM_TRAINER_CANVAS_HEIGHT - horizon);
ctx.strokeStyle = "rgba(202, 193, 139, 0.2)";
ctx.setLineDash([12, 12]);
ctx.beginPath();
ctx.moveTo(0, horizon);
ctx.lineTo(AIM_TRAINER_CANVAS_WIDTH, horizon);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "rgba(201, 192, 142, 0.18)";
ctx.font = "11px ui-monospace, monospace";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText("RANGE 04 // LIVE FIRE", 18, 16);
ctx.textAlign = "right";
ctx.fillText("QUALIFICATION COURSE", AIM_TRAINER_CANVAS_WIDTH - 18, 16);
}
function drawTarget(
ctx: CanvasRenderingContext2D,
activeTarget: AimTrainerActiveTarget,
frame: AimTrainerSceneFrame,
): void {
const { target, spawnedAt } = activeTarget;
const age = Math.max(0, frame.now - spawnedAt);
const fadeDuration = Math.min(
AIM_TRAINER_TARGET_FADE_DURATION_MS,
frame.targetLifetimeMs / 5,
);
const fadeStart = frame.targetLifetimeMs - fadeDuration;
const fadeProgress =
fadeDuration > 0 ? Math.min(1, Math.max(0, (age - fadeStart) / fadeDuration)) : 0;
const pulse = 1 + Math.sin(frame.now / 150) * 0.025;
const torsoX = target.x - (target.torsoW * pulse) / 2;
ctx.save();
ctx.globalAlpha = 1 - fadeProgress;
ctx.save();
ctx.translate(target.x, target.y);
ctx.shadowColor = "rgba(0, 0, 0, 0.45)";
ctx.shadowBlur = 18;
ctx.shadowOffsetY = 8;
ctx.fillStyle = "#0d1411";
ctx.fillRect(-target.torsoW / 2 - 3, 3, target.torsoW + 6, target.torsoH + 4);
ctx.beginPath();
ctx.arc(0, -target.headRadius, target.headRadius + 3, 0, Math.PI * 2);
ctx.fill();
ctx.shadowColor = "transparent";
const torso = ctx.createLinearGradient(-target.torsoW / 2, 0, target.torsoW / 2, target.torsoH);
torso.addColorStop(0, "#9b956d");
torso.addColorStop(0.5, "#706f52");
torso.addColorStop(1, "#4d5542");
ctx.fillStyle = torso;
ctx.fillRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH);
ctx.fillStyle = "rgba(29, 38, 30, 0.42)";
ctx.fillRect(-target.torsoW / 2, target.torsoH * 0.54, target.torsoW, target.torsoH * 0.46);
ctx.fillStyle = "rgba(217, 207, 151, 0.32)";
ctx.fillRect(-target.torsoW * 0.28, target.torsoH * 0.16, target.torsoW * 0.56, 3);
ctx.fillRect(-target.torsoW * 0.28, target.torsoH * 0.42, target.torsoW * 0.56, 3);
ctx.strokeStyle = "rgba(226, 217, 166, 0.6)";
ctx.lineWidth = 1.5;
ctx.strokeRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH);
ctx.fillStyle = "#afa678";
ctx.beginPath();
ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(48, 55, 40, 0.48)";
ctx.beginPath();
ctx.arc(0, -target.headRadius - 2, target.headRadius * 0.72, Math.PI, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(226, 217, 166, 0.72)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "rgba(235, 226, 175, 0.32)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, -target.headRadius - target.headRadius - 9);
ctx.lineTo(0, -target.headRadius - target.headRadius - 1);
ctx.moveTo(-target.headRadius - 9, -target.headRadius);
ctx.lineTo(-target.headRadius - 1, -target.headRadius);
ctx.moveTo(target.headRadius + 1, -target.headRadius);
ctx.lineTo(target.headRadius + 9, -target.headRadius);
ctx.stroke();
ctx.restore();
ctx.strokeStyle = "rgba(211, 202, 149, 0.12)";
ctx.setLineDash([4, 5]);
ctx.strokeRect(torsoX, target.y, target.torsoW * pulse, target.torsoH);
ctx.setLineDash([]);
ctx.restore();
}
function drawFeedback(
ctx: CanvasRenderingContext2D,
feedback: readonly AimTrainerPointFeedback[],
now: number,
): void {
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "bold 18px ui-monospace, monospace";
for (const item of feedback) {
const age = now - item.createdAt;
const progress = Math.min(1, age / AIM_TRAINER_FEEDBACK_DURATION_MS);
ctx.globalAlpha = 1 - progress;
ctx.fillStyle = item.color;
ctx.fillText(item.text, item.x, item.y - progress * 28);
}
ctx.globalAlpha = 1;
}
export function drawAimTrainerScene(
ctx: CanvasRenderingContext2D,
game: AimTrainerGameState,
frame: AimTrainerSceneFrame,
): void {
drawRange(ctx);
for (const activeTarget of game.targets) {
drawTarget(ctx, activeTarget, frame);
}
drawFeedback(ctx, game.feedback, frame.now);
if (game.flashUntil > frame.now) {
ctx.fillStyle = `${game.flashColor}${Math.round(
((game.flashUntil - frame.now) / AIM_TRAINER_FLASH_DURATION_MS) * 28,
)
.toString(16)
.padStart(2, "0")}`;
ctx.fillRect(0, 0, AIM_TRAINER_CANVAS_WIDTH, AIM_TRAINER_CANVAS_HEIGHT);
}
}

View file

@ -0,0 +1,664 @@
"use client";
// allow: SIZE_OK — the course state machine keeps its rAF loop and submission lifecycle together.
import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
import { useRouter } from "next/navigation";
import { Crosshair, RotateCcw, Target, Timer, Trophy } from "lucide-react";
import {
AIM_TRAINER_DIFFICULTIES,
calculateAimTrainerAccuracy,
calculateAimTrainerScore,
COURSE_DURATION_MS,
HEADSHOT_POINTS,
createAimTrainerTarget,
resolveAimTrainerTargetHit,
stepAimTrainerTarget,
TORSO_POINTS,
type AimTrainerDifficulty,
} from "@/lib/aim-trainer";
import {
type AimTrainerResultInput,
type AimTrainerStats,
submitAimTrainerResult,
} from "@/app/(frontend)/qualification/actions";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { cn } from "@/lib/utils";
import {
AIM_TRAINER_CANVAS_HEIGHT as CANVAS_H,
AIM_TRAINER_CANVAS_WIDTH as CANVAS_W,
AIM_TRAINER_FEEDBACK_DURATION_MS as FEEDBACK_DURATION_MS,
AIM_TRAINER_FLASH_DURATION_MS as FLASH_DURATION_MS,
createAimTrainerGameState,
drawAimTrainerScene,
emptyAimTrainerLiveStats,
getAimTrainerLiveStats,
type AimTrainerActiveTarget,
type AimTrainerGameState,
type AimTrainerLiveStats,
} from "./AimTrainerCanvas";
import {
playCountdownTick,
playFire,
playHeadshot,
playMiss,
playRoundComplete,
playTorsoHit,
playXp,
} from "./sounds";
const FINAL_COUNTDOWN_WINDOW_MS = 10_000;
const CRITICAL_COUNTDOWN_WINDOW_MS = 3_000;
const MAX_FRAME_DT_SEC = 0.05;
const COURSE_DURATION_SECONDS = COURSE_DURATION_MS / 1000;
const TIER_META = {
recruit: {
label: "Recruit",
description: "Stationary silhouettes with a forgiving acquisition window.",
},
veteran: {
label: "Veteran",
description: "A mixed range with faster movement and tighter hit zones.",
},
elite: {
label: "Elite",
description: "High-speed targets. Every sight picture matters.",
},
} as const satisfies Record<
AimTrainerDifficulty,
{ readonly label: string; readonly description: string }
>;
const TIER_SCORE_KEYS = {
recruit: "recruitBestScore",
veteran: "veteranBestScore",
elite: "eliteBestScore",
} as const satisfies Record<
AimTrainerDifficulty,
"recruitBestScore" | "veteranBestScore" | "eliteBestScore"
>;
export type AimTrainerPhase = "ready" | "playing" | "over";
type LastRun = {
readonly hitScore: number;
readonly accuracyBonus: number;
readonly totalScore: number;
readonly accuracy: number;
readonly headshots: number;
readonly torsoHits: number;
readonly shotsFired: number;
readonly despawnedUnhitTargets: number;
readonly xpEarned: number;
readonly bestScore: number;
readonly isNewRecord: boolean;
readonly error?: string;
};
function assertNever(value: never): never {
throw new Error(`Unexpected hit zone: ${String(value)}`);
}
function tierBestScore(stats: AimTrainerStats, difficulty: AimTrainerDifficulty): number {
return stats[TIER_SCORE_KEYS[difficulty]];
}
function formatAccuracy(value: number): string {
return `${Math.round(value * 100)}%`;
}
function HudMetric({
label,
value,
urgent = false,
}: {
label: string;
value: string | number;
urgent?: boolean;
}) {
return (
<div
className={cn(
"flex min-w-0 flex-col gap-1 rounded-md border border-border bg-background/70 px-3 py-2",
urgent && "border-destructive/50 bg-destructive/10",
)}
>
<span className="truncate text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</span>
<span
className={cn(
"font-mono text-base font-semibold tabular-nums",
urgent && "text-destructive",
)}
>
{value}
</span>
</div>
);
}
function SummaryMetric({ label, value }: { label: string; value: string | number }) {
return (
<div className="flex flex-col gap-0.5 rounded-md border border-border bg-background/60 px-3 py-2">
<dt className="text-xs uppercase tracking-wider text-muted-foreground">{label}</dt>
<dd className="font-mono text-sm font-semibold tabular-nums">{value}</dd>
</div>
);
}
export function AimTrainerGame({
initialStats: serverStats,
difficulty,
onGameRecordedAction,
onPhaseChangeAction,
}: {
readonly initialStats: AimTrainerStats;
readonly difficulty: AimTrainerDifficulty;
readonly onGameRecordedAction?: (stats: AimTrainerStats) => void;
readonly onPhaseChangeAction?: (phase: AimTrainerPhase) => void;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const phaseRef = useRef<AimTrainerPhase>("ready");
const submittingRef = useRef(false);
const statsRef = useRef<AimTrainerStats>(serverStats);
const gameRef = useRef<AimTrainerGameState>(createAimTrainerGameState());
const [phase, setPhase] = useState<AimTrainerPhase>("ready");
const [timerSeconds, setTimerSeconds] = useState(COURSE_DURATION_SECONDS);
const [live, setLive] = useState<AimTrainerLiveStats>(emptyAimTrainerLiveStats);
const [stats, setStats] = useState<AimTrainerStats>(serverStats);
const [lastRun, setLastRun] = useState<LastRun | null>(null);
const [submitting, setSubmitting] = useState(false);
const tier = TIER_META[difficulty];
const config = AIM_TRAINER_DIFFICULTIES[difficulty];
const setPhaseSafe = useCallback(
(nextPhase: AimTrainerPhase) => {
phaseRef.current = nextPhase;
setPhase(nextPhase);
onPhaseChangeAction?.(nextPhase);
},
[onPhaseChangeAction],
);
const handleRoundComplete = useCallback(
async (round: AimTrainerResultInput) => {
if (submittingRef.current) return;
const hits = round.headshots + round.torsoHits;
const accuracy = calculateAimTrainerAccuracy(
hits,
round.shotsFired,
round.despawnedUnhitTargets,
);
const totalScore = calculateAimTrainerScore(
round.headshots,
round.torsoHits,
round.shotsFired,
round.despawnedUnhitTargets,
);
const hitScore = round.headshots * HEADSHOT_POINTS + round.torsoHits * TORSO_POINTS;
const previousTierBest = tierBestScore(statsRef.current, difficulty);
const preview: LastRun = {
hitScore,
accuracyBonus: Math.max(0, totalScore - hitScore),
totalScore,
accuracy,
headshots: round.headshots,
torsoHits: round.torsoHits,
shotsFired: round.shotsFired,
despawnedUnhitTargets: round.despawnedUnhitTargets,
xpEarned: 0,
bestScore: previousTierBest,
isNewRecord: false,
};
setLastRun(preview);
if (expiredRef.current) {
setLastRun({ ...preview, error: "Your session expired. Result not recorded." });
return;
}
submittingRef.current = true;
setSubmitting(true);
try {
const result = await submitAimTrainerResult(round);
if (result.success && result.data) {
statsRef.current = result.data;
setStats(result.data);
onGameRecordedAction?.(result.data);
if (result.data.xpEarned > 0) {
playXp();
window.dispatchEvent(
new CustomEvent(PROFILE_UPDATE_EVENT, {
detail: { reason: "xp", xpGained: result.data.xpEarned },
}),
);
window.setTimeout(() => router.refresh(), 1700);
}
setLastRun({
...preview,
accuracy: result.data.accuracy,
accuracyBonus: Math.max(0, result.data.score - hitScore),
totalScore: result.data.score,
xpEarned: result.data.xpEarned,
bestScore: tierBestScore(result.data, difficulty),
isNewRecord: result.data.score > previousTierBest,
});
} else {
setLastRun({ ...preview, error: result.error ?? "Could not record qualification." });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Could not record qualification.";
setLastRun({ ...preview, error: message });
} finally {
submittingRef.current = false;
setSubmitting(false);
}
},
[difficulty, expiredRef, onGameRecordedAction, router],
);
const handleRoundCompleteRef = useRef(handleRoundComplete);
useEffect(() => {
handleRoundCompleteRef.current = handleRoundComplete;
}, [handleRoundComplete]);
const resetToReady = useCallback(() => {
if (expiredRef.current || submittingRef.current) return;
gameRef.current = createAimTrainerGameState();
setLive(emptyAimTrainerLiveStats());
setTimerSeconds(COURSE_DURATION_SECONDS);
setLastRun(null);
setPhaseSafe("ready");
}, [expiredRef, setPhaseSafe]);
const startGame = useCallback(() => {
if (expiredRef.current || submittingRef.current) return;
const now = performance.now();
const game = createAimTrainerGameState();
game.pendingSpawnAts = Array.from(
{ length: config.maxConcurrentTargets },
(_, index) => now + index * config.replacementDelayMs,
);
game.startedAt = now;
gameRef.current = game;
setLive(emptyAimTrainerLiveStats());
setTimerSeconds(COURSE_DURATION_SECONDS);
setLastRun(null);
setPhaseSafe("playing");
wrapperRef.current?.focus({ preventScroll: true });
}, [config.maxConcurrentTargets, config.replacementDelayMs, expiredRef, setPhaseSafe]);
const handlePrimaryAction = useCallback(() => {
const currentPhase = phaseRef.current;
switch (currentPhase) {
case "ready":
startGame();
return;
case "playing":
return;
case "over":
resetToReady();
return;
default:
assertNever(currentPhase);
}
}, [resetToReady, startGame]);
const handleShot = useCallback(
(event: PointerEvent<HTMLCanvasElement>) => {
if (event.button !== 0 || phaseRef.current !== "playing") return;
if (expiredRef.current || submittingRef.current) return;
event.preventDefault();
wrapperRef.current?.focus({ preventScroll: true });
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * CANVAS_W;
const y = ((event.clientY - rect.top) / rect.height) * CANVAS_H;
const game = gameRef.current;
const now = performance.now();
game.shotsFired += 1;
playFire();
const hit = resolveAimTrainerTargetHit(
game.targets.map((activeTarget) => activeTarget.target),
x,
y,
);
if (!hit) {
playMiss();
game.feedback.push({ x, y, text: "MISS", color: "#b8b49c", createdAt: now });
setLive(getAimTrainerLiveStats(game));
return;
}
const [activeTarget] = game.targets.splice(hit.index, 1);
if (!activeTarget) return;
const target = activeTarget.target;
game.pendingSpawnAts.push(now + config.replacementDelayMs);
switch (hit.zone) {
case "head":
game.headshots += 1;
game.feedback.push({
x: target.x,
y: target.y - target.headRadius,
text: `+${HEADSHOT_POINTS}`,
color: "#f0e6a8",
createdAt: now,
});
game.flashColor = "#e5d98a";
game.flashUntil = now + FLASH_DURATION_MS;
playHeadshot();
break;
case "torso":
game.torsoHits += 1;
game.feedback.push({
x: target.x,
y: target.y + target.torsoH * 0.45,
text: `+${TORSO_POINTS}`,
color: "#c8c59d",
createdAt: now,
});
game.flashColor = "#aaa878";
game.flashUntil = now + FLASH_DURATION_MS;
playTorsoHit();
break;
default:
assertNever(hit.zone);
}
setLive(getAimTrainerLiveStats(game));
},
[config.replacementDelayMs, expiredRef],
);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = CANVAS_W * dpr;
canvas.height = CANVAS_H * dpr;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
let raf = 0;
let lastTime = performance.now();
const finish = () => {
const game = gameRef.current;
if (phaseRef.current !== "playing") return;
game.targets = [];
game.pendingSpawnAts = [];
setTimerSeconds(0);
setLive(getAimTrainerLiveStats(game));
setPhaseSafe("over");
playRoundComplete();
void handleRoundCompleteRef.current({
difficulty,
headshots: game.headshots,
torsoHits: game.torsoHits,
shotsFired: game.shotsFired,
despawnedUnhitTargets: game.despawnedUnhitTargets,
});
};
const step = (now: number) => {
raf = requestAnimationFrame(step);
const dtSec = Math.min(Math.max((now - lastTime) / 1000, 0), MAX_FRAME_DT_SEC);
lastTime = now;
const game = gameRef.current;
if (phaseRef.current === "playing") {
const elapsed = now - game.startedAt;
const remaining = Math.max(0, COURSE_DURATION_MS - elapsed);
if (remaining === 0) {
finish();
} else {
const displayedSecond =
remaining <= FINAL_COUNTDOWN_WINDOW_MS
? Math.ceil(remaining / 1000)
: COURSE_DURATION_SECONDS;
if (displayedSecond !== game.lastDisplayedSecond) {
game.lastDisplayedSecond = displayedSecond;
setTimerSeconds(displayedSecond);
if (remaining <= CRITICAL_COUNTDOWN_WINDOW_MS) playCountdownTick(true);
else if (remaining <= FINAL_COUNTDOWN_WINDOW_MS) playCountdownTick();
}
const survivingTargets: AimTrainerActiveTarget[] = [];
let expiredTargets = 0;
for (const activeTarget of game.targets) {
if (now - activeTarget.spawnedAt >= config.targetLifetimeMs) {
expiredTargets += 1;
game.pendingSpawnAts.push(now + config.replacementDelayMs);
continue;
}
survivingTargets.push({
target: stepAimTrainerTarget(activeTarget.target, dtSec, CANVAS_W, CANVAS_H),
spawnedAt: activeTarget.spawnedAt,
});
}
game.targets = survivingTargets;
if (expiredTargets > 0) {
game.despawnedUnhitTargets += expiredTargets;
setLive(getAimTrainerLiveStats(game));
}
while (game.targets.length < config.maxConcurrentTargets) {
const dueIndex = game.pendingSpawnAts.findIndex((spawnAt) => spawnAt <= now);
if (dueIndex === -1) break;
game.pendingSpawnAts.splice(dueIndex, 1);
game.targets.push({
target: createAimTrainerTarget(Math.random, CANVAS_W, CANVAS_H, difficulty),
spawnedAt: now,
});
}
}
}
game.feedback = game.feedback.filter((item) => now - item.createdAt < FEEDBACK_DURATION_MS);
drawAimTrainerScene(ctx, game, {
now,
targetLifetimeMs: config.targetLifetimeMs,
});
};
raf = requestAnimationFrame(step);
const onKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Enter" && event.code !== "Space") return;
if (event.repeat) return;
event.preventDefault();
event.stopPropagation();
handlePrimaryAction();
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.code !== "Space") return;
event.preventDefault();
event.stopPropagation();
};
window.addEventListener("keydown", onKeyDown, { capture: true });
window.addEventListener("keyup", onKeyUp, { capture: true });
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("keydown", onKeyDown, { capture: true });
window.removeEventListener("keyup", onKeyUp, { capture: true });
};
}, [
config.replacementDelayMs,
config.maxConcurrentTargets,
config.targetLifetimeMs,
difficulty,
handlePrimaryAction,
setPhaseSafe,
]);
const timerIsCritical = phase === "playing" && timerSeconds <= 3;
const timerLabel = phase === "ready" ? `${COURSE_DURATION_SECONDS}s` : `${timerSeconds}s`;
return (
<Card className="w-full max-w-3xl overflow-hidden">
<CardHeader className="gap-3 border-b border-border">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="rounded-md border border-border bg-background p-2 text-primary">
<Crosshair aria-hidden="true" className="size-5" />
</div>
<div className="flex flex-col gap-1">
<CardTitle>Live-fire qualification</CardTitle>
<CardDescription>{tier.description}</CardDescription>
</div>
</div>
<Badge variant={timerIsCritical ? "destructive" : "outline"}>
<Timer data-icon="inline-start" />
{timerLabel}
</Badge>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5" aria-live="polite">
<HudMetric label="Score" value={live.score} />
<HudMetric label="Accuracy" value={formatAccuracy(live.accuracy)} />
<HudMetric label="Shots" value={live.shotsFired} />
<HudMetric label="Tier" value={tier.label} />
<HudMetric label="Time" value={timerLabel} urgent={timerIsCritical} />
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-4">
<div
ref={wrapperRef}
tabIndex={-1}
className="relative mx-auto w-full cursor-crosshair select-none overflow-hidden rounded-lg border border-border bg-background outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<canvas
ref={canvasRef}
width={CANVAS_W}
height={CANVAS_H}
onPointerDown={handleShot}
className="block h-auto w-full"
aria-label="Qualification Course target range"
/>
{phase === "ready" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/75 p-4 text-center">
<Badge variant="outline">{tier.label} lane</Badge>
<div className="flex flex-col gap-1">
<span className="text-lg font-semibold tracking-wide text-foreground">
Ready on the line
</span>
<span className="text-sm text-muted-foreground">
Acquire every silhouette before it clears the range.
</span>
</div>
<Button
type="button"
className="motion-reduce:transition-none"
onClick={startGame}
disabled={expired || submitting}
>
<Target data-icon="inline-start" />
Begin Qualification
</Button>
<span className="text-xs text-muted-foreground">
Click the target or press Enter / Space to begin. Course duration:{" "}
{COURSE_DURATION_SECONDS}s.
</span>
</div>
)}
{phase === "over" && lastRun && (
<div className="absolute inset-0 flex flex-col justify-center gap-3 overflow-y-auto bg-background/90 p-4 text-center">
<div className="flex items-center justify-center gap-2">
<Trophy aria-hidden="true" className="size-5 text-primary" />
<span className="text-lg font-semibold tracking-wide">Course complete</span>
</div>
{lastRun.isNewRecord && <Badge className="mx-auto">New record!</Badge>}
<dl className="mx-auto grid w-full max-w-xl grid-cols-2 gap-2 text-left sm:grid-cols-3">
<SummaryMetric label="Hit score" value={lastRun.hitScore} />
<SummaryMetric label="Accuracy bonus" value={`+${lastRun.accuracyBonus}`} />
<SummaryMetric label="Total score" value={lastRun.totalScore} />
<SummaryMetric label="Accuracy" value={formatAccuracy(lastRun.accuracy)} />
<SummaryMetric label="Headshots" value={lastRun.headshots} />
<SummaryMetric label="Torso hits" value={lastRun.torsoHits} />
</dl>
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{lastRun.shotsFired} shots fired</span>
<span>{lastRun.despawnedUnhitTargets} targets expired</span>
<span className="font-semibold text-primary">+{lastRun.xpEarned} XP</span>
</div>
{lastRun.error && <span className="text-xs text-destructive">{lastRun.error}</span>}
{submitting && (
<span className="text-xs text-muted-foreground">Recording result…</span>
)}
<Button
type="button"
variant="outline"
className="mx-auto motion-reduce:transition-none"
onClick={resetToReady}
disabled={submitting || expired}
>
<RotateCcw data-icon="inline-start" />
Play Again
</Button>
<span className="text-xs text-muted-foreground">
Press Enter or Space to return to the line.
</span>
</div>
)}
{expired && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 p-4 text-center">
<span className="text-xl font-semibold tracking-wide text-destructive">
Session ending soon
</span>
<span className="max-w-md text-sm text-muted-foreground">
Your result will not be recorded after the session expires. Log in again to
continue.
</span>
<LoginLink>
<Button type="button" size="sm" className="motion-reduce:transition-none">
Log in
</Button>
</LoginLink>
</div>
)}
</div>
<div className="flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<span>Games</span>
<span className="font-semibold text-foreground">{stats.gamesPlayed}</span>
</div>
<div className="flex items-center gap-1.5">
<span>Best score</span>
<span className="font-semibold text-foreground">{stats.bestScore}</span>
</div>
<div className="flex items-center gap-1.5">
<span>{tier.label} best</span>
<span className="font-semibold text-foreground">
{tierBestScore(stats, difficulty)}
</span>
</div>
<div className="flex items-center gap-1.5">
<span>XP from the course</span>
<span className="font-semibold text-foreground">{stats.aimTrainerXp}</span>
</div>
</div>
<div className="text-xs text-muted-foreground">
Headshots score {HEADSHOT_POINTS}. Torso hits score {TORSO_POINTS}.
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,220 @@
"use client";
import { useEffect, useState } from "react";
import { Trophy } from "lucide-react";
import type { AimTrainerDifficulty } from "@/lib/aim-trainer";
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 {
AIM_TRAINER_LEADERBOARD_FIELDS,
readAimTrainerProfiles,
} from "./aimTrainerLeaderboardData";
type LeaderboardRow = {
readonly id: number;
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-primary";
if (index === 1) return "font-semibold text-foreground";
if (index === 2) return "font-semibold text-muted-foreground";
return "text-muted-foreground";
}
export function AimTrainerLeaderboard({
refreshKey = 0,
difficulty,
currentUserId,
activeTrainees = [],
}: {
readonly refreshKey?: number;
readonly difficulty: AimTrainerDifficulty;
readonly currentUserId?: number | string;
readonly activeTrainees?: readonly PresenceParticipant[];
}) {
const [rows, setRows] = useState<readonly LeaderboardRow[] | null>(null);
const [failed, setFailed] = useState<string | null>(null);
const tier = AIM_TRAINER_LEADERBOARD_FIELDS[difficulty];
useEffect(() => {
let cancelled = false;
setFailed(null);
setRows(null);
const field = `progression.minigames.aimTrainer.${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 readAimTrainerProfiles(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 aimTrainer = profile.aimTrainer;
return {
id: profile.id,
name,
gamesPlayed: aimTrainer?.gamesPlayed ?? 0,
bestScore: aimTrainer?.[tier.scoreField] ?? 0,
bestAccuracy: aimTrainer?.[tier.accuracyField] ?? 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.accuracyField, 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-primary" />
<CardTitle className="text-sm">Qualification leaderboard</CardTitle>
<span className="ml-auto text-xs text-muted-foreground">{tier.label} · Top 10</span>
</div>
<CardDescription>Best score for the selected qualification lane.</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-primary">{activeTrainees.length}</span>{" "}
{activeTrainees.length === 1 ? "trainee is" : "trainees are"} on the line
{activeTrainees.length > 0 && (
<span className="ml-1 text-foreground">
({activeTrainees.map((trainee) => trainee.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 lane yet — take the first qualification slot.
</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
aria-sort="descending"
className="px-2 py-2 text-right font-medium text-foreground"
>
Score
</th>
<th className="px-4 py-2 text-right font-medium">Accuracy</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>
)}
{activeTrainees.some((trainee) => trainee.id === row.id) && (
<span
className="size-2 rounded-full bg-primary"
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 font-semibold tabular-nums">
{row.bestScore}
</td>
<td className="px-4 py-2 text-right font-mono font-semibold tabular-nums">
{Math.round(row.bestAccuracy * 100)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,110 @@
import type { AimTrainerDifficulty } from "@/lib/aim-trainer";
export type AimTrainerLeaderboardFields = {
readonly label: string;
readonly scoreField: keyof AimTrainerApiStats;
readonly accuracyField: keyof AimTrainerApiStats;
};
export type AimTrainerApiStats = {
readonly gamesPlayed: number | null;
readonly recruitBestScore: number | null;
readonly veteranBestScore: number | null;
readonly eliteBestScore: number | null;
readonly recruitBestAccuracy: number | null;
readonly veteranBestAccuracy: number | null;
readonly eliteBestAccuracy: 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 aimTrainer: AimTrainerApiStats | null;
};
export const AIM_TRAINER_LEADERBOARD_FIELDS = {
recruit: {
label: "Recruit",
scoreField: "recruitBestScore",
accuracyField: "recruitBestAccuracy",
},
veteran: {
label: "Veteran",
scoreField: "veteranBestScore",
accuracyField: "veteranBestAccuracy",
},
elite: {
label: "Elite",
scoreField: "eliteBestScore",
accuracyField: "eliteBestAccuracy",
},
} as const satisfies Record<AimTrainerDifficulty, AimTrainerLeaderboardFields>;
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 readAimTrainer(value: unknown): AimTrainerApiStats | null {
if (!isRecord(value)) return null;
return {
gamesPlayed: readNumber(value.gamesPlayed),
recruitBestScore: readNumber(value.recruitBestScore),
veteranBestScore: readNumber(value.veteranBestScore),
eliteBestScore: readNumber(value.eliteBestScore),
recruitBestAccuracy: readNumber(value.recruitBestAccuracy),
veteranBestAccuracy: readNumber(value.veteranBestAccuracy),
eliteBestAccuracy: readNumber(value.eliteBestAccuracy),
};
}
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),
aimTrainer: minigames ? readAimTrainer(minigames.aimTrainer) : null,
};
}
export function readAimTrainerProfiles(value: unknown): readonly ApiProfile[] {
if (!isRecord(value) || !Array.isArray(value.docs)) return [];
return value.docs.flatMap((item) => {
const profile = readProfile(item);
return profile ? [profile] : [];
});
}

View file

@ -0,0 +1,145 @@
declare global {
interface Window {
webkitAudioContext?: typeof AudioContext;
}
}
let audioCtx: AudioContext | null = null;
function getCtx(): AudioContext | null {
if (typeof window === "undefined") return null;
const AudioCtor = window.AudioContext ?? window.webkitAudioContext;
if (!AudioCtor) return null;
if (!audioCtx) audioCtx = new AudioCtor();
if (audioCtx.state === "suspended") void audioCtx.resume();
return audioCtx;
}
type ToneOptions = {
readonly frequency: number;
readonly endFrequency?: number;
readonly duration: number;
readonly type?: OscillatorType;
readonly volume?: number;
readonly delay?: number;
};
function playTone(ctx: AudioContext, options: ToneOptions): void {
const { frequency, endFrequency, duration, type = "sine", volume = 0.2, delay = 0 } = options;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
const startAt = ctx.currentTime + delay;
oscillator.type = type;
oscillator.frequency.setValueAtTime(Math.max(frequency, 1), startAt);
if (endFrequency !== undefined && endFrequency !== frequency) {
oscillator.frequency.exponentialRampToValueAtTime(
Math.max(endFrequency, 1),
startAt + duration,
);
}
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(volume, startAt + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.start(startAt);
oscillator.stop(startAt + duration + 0.05);
}
function playNoise(
ctx: AudioContext,
options: { readonly duration: number; readonly volume?: number; readonly lowpass?: number },
): void {
const { duration, volume = 0.2, lowpass = 800 } = options;
const bufferSize = Math.max(1, Math.floor(ctx.sampleRate * duration));
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let index = 0; index < bufferSize; index += 1) {
data[index] = Math.random() * 2 - 1;
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.setValueAtTime(lowpass, ctx.currentTime);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(volume, ctx.currentTime + 0.005);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
source.start();
}
export function playFire(): void {
const ctx = getCtx();
if (!ctx) return;
playTone(ctx, {
frequency: 180,
endFrequency: 90,
duration: 0.055,
type: "square",
volume: 0.12,
});
}
export function playHeadshot(): void {
const ctx = getCtx();
if (!ctx) return;
playTone(ctx, {
frequency: 720,
endFrequency: 1180,
duration: 0.12,
type: "triangle",
volume: 0.16,
});
playTone(ctx, { frequency: 1040, duration: 0.16, type: "sine", volume: 0.12, delay: 0.06 });
}
export function playTorsoHit(): void {
const ctx = getCtx();
if (!ctx) return;
playTone(ctx, {
frequency: 360,
endFrequency: 220,
duration: 0.1,
type: "triangle",
volume: 0.15,
});
}
export function playMiss(): void {
const ctx = getCtx();
if (!ctx) return;
playTone(ctx, {
frequency: 120,
endFrequency: 55,
duration: 0.18,
type: "sawtooth",
volume: 0.14,
});
playNoise(ctx, { duration: 0.11, volume: 0.11, lowpass: 480 });
}
export function playCountdownTick(critical = false): void {
const ctx = getCtx();
if (!ctx) return;
const frequency = critical ? 880 : 520;
playTone(ctx, { frequency, duration: critical ? 0.09 : 0.06, type: "square", volume: 0.1 });
}
export function playRoundComplete(): void {
const ctx = getCtx();
if (!ctx) return;
playTone(ctx, { frequency: 420, duration: 0.12, type: "triangle", volume: 0.14 });
playTone(ctx, { frequency: 630, duration: 0.12, type: "triangle", volume: 0.14, delay: 0.1 });
playTone(ctx, { frequency: 940, duration: 0.24, type: "sine", volume: 0.16, delay: 0.2 });
}
export function playXp(): void {
const ctx = getCtx();
if (!ctx) return;
playTone(ctx, { frequency: 660, duration: 0.09, type: "sine", volume: 0.18 });
playTone(ctx, { frequency: 880, duration: 0.16, type: "sine", volume: 0.18, delay: 0.09 });
}

View file

@ -21,7 +21,7 @@ const MESSAGE_SLOTS = [
{
name: "LOCATION",
separator: " ",
options: ["GRID 341-512", "GRID BRAVO ONE SIX", "SECTOR K-7", "CHECKPOINT ALPHA", "CHECKPOINT CHARLIE", "OVERWATCH POINT", "NORTH RIDGELINE", "OBJECTIVE DELTA", "SUPPLY ROAD", "EASTERN APPROACH", "SOUTH TRENCHLINE", "SITE 19", "FOB LIMA", "EGRESS", "WATCHPOST", "MAIN GATE", "HELI PAD", "BRIDGE CROSSING", "WESTERN VALLEY", "HILL 402", "RIVERBEND", "OLD MINE ROAD", "STAGING AREA", "PERIMETER LIMA"],
options: ["GRID 341-512", "GRID BRAVO ONE SIX", "SECTOR K-7", "CHECKPOINT ALPHA", "CHECKPOINT CHARLIE", "OVERWATCH POINT", "NORTH RIDGELINE", "OBJECTIVE DELTA", "SUPPLY ROAD", "EASTERN APPROACH", "SOUTH TRENCHLINE", "SITE 19", "FOB LIMA", "EGRESS", "WATCHPOST", "MAIN GATE", "HELIPAD", "BRIDGE CROSSING", "WESTERN VALLEY", "HILL 402", "RIVERBEND", "OLD MINE ROAD", "STAGING AREA", "PERIMETER LIMA"],
},
{
name: "STATUS",

251
src/lib/aim-trainer.ts Normal file
View file

@ -0,0 +1,251 @@
/**
* Aim Trainer (Qualification Course) — deterministic, pure game-rules module.
*
* Framework-free and client-safe: no React, Next.js, or Payload imports, no
* side effects. All randomness is injected via an `rng` callback so every
* function is reproducible and unit-testable.
*/
export type AimTrainerDifficulty = "recruit" | "veteran" | "elite";
export interface AimTrainerTierConfig {
readonly torsoW: number;
readonly torsoH: number;
readonly headRadius: number;
readonly movingRatio: number;
readonly speed: number;
readonly maxConcurrentTargets: number;
readonly targetLifetimeMs: number;
readonly replacementDelayMs: number;
}
export const AIM_TRAINER_DIFFICULTIES: Readonly<
Record<AimTrainerDifficulty, AimTrainerTierConfig>
> = {
recruit: {
torsoW: 76,
torsoH: 118,
headRadius: 21,
movingRatio: 0.25,
speed: 80,
maxConcurrentTargets: 1,
targetLifetimeMs: 2000,
replacementDelayMs: 400,
},
veteran: {
torsoW: 62,
torsoH: 100,
headRadius: 18,
movingRatio: 0.55,
speed: 140,
maxConcurrentTargets: 2,
targetLifetimeMs: 1500,
replacementDelayMs: 300,
},
elite: {
torsoW: 50,
torsoH: 82,
headRadius: 15,
movingRatio: 0.8,
speed: 220,
maxConcurrentTargets: 2,
targetLifetimeMs: 1100,
replacementDelayMs: 200,
},
};
export const COURSE_DURATION_MS = 30_000;
export const TORSO_POINTS = 100;
export const HEADSHOT_POINTS = 250;
export const ACCURACY_BONUS_MAX = 500;
export const POINTS_PER_XP = 500;
/** XP awarded per full POINTS_PER_XP score block — one XP per 4,000 score. */
export const SCORE_XP_RATE = 0.125;
export const COMPLETION_XP = 1;
export const NEW_TIER_RECORD_XP = 2;
export const XP_CAP = 50;
export const MAX_SHOTS_FIRED = 500;
export const MAX_HITS = 180;
export const MAX_DESPAWNED_TARGETS = 60;
const DIFFICULTY_VALUES: readonly AimTrainerDifficulty[] = ["recruit", "veteran", "elite"];
export function isAimTrainerDifficulty(value: unknown): value is AimTrainerDifficulty {
return (
typeof value === "string" &&
(DIFFICULTY_VALUES as readonly string[]).includes(value)
);
}
export function calculateAimTrainerAccuracy(
hits: number,
shotsFired: number,
despawnedUnhitTargets: number,
): number {
const denominator = shotsFired + despawnedUnhitTargets;
if (denominator === 0) return 0;
return Math.min(1, Math.max(0, hits / denominator));
}
export function calculateAimTrainerScore(
headshots: number,
torsoHits: number,
shotsFired: number,
despawnedUnhitTargets: number,
): number {
const accuracy = calculateAimTrainerAccuracy(headshots + torsoHits, shotsFired, despawnedUnhitTargets);
return (
headshots * HEADSHOT_POINTS +
torsoHits * TORSO_POINTS +
Math.round(accuracy * ACCURACY_BONUS_MAX)
);
}
export function calculateAimTrainerXp(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);
}
export interface AimTrainerTarget {
readonly x: number;
readonly y: number;
readonly vx: number;
readonly vy: number;
readonly moving: boolean;
readonly torsoW: number;
readonly torsoH: number;
readonly headRadius: number;
}
export interface AimTrainerTargetHit {
readonly index: number;
readonly zone: "head" | "torso";
}
/**
* Create a target whose whole body (torso rectangle plus the circular head
* sitting above it) stays inside the canvas. The head circle is centered at
* `(x, y - headRadius)` and the torso spans `[y, y + torsoH]`, so the target's
* vertical extent is `[y - 2*headRadius, y + torsoH]`.
*/
export function createAimTrainerTarget(
rng: () => number,
canvasW: number,
canvasH: number,
difficulty: AimTrainerDifficulty,
): AimTrainerTarget {
const config = AIM_TRAINER_DIFFICULTIES[difficulty];
const minX = config.torsoW / 2;
const maxX = canvasW - config.torsoW / 2;
const minY = 2 * config.headRadius;
const maxY = canvasH - config.torsoH;
const x = minX + rng() * (maxX - minX);
const y = minY + rng() * (maxY - minY);
const moving = rng() < config.movingRatio;
if (!moving) {
return { x, y, vx: 0, vy: 0, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius };
}
const angle = rng() * 2 * Math.PI;
const vx = Math.cos(angle) * config.speed;
const vy = Math.sin(angle) * config.speed;
return { x, y, vx, vy, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius };
}
/**
* Advance a target by `dt` seconds, bouncing it off the playable bounds so the
* whole body stays inside the canvas. Pure: returns a new target, never mutates
* the input.
*/
export function stepAimTrainerTarget(
target: AimTrainerTarget,
dt: number,
canvasW: number,
canvasH: number,
): AimTrainerTarget {
if (!target.moving) return target;
let x = target.x + target.vx * dt;
let y = target.y + target.vy * dt;
let vx = target.vx;
let vy = target.vy;
const minX = target.torsoW / 2;
const maxX = canvasW - target.torsoW / 2;
const minY = 2 * target.headRadius;
const maxY = canvasH - target.torsoH;
if (x < minX) {
x = minX;
vx = -vx;
} else if (x > maxX) {
x = maxX;
vx = -vx;
}
if (y < minY) {
y = minY;
vy = -vy;
} else if (y > maxY) {
y = maxY;
vy = -vy;
}
return { ...target, x, y, vx, vy };
}
/**
* Resolve a click at `(x, y)` against a target. The circular head zone is
* checked first, then the torso rectangle. Returns `null` for a miss.
*/
export function resolveAimTrainerHitZone(
target: AimTrainerTarget,
x: number,
y: number,
): "head" | "torso" | null {
const headCx = target.x;
const headCy = target.y - target.headRadius;
const dx = x - headCx;
const dy = y - headCy;
if (dx * dx + dy * dy <= target.headRadius * target.headRadius) {
return "head";
}
const halfW = target.torsoW / 2;
if (
x >= target.x - halfW &&
x <= target.x + halfW &&
y >= target.y &&
y <= target.y + target.torsoH
) {
return "torso";
}
return null;
}
/**
* Resolve a click against the topmost matching target. Later targets are drawn
* above earlier targets, so overlapping targets are checked from last to first.
*/
export function resolveAimTrainerTargetHit(
targets: readonly AimTrainerTarget[],
x: number,
y: number,
): AimTrainerTargetHit | null {
for (let targetIndex = targets.length - 1; targetIndex >= 0; targetIndex -= 1) {
const target = targets[targetIndex];
if (!target) continue;
const zone = resolveAimTrainerHitZone(target, x, y);
if (zone) return { index: targetIndex, zone };
}
return null;
}

View file

@ -0,0 +1,25 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
const columns = [
'progression_minigames_aim_trainer_games_played',
'progression_minigames_aim_trainer_best_score',
'progression_minigames_aim_trainer_recruit_best_score',
'progression_minigames_aim_trainer_veteran_best_score',
'progression_minigames_aim_trainer_elite_best_score',
'progression_minigames_aim_trainer_recruit_best_accuracy',
'progression_minigames_aim_trainer_veteran_best_accuracy',
'progression_minigames_aim_trainer_elite_best_accuracy',
'progression_minigames_aim_trainer_xp_earned',
] as const
export async function up({ db }: MigrateUpArgs): Promise<void> {
for (const column of columns) {
await db.execute(sql.raw(`ALTER TABLE "profiles" ADD COLUMN "${column}" numeric DEFAULT 0;`))
}
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
for (const column of [...columns].reverse()) {
await db.execute(sql.raw(`ALTER TABLE "profiles" DROP COLUMN "${column}";`))
}
}

View file

@ -20,6 +20,7 @@ import * as migration_20260903_044047 from './20260903_044047';
import * as migration_20260903_053419 from './20260903_053419';
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats';
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
export const migrations = [
{
@ -132,4 +133,9 @@ export const migrations = [
down: migration_20260903_061000_add_radio_incorrect_counts.down,
name: '20260903_061000_add_radio_incorrect_counts',
},
{
up: migration_20260903_062000_add_aim_trainer_stats.up,
down: migration_20260903_062000_add_aim_trainer_stats.down,
name: '20260903_062000_add_aim_trainer_stats',
},
];

View file

@ -1803,6 +1803,17 @@ export interface Profile {
watchBestAccuracy?: number | null;
xpEarned?: number | null;
};
aimTrainer?: {
gamesPlayed?: number | null;
bestScore?: number | null;
recruitBestScore?: number | null;
veteranBestScore?: number | null;
eliteBestScore?: number | null;
recruitBestAccuracy?: number | null;
veteranBestAccuracy?: number | null;
eliteBestAccuracy?: number | null;
xpEarned?: number | null;
};
};
};
updatedAt: string;
@ -3832,6 +3843,19 @@ export interface ProfilesSelect<T extends boolean = true> {
watchBestAccuracy?: T;
xpEarned?: T;
};
aimTrainer?:
| T
| {
gamesPlayed?: T;
bestScore?: T;
recruitBestScore?: T;
veteranBestScore?: T;
eliteBestScore?: T;
recruitBestAccuracy?: T;
veteranBestAccuracy?: T;
eliteBestAccuracy?: T;
xpEarned?: T;
};
};
};
updatedAt?: T;

View file

@ -64,6 +64,7 @@ export const EventTypes = {
MinigameFlappy: "minigame:flappy",
MinigameEod: "minigame:eod",
MinigameRadioTraffic: "minigame:radio-traffic",
MinigameAimTrainer: "minigame:aim-trainer",
// Market
MarketListingCreate: "market:listing-create",

View file

@ -0,0 +1,409 @@
import { describe, expect, it } from "vitest";
import {
ACCURACY_BONUS_MAX,
AIM_TRAINER_DIFFICULTIES,
COMPLETION_XP,
COURSE_DURATION_MS,
HEADSHOT_POINTS,
MAX_DESPAWNED_TARGETS,
MAX_HITS,
MAX_SHOTS_FIRED,
NEW_TIER_RECORD_XP,
POINTS_PER_XP,
SCORE_XP_RATE,
TORSO_POINTS,
XP_CAP,
type AimTrainerTarget,
calculateAimTrainerAccuracy,
calculateAimTrainerScore,
calculateAimTrainerXp,
createAimTrainerTarget,
isAimTrainerDifficulty,
resolveAimTrainerHitZone,
resolveAimTrainerTargetHit,
stepAimTrainerTarget,
} from "@/lib/aim-trainer";
/** Deterministic LCG-style RNG so target creation is reproducible across runs. */
function seededRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0x100000000;
};
}
describe("Aim Trainer constants", () => {
it("exposes the course duration and scoring constants", () => {
expect(COURSE_DURATION_MS).toBe(30_000);
expect(TORSO_POINTS).toBe(100);
expect(HEADSHOT_POINTS).toBe(250);
expect(ACCURACY_BONUS_MAX).toBe(500);
expect(POINTS_PER_XP).toBe(500);
expect(SCORE_XP_RATE).toBe(0.125);
expect(COMPLETION_XP).toBe(1);
expect(NEW_TIER_RECORD_XP).toBe(2);
expect(XP_CAP).toBe(50);
});
it("exposes the server safety caps", () => {
expect(MAX_SHOTS_FIRED).toBe(500);
expect(MAX_HITS).toBe(180);
expect(MAX_DESPAWNED_TARGETS).toBe(60);
});
});
describe("AIM_TRAINER_DIFFICULTIES", () => {
it("defines exactly the three tiers with the specified values", () => {
expect(AIM_TRAINER_DIFFICULTIES).toEqual({
recruit: {
torsoW: 76,
torsoH: 118,
headRadius: 21,
movingRatio: 0.25,
speed: 80,
targetLifetimeMs: 2000,
replacementDelayMs: 400,
maxConcurrentTargets: 1,
},
veteran: {
torsoW: 62,
torsoH: 100,
headRadius: 18,
movingRatio: 0.55,
speed: 140,
targetLifetimeMs: 1500,
replacementDelayMs: 300,
maxConcurrentTargets: 2,
},
elite: {
torsoW: 50,
torsoH: 82,
headRadius: 15,
movingRatio: 0.8,
speed: 220,
targetLifetimeMs: 1100,
replacementDelayMs: 200,
maxConcurrentTargets: 2,
},
});
});
it("asserts every field of every tier explicitly", () => {
const recruit = AIM_TRAINER_DIFFICULTIES.recruit;
expect(recruit.torsoW).toBe(76);
expect(recruit.torsoH).toBe(118);
expect(recruit.headRadius).toBe(21);
expect(recruit.movingRatio).toBe(0.25);
expect(recruit.speed).toBe(80);
expect(recruit.targetLifetimeMs).toBe(2000);
expect(recruit.replacementDelayMs).toBe(400);
expect(recruit.maxConcurrentTargets).toBe(1);
const veteran = AIM_TRAINER_DIFFICULTIES.veteran;
expect(veteran.torsoW).toBe(62);
expect(veteran.torsoH).toBe(100);
expect(veteran.headRadius).toBe(18);
expect(veteran.movingRatio).toBe(0.55);
expect(veteran.speed).toBe(140);
expect(veteran.targetLifetimeMs).toBe(1500);
expect(veteran.replacementDelayMs).toBe(300);
expect(veteran.maxConcurrentTargets).toBe(2);
const elite = AIM_TRAINER_DIFFICULTIES.elite;
expect(elite.torsoW).toBe(50);
expect(elite.torsoH).toBe(82);
expect(elite.headRadius).toBe(15);
expect(elite.movingRatio).toBe(0.8);
expect(elite.speed).toBe(220);
expect(elite.targetLifetimeMs).toBe(1100);
expect(elite.replacementDelayMs).toBe(200);
expect(elite.maxConcurrentTargets).toBe(2);
});
});
describe("isAimTrainerDifficulty", () => {
it("accepts the three valid tiers", () => {
expect(isAimTrainerDifficulty("recruit")).toBe(true);
expect(isAimTrainerDifficulty("veteran")).toBe(true);
expect(isAimTrainerDifficulty("elite")).toBe(true);
});
it("rejects unknown values", () => {
expect(isAimTrainerDifficulty("legendary")).toBe(false);
expect(isAimTrainerDifficulty("")).toBe(false);
expect(isAimTrainerDifficulty(42)).toBe(false);
expect(isAimTrainerDifficulty(null)).toBe(false);
expect(isAimTrainerDifficulty(undefined)).toBe(false);
expect(isAimTrainerDifficulty({})).toBe(false);
});
});
describe("calculateAimTrainerAccuracy", () => {
it("returns 0 when there were zero attempts", () => {
expect(calculateAimTrainerAccuracy(0, 0, 0)).toBe(0);
});
it("counts hits against shots fired plus despawned unhit targets", () => {
// 3 hits, 3 shots, 1 despawned unhit target -> 3 / 4
expect(calculateAimTrainerAccuracy(3, 3, 1)).toBeCloseTo(0.75);
});
it("clamps the result to the [0, 1] range", () => {
expect(calculateAimTrainerAccuracy(10, 2, 0)).toBe(1);
expect(calculateAimTrainerAccuracy(0, 5, 5)).toBe(0);
});
});
describe("calculateAimTrainerScore", () => {
it("scores headshots, torso hits, and the accuracy bonus", () => {
// 2 headshots + 1 torso, 3 shots fired, 1 despawned unhit target.
// Hit score = 2*250 + 1*100 = 600. Accuracy = 3/4. Bonus = round(0.75*500) = 375. Total 975.
expect(calculateAimTrainerScore(2, 1, 3, 1)).toBe(975);
});
it("scores a perfect run with no accuracy penalty", () => {
// 1 headshot, 1 shot, no despawned targets -> 250 + round(1*500) = 750.
expect(calculateAimTrainerScore(1, 0, 1, 0)).toBe(750);
});
});
describe("calculateAimTrainerXp", () => {
it("awards the completion XP for a zero score", () => {
expect(calculateAimTrainerXp(0, false)).toBe(1);
});
it("awards completion XP plus 0.125 per full 500 points", () => {
expect(calculateAimTrainerXp(499, false)).toBe(1);
expect(calculateAimTrainerXp(500, false)).toBe(1.125);
expect(calculateAimTrainerXp(999, false)).toBe(1.125);
expect(calculateAimTrainerXp(1000, false)).toBe(1.25);
});
it("adds the new-tier-best bonus", () => {
expect(calculateAimTrainerXp(0, true)).toBe(3);
expect(calculateAimTrainerXp(500, true)).toBe(3.125);
});
it("caps the result at XP_CAP", () => {
expect(calculateAimTrainerXp(1_000_000, true)).toBe(50);
expect(calculateAimTrainerXp(1_000_000, false)).toBe(50);
});
});
describe("createAimTrainerTarget", () => {
const canvasW = 800;
const canvasH = 600;
it("returns a target with the tier's dimensions and a deterministic position", () => {
const rng = seededRng(1234);
const target = createAimTrainerTarget(rng, canvasW, canvasH, "veteran");
expect(target.torsoW).toBe(62);
expect(target.torsoH).toBe(100);
expect(target.headRadius).toBe(18);
expect(typeof target.moving).toBe("boolean");
expect(typeof target.x).toBe("number");
expect(typeof target.y).toBe("number");
expect(typeof target.vx).toBe("number");
expect(typeof target.vy).toBe("number");
});
it("is deterministic for the same seed across repeated calls", () => {
// Given: a fixed seed for Elite target generation.
const seed = 42;
// When: identical seeded streams each generate their first target.
const a = createAimTrainerTarget(seededRng(seed), canvasW, canvasH, "elite");
const b = createAimTrainerTarget(seededRng(seed), canvasW, canvasH, "elite");
// Then: the complete targets match and the moving target has the balanced Elite speed.
expect(a).toEqual(b);
expect(a.moving).toBe(true);
expect(Math.hypot(a.vx, a.vy)).toBeCloseTo(220);
});
it("keeps the whole target (torso plus head) inside the canvas", () => {
for (let seed = 0; seed < 200; seed++) {
const target = createAimTrainerTarget(seededRng(seed), canvasW, canvasH, "recruit");
const left = target.x - target.torsoW / 2;
const right = target.x + target.torsoW / 2;
const top = target.y - 2 * target.headRadius;
const bottom = target.y + target.torsoH;
expect(left).toBeGreaterThanOrEqual(0);
expect(right).toBeLessThanOrEqual(canvasW);
expect(top).toBeGreaterThanOrEqual(0);
expect(bottom).toBeLessThanOrEqual(canvasH);
}
});
it("selects moving vs stationary based on the RNG roll against movingRatio", () => {
// A rng that always returns 0 -> always moving (0 < movingRatio).
const alwaysMoving = createAimTrainerTarget(() => 0, canvasW, canvasH, "recruit");
expect(alwaysMoving.moving).toBe(true);
// A rng that always returns 1 -> never moving (1 < movingRatio is false).
const neverMoving = createAimTrainerTarget(() => 1, canvasW, canvasH, "recruit");
expect(neverMoving.moving).toBe(false);
});
it("gives stationary targets zero velocity", () => {
const stationary = createAimTrainerTarget(() => 1, canvasW, canvasH, "veteran");
expect(stationary.moving).toBe(false);
expect(stationary.vx).toBe(0);
expect(stationary.vy).toBe(0);
});
});
describe("resolveAimTrainerHitZone", () => {
const target = {
x: 400,
y: 300,
vx: 0,
vy: 0,
moving: false,
torsoW: 62,
torsoH: 100,
headRadius: 18,
};
it("resolves a head hit inside the head circle", () => {
// Head center is at (x, y - headRadius) = (400, 282).
expect(resolveAimTrainerHitZone(target, 400, 282)).toBe("head");
});
it("resolves a torso hit inside the torso rectangle", () => {
// Torso spans x in [369, 431], y in [300, 400].
expect(resolveAimTrainerHitZone(target, 400, 350)).toBe("torso");
});
it("returns null for a miss outside both zones", () => {
expect(resolveAimTrainerHitZone(target, 100, 100)).toBeNull();
});
it("checks the head zone before the torso rectangle", () => {
// A point that is inside the head circle but also within the torso's x-range
// must resolve to "head", proving head is checked first.
expect(resolveAimTrainerHitZone(target, 400, 282)).toBe("head");
});
});
describe("resolveAimTrainerTargetHit", () => {
const rearTarget: AimTrainerTarget = {
x: 400,
y: 300,
vx: 0,
vy: 0,
moving: false,
torsoW: 62,
torsoH: 100,
headRadius: 18,
};
const frontTarget: AimTrainerTarget = {
...rearTarget,
y: 280,
};
it("resolves the topmost target first when ordered targets overlap", () => {
// Given: index 1 is drawn above index 0, and (400, 282) hits its torso and index 0's head.
const targets = [rearTarget, frontTarget];
// When: the overlapping point is resolved against the ordered targets.
const hit = resolveAimTrainerTargetHit(targets, 400, 282);
// Then: the topmost target wins even though the lower target offers a headshot.
expect(hit).toEqual({ index: 1, zone: "torso" });
});
it("returns null when every ordered target misses", () => {
// Given: two overlapping targets whose complete geometry is away from the origin.
const targets = [rearTarget, frontTarget];
// When: a point outside every target is resolved.
const hit = resolveAimTrainerTargetHit(targets, 0, 0);
// Then: no target or zone is selected.
expect(hit).toBeNull();
});
});
describe("stepAimTrainerTarget", () => {
const canvasW = 800;
const canvasH = 600;
it("moves a moving target by velocity times dt", () => {
const target = {
x: 400,
y: 300,
vx: 100,
vy: 0,
moving: true,
torsoW: 62,
torsoH: 100,
headRadius: 18,
};
const stepped = stepAimTrainerTarget(target, 0.5, canvasW, canvasH);
expect(stepped.x).toBeCloseTo(450);
expect(stepped.y).toBeCloseTo(300);
});
it("leaves a stationary target in place", () => {
const target = {
x: 400,
y: 300,
vx: 100,
vy: 50,
moving: false,
torsoW: 62,
torsoH: 100,
headRadius: 18,
};
const stepped = stepAimTrainerTarget(target, 1, canvasW, canvasH);
expect(stepped.x).toBe(400);
expect(stepped.y).toBe(300);
});
it("bounces off the horizontal bounds and keeps the target inside", () => {
const target = {
x: 400,
y: 300,
vx: 1000,
vy: 0,
moving: true,
torsoW: 62,
torsoH: 100,
headRadius: 18,
};
let current = target;
for (let i = 0; i < 1000; i++) {
current = stepAimTrainerTarget(current, 0.1, canvasW, canvasH);
const left = current.x - current.torsoW / 2;
const right = current.x + current.torsoW / 2;
const top = current.y - 2 * current.headRadius;
const bottom = current.y + current.torsoH;
expect(left).toBeGreaterThanOrEqual(0);
expect(right).toBeLessThanOrEqual(canvasW);
expect(top).toBeGreaterThanOrEqual(0);
expect(bottom).toBeLessThanOrEqual(canvasH);
}
});
it("bounces off the vertical bounds and keeps the target inside", () => {
const target = {
x: 400,
y: 300,
vx: 0,
vy: 1000,
moving: true,
torsoW: 62,
torsoH: 100,
headRadius: 18,
};
let current = target;
for (let i = 0; i < 1000; i++) {
current = stepAimTrainerTarget(current, 0.1, canvasW, canvasH);
const top = current.y - 2 * current.headRadius;
const bottom = current.y + current.torsoH;
expect(top).toBeGreaterThanOrEqual(0);
expect(bottom).toBeLessThanOrEqual(canvasH);
}
});
});