1
0
Fork 0

Compare commits

...

7 commits

Author SHA1 Message Date
72715c42a4 fix(leaderboards): match active players by user id
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphus.ai>
2026-09-04 12:04:44 -04:00
dd2c868c89 fix(qualification): expose updated aim trainer leaderboard data
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphus.ai>
2026-09-04 12:04:44 -04:00
15e5fa7d01 feat(qualification): update aim trainer instructions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphus.ai>
2026-09-04 12:04:44 -04:00
b905fe2b5e feat(qualification): handle civilian gameplay feedback
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-04 12:04:44 -04:00
6c7a9fb5e2 feat(qualification): render civilian targets
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-04 12:04:44 -04:00
dc9d2f4605 feat(qualification): validate civilian hit results
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-04 12:04:44 -04:00
7d6a4bd671 feat(qualification): add civilian target scoring rules
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-04 12:04:44 -04:00
11 changed files with 526 additions and 62 deletions

View file

@ -13,6 +13,7 @@ import {
MAX_SHOTS_FIRED, MAX_SHOTS_FIRED,
MAX_HITS, MAX_HITS,
MAX_DESPAWNED_TARGETS, MAX_DESPAWNED_TARGETS,
MAX_CIVILIAN_HITS,
} from "@/lib/aim-trainer"; } from "@/lib/aim-trainer";
interface ActionResult<T = undefined> { interface ActionResult<T = undefined> {
@ -40,6 +41,7 @@ export interface AimTrainerResultInput {
difficulty: string; difficulty: string;
headshots: number; headshots: number;
torsoHits: number; torsoHits: number;
civilianHits: number;
shotsFired: number; shotsFired: number;
despawnedUnhitTargets: number; despawnedUnhitTargets: number;
} }
@ -71,12 +73,14 @@ export async function submitAimTrainerResult(
return { success: false, error: "Invalid difficulty." }; return { success: false, error: "Invalid difficulty." };
} }
const { headshots, torsoHits, shotsFired, despawnedUnhitTargets } = input; const { headshots, torsoHits, civilianHits, shotsFired, despawnedUnhitTargets } = input;
if ( if (
!Number.isInteger(headshots) || !Number.isInteger(headshots) ||
headshots < 0 || headshots < 0 ||
!Number.isInteger(torsoHits) || !Number.isInteger(torsoHits) ||
torsoHits < 0 || torsoHits < 0 ||
!Number.isInteger(civilianHits) ||
civilianHits < 0 ||
!Number.isInteger(shotsFired) || !Number.isInteger(shotsFired) ||
shotsFired < 0 || shotsFired < 0 ||
!Number.isInteger(despawnedUnhitTargets) || !Number.isInteger(despawnedUnhitTargets) ||
@ -91,9 +95,12 @@ export async function submitAimTrainerResult(
if (hits > MAX_HITS) { if (hits > MAX_HITS) {
return { success: false, error: "Too many hits." }; return { success: false, error: "Too many hits." };
} }
if (hits > shotsFired) { if (headshots + torsoHits + civilianHits > shotsFired) {
return { success: false, error: "Hits cannot exceed shots fired." }; return { success: false, error: "Hits cannot exceed shots fired." };
} }
if (civilianHits > MAX_CIVILIAN_HITS) {
return { success: false, error: "Too many civilian hits." };
}
if (despawnedUnhitTargets > MAX_DESPAWNED_TARGETS) { if (despawnedUnhitTargets > MAX_DESPAWNED_TARGETS) {
return { success: false, error: "Too many despawned targets." }; return { success: false, error: "Too many despawned targets." };
} }
@ -129,7 +136,13 @@ export async function submitAimTrainerResult(
const current = progression.minigames?.aimTrainer ?? {}; const current = progression.minigames?.aimTrainer ?? {};
const accuracy = calculateAimTrainerAccuracy(hits, shotsFired, despawnedUnhitTargets); const accuracy = calculateAimTrainerAccuracy(hits, shotsFired, despawnedUnhitTargets);
const score = calculateAimTrainerScore(headshots, torsoHits, shotsFired, despawnedUnhitTargets); const score = calculateAimTrainerScore(
headshots,
torsoHits,
shotsFired,
despawnedUnhitTargets,
civilianHits,
);
const difficultyBestScoreKey = `${input.difficulty}BestScore` as const difficultyBestScoreKey = `${input.difficulty}BestScore` as
| "recruitBestScore" | "recruitBestScore"
@ -204,6 +217,7 @@ export async function submitAimTrainerResult(
difficulty: input.difficulty, difficulty: input.difficulty,
score, score,
accuracy, accuracy,
civilianHits,
xpEarned: xp, xpEarned: xp,
bestScore, bestScore,
difficultyBestScore, difficultyBestScore,

View file

@ -44,6 +44,7 @@ interface ApiProfile {
interface LeaderboardRow { interface LeaderboardRow {
id: number; id: number;
userId: number | null;
name: string; name: string;
gamesPlayed: number; gamesPlayed: number;
wins: number; wins: number;
@ -113,6 +114,7 @@ export function EodLeaderboard({
user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`; user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`;
return { return {
id: doc.id, id: doc.id,
userId: user?.id ?? null,
name, name,
gamesPlayed: eod.gamesPlayed ?? 0, gamesPlayed: eod.gamesPlayed ?? 0,
wins: eod.wins ?? 0, wins: eod.wins ?? 0,
@ -236,7 +238,7 @@ export function EodLeaderboard({
unranked unranked
</span> </span>
)} )}
{activeTeams.some((team) => team.id === row.id) && ( {row.userId !== null && activeTeams.some((team) => team.id === row.userId) && (
<span <span
className="size-2 rounded-full bg-emerald-400" className="size-2 rounded-full bg-emerald-400"
title="Clearing now" title="Clearing now"

View file

@ -30,6 +30,7 @@ interface ApiProfile {
interface LeaderboardRow { interface LeaderboardRow {
id: number; id: number;
userId: number | null;
name: string; name: string;
gamesPlayed: number; gamesPlayed: number;
totalPasses: number; totalPasses: number;
@ -82,6 +83,7 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId, activePilots
user?.displayName || doc.profileTitle || user?.username || `Pilot #${doc.id}`; user?.displayName || doc.profileTitle || user?.username || `Pilot #${doc.id}`;
return { return {
id: doc.id, id: doc.id,
userId: user?.id ?? null,
name, name,
gamesPlayed: flappy.gamesPlayed ?? 0, gamesPlayed: flappy.gamesPlayed ?? 0,
totalPasses: flappy.totalPasses ?? 0, totalPasses: flappy.totalPasses ?? 0,
@ -170,7 +172,7 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId, activePilots
unranked unranked
</span> </span>
)} )}
{activePilots.some((pilot) => pilot.id === row.id) && ( {row.userId !== null && activePilots.some((pilot) => pilot.id === row.userId) && (
<span className="size-2 rounded-full bg-emerald-400" title="Playing now" aria-label="Playing now" /> <span className="size-2 rounded-full bg-emerald-400" title="Playing now" aria-label="Playing now" />
)} )}
</span> </span>

View file

@ -148,13 +148,17 @@ export function AimTrainerArena({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="font-medium text-foreground">Acquire</span> <span className="font-medium text-foreground">Acquire</span>
<span> <span>
Up to {AIM_TRAINER_DIFFICULTIES[difficulty].maxConcurrentTargets} silhouettes are Up to {AIM_TRAINER_DIFFICULTIES[difficulty].maxConcurrentTargets} hostile
active at once. Moving targets rebound inside the range. silhouettes are active at once, plus an occasional blue civilian that never counts
toward the limit. Targets rebound off each other and the range walls.
</span> </span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="font-medium text-foreground">Prioritize</span> <span className="font-medium text-foreground">Prioritize</span>
<span>Headshots score 250 points. Torso hits score 100 points.</span> <span>
Headshots score 250 points. Torso hits score 100 points. Blue civilians cost 100
points — never shoot them.
</span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="font-medium text-foreground">Qualify</span> <span className="font-medium text-foreground">Qualify</span>

View file

@ -37,6 +37,7 @@ export type AimTrainerGameState = {
startedAt: number; startedAt: number;
headshots: number; headshots: number;
torsoHits: number; torsoHits: number;
civilianHits: number;
shotsFired: number; shotsFired: number;
despawnedUnhitTargets: number; despawnedUnhitTargets: number;
feedback: AimTrainerPointFeedback[]; feedback: AimTrainerPointFeedback[];
@ -51,6 +52,7 @@ export type AimTrainerLiveStats = {
readonly shotsFired: number; readonly shotsFired: number;
readonly headshots: number; readonly headshots: number;
readonly torsoHits: number; readonly torsoHits: number;
readonly civilianHits: number;
readonly despawnedUnhitTargets: number; readonly despawnedUnhitTargets: number;
}; };
@ -61,6 +63,7 @@ export function createAimTrainerGameState(): AimTrainerGameState {
startedAt: 0, startedAt: 0,
headshots: 0, headshots: 0,
torsoHits: 0, torsoHits: 0,
civilianHits: 0,
shotsFired: 0, shotsFired: 0,
despawnedUnhitTargets: 0, despawnedUnhitTargets: 0,
feedback: [], feedback: [],
@ -77,6 +80,7 @@ export function emptyAimTrainerLiveStats(): AimTrainerLiveStats {
shotsFired: 0, shotsFired: 0,
headshots: 0, headshots: 0,
torsoHits: 0, torsoHits: 0,
civilianHits: 0,
despawnedUnhitTargets: 0, despawnedUnhitTargets: 0,
}; };
} }
@ -89,11 +93,13 @@ export function getAimTrainerLiveStats(game: AimTrainerGameState): AimTrainerLiv
game.torsoHits, game.torsoHits,
game.shotsFired, game.shotsFired,
game.despawnedUnhitTargets, game.despawnedUnhitTargets,
game.civilianHits,
), ),
accuracy: calculateAimTrainerAccuracy(hits, game.shotsFired, game.despawnedUnhitTargets), accuracy: calculateAimTrainerAccuracy(hits, game.shotsFired, game.despawnedUnhitTargets),
shotsFired: game.shotsFired, shotsFired: game.shotsFired,
headshots: game.headshots, headshots: game.headshots,
torsoHits: game.torsoHits, torsoHits: game.torsoHits,
civilianHits: game.civilianHits,
despawnedUnhitTargets: game.despawnedUnhitTargets, despawnedUnhitTargets: game.despawnedUnhitTargets,
}; };
} }
@ -141,12 +147,37 @@ function drawRange(ctx: CanvasRenderingContext2D): void {
ctx.fillText("QUALIFICATION COURSE", AIM_TRAINER_CANVAS_WIDTH - 18, 16); ctx.fillText("QUALIFICATION COURSE", AIM_TRAINER_CANVAS_WIDTH - 18, 16);
} }
const STANDARD_TARGET_PALETTE = {
torsoStops: ["#9b956d", "#706f52", "#4d5542"] as const,
lowerShade: "rgba(29, 38, 30, 0.42)",
straps: "rgba(217, 207, 151, 0.32)",
torsoOutline: "rgba(226, 217, 166, 0.6)",
headFill: "#afa678",
headShade: "rgba(48, 55, 40, 0.48)",
headOutline: "rgba(226, 217, 166, 0.72)",
crosshair: "rgba(235, 226, 175, 0.32)",
boundsBox: "rgba(211, 202, 149, 0.12)",
};
const CIVILIAN_TARGET_PALETTE = {
torsoStops: ["#8fb4dd", "#5b87b8", "#3a6191"] as const,
lowerShade: "rgba(16, 32, 52, 0.42)",
straps: "rgba(235, 245, 255, 0.35)",
torsoOutline: "rgba(190, 215, 240, 0.6)",
headFill: "#b7d0ec",
headShade: "rgba(30, 58, 92, 0.48)",
headOutline: "rgba(190, 215, 240, 0.72)",
crosshair: "rgba(215, 235, 255, 0.35)",
boundsBox: "rgba(167, 197, 232, 0.14)",
};
function drawTarget( function drawTarget(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
activeTarget: AimTrainerActiveTarget, activeTarget: AimTrainerActiveTarget,
frame: AimTrainerSceneFrame, frame: AimTrainerSceneFrame,
): void { ): void {
const { target, spawnedAt } = activeTarget; const { target, spawnedAt } = activeTarget;
const palette = target.kind === "civilian" ? CIVILIAN_TARGET_PALETTE : STANDARD_TARGET_PALETTE;
const age = Math.max(0, frame.now - spawnedAt); const age = Math.max(0, frame.now - spawnedAt);
const fadeDuration = Math.min( const fadeDuration = Math.min(
AIM_TRAINER_TARGET_FADE_DURATION_MS, AIM_TRAINER_TARGET_FADE_DURATION_MS,
@ -173,35 +204,35 @@ function drawTarget(
ctx.shadowColor = "transparent"; ctx.shadowColor = "transparent";
const torso = ctx.createLinearGradient(-target.torsoW / 2, 0, target.torsoW / 2, target.torsoH); const torso = ctx.createLinearGradient(-target.torsoW / 2, 0, target.torsoW / 2, target.torsoH);
torso.addColorStop(0, "#9b956d"); torso.addColorStop(0, palette.torsoStops[0]);
torso.addColorStop(0.5, "#706f52"); torso.addColorStop(0.5, palette.torsoStops[1]);
torso.addColorStop(1, "#4d5542"); torso.addColorStop(1, palette.torsoStops[2]);
ctx.fillStyle = torso; ctx.fillStyle = torso;
ctx.fillRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH); ctx.fillRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH);
ctx.fillStyle = "rgba(29, 38, 30, 0.42)"; ctx.fillStyle = palette.lowerShade;
ctx.fillRect(-target.torsoW / 2, target.torsoH * 0.54, target.torsoW, target.torsoH * 0.46); ctx.fillRect(-target.torsoW / 2, target.torsoH * 0.54, target.torsoW, target.torsoH * 0.46);
ctx.fillStyle = "rgba(217, 207, 151, 0.32)"; ctx.fillStyle = palette.straps;
ctx.fillRect(-target.torsoW * 0.28, target.torsoH * 0.16, target.torsoW * 0.56, 3); 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.fillRect(-target.torsoW * 0.28, target.torsoH * 0.42, target.torsoW * 0.56, 3);
ctx.strokeStyle = "rgba(226, 217, 166, 0.6)"; ctx.strokeStyle = palette.torsoOutline;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.strokeRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH); ctx.strokeRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH);
ctx.fillStyle = "#afa678"; ctx.fillStyle = palette.headFill;
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2); ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.fillStyle = "rgba(48, 55, 40, 0.48)"; ctx.fillStyle = palette.headShade;
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, -target.headRadius - 2, target.headRadius * 0.72, Math.PI, Math.PI * 2); ctx.arc(0, -target.headRadius - 2, target.headRadius * 0.72, Math.PI, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.strokeStyle = "rgba(226, 217, 166, 0.72)"; ctx.strokeStyle = palette.headOutline;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2); ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.strokeStyle = "rgba(235, 226, 175, 0.32)"; ctx.strokeStyle = palette.crosshair;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, -target.headRadius - target.headRadius - 9); ctx.moveTo(0, -target.headRadius - target.headRadius - 9);
@ -213,7 +244,7 @@ function drawTarget(
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
ctx.strokeStyle = "rgba(211, 202, 149, 0.12)"; ctx.strokeStyle = palette.boundsBox;
ctx.setLineDash([4, 5]); ctx.setLineDash([4, 5]);
ctx.strokeRect(torsoX, target.y, target.torsoW * pulse, target.torsoH); ctx.strokeRect(torsoX, target.y, target.torsoW * pulse, target.torsoH);
ctx.setLineDash([]); ctx.setLineDash([]);

View file

@ -8,13 +8,15 @@ import { Crosshair, RotateCcw, Target, Timer, Trophy } from "lucide-react";
import { import {
AIM_TRAINER_DIFFICULTIES, AIM_TRAINER_DIFFICULTIES,
CIVILIAN_PENALTY,
MAX_CONCURRENT_CIVILIANS,
calculateAimTrainerAccuracy, calculateAimTrainerAccuracy,
calculateAimTrainerScore, calculateAimTrainerScore,
COURSE_DURATION_MS, COURSE_DURATION_MS,
HEADSHOT_POINTS, HEADSHOT_POINTS,
createAimTrainerTarget, createAimTrainerTarget,
resolveAimTrainerTargetHit, resolveAimTrainerTargetHit,
stepAimTrainerTarget, stepAimTrainerTargets,
TORSO_POINTS, TORSO_POINTS,
type AimTrainerDifficulty, type AimTrainerDifficulty,
} from "@/lib/aim-trainer"; } from "@/lib/aim-trainer";
@ -44,6 +46,7 @@ import {
type AimTrainerLiveStats, type AimTrainerLiveStats,
} from "./AimTrainerCanvas"; } from "./AimTrainerCanvas";
import { import {
playCivilianHit,
playCountdownTick, playCountdownTick,
playFire, playFire,
playHeadshot, playHeadshot,
@ -94,6 +97,7 @@ type LastRun = {
readonly accuracy: number; readonly accuracy: number;
readonly headshots: number; readonly headshots: number;
readonly torsoHits: number; readonly torsoHits: number;
readonly civilianHits: number;
readonly shotsFired: number; readonly shotsFired: number;
readonly despawnedUnhitTargets: number; readonly despawnedUnhitTargets: number;
readonly xpEarned: number; readonly xpEarned: number;
@ -205,8 +209,12 @@ export function AimTrainerGame({
round.torsoHits, round.torsoHits,
round.shotsFired, round.shotsFired,
round.despawnedUnhitTargets, round.despawnedUnhitTargets,
round.civilianHits,
); );
const hitScore = round.headshots * HEADSHOT_POINTS + round.torsoHits * TORSO_POINTS; const hitScore =
round.headshots * HEADSHOT_POINTS +
round.torsoHits * TORSO_POINTS -
round.civilianHits * CIVILIAN_PENALTY;
const previousTierBest = tierBestScore(statsRef.current, difficulty); const previousTierBest = tierBestScore(statsRef.current, difficulty);
const preview: LastRun = { const preview: LastRun = {
hitScore, hitScore,
@ -215,6 +223,7 @@ export function AimTrainerGame({
accuracy, accuracy,
headshots: round.headshots, headshots: round.headshots,
torsoHits: round.torsoHits, torsoHits: round.torsoHits,
civilianHits: round.civilianHits,
shotsFired: round.shotsFired, shotsFired: round.shotsFired,
despawnedUnhitTargets: round.despawnedUnhitTargets, despawnedUnhitTargets: round.despawnedUnhitTargets,
xpEarned: 0, xpEarned: 0,
@ -352,6 +361,23 @@ export function AimTrainerGame({
const [activeTarget] = game.targets.splice(hit.index, 1); const [activeTarget] = game.targets.splice(hit.index, 1);
if (!activeTarget) return; if (!activeTarget) return;
const target = activeTarget.target; const target = activeTarget.target;
if (target.kind === "civilian") {
game.civilianHits += 1;
game.feedback.push({
x: target.x,
y: target.y + target.torsoH * 0.45,
text: `-${CIVILIAN_PENALTY}`,
color: "#efb08a",
createdAt: now,
});
game.flashColor = "#7fa8d9";
game.flashUntil = now + FLASH_DURATION_MS;
playCivilianHit();
setLive(getAimTrainerLiveStats(game));
return;
}
game.pendingSpawnAts.push(now + config.replacementDelayMs); game.pendingSpawnAts.push(now + config.replacementDelayMs);
switch (hit.zone) { switch (hit.zone) {
case "head": case "head":
@ -410,13 +436,14 @@ export function AimTrainerGame({
setLive(getAimTrainerLiveStats(game)); setLive(getAimTrainerLiveStats(game));
setPhaseSafe("over"); setPhaseSafe("over");
playRoundComplete(); playRoundComplete();
void handleRoundCompleteRef.current({ void handleRoundCompleteRef.current({
difficulty, difficulty,
headshots: game.headshots, headshots: game.headshots,
torsoHits: game.torsoHits, torsoHits: game.torsoHits,
shotsFired: game.shotsFired, civilianHits: game.civilianHits,
despawnedUnhitTargets: game.despawnedUnhitTargets, shotsFired: game.shotsFired,
}); despawnedUnhitTargets: game.despawnedUnhitTargets,
});
}; };
const step = (now: number) => { const step = (now: number) => {
@ -443,32 +470,60 @@ export function AimTrainerGame({
} }
const survivingTargets: AimTrainerActiveTarget[] = []; const survivingTargets: AimTrainerActiveTarget[] = [];
let expiredTargets = 0; let expiredStandardTargets = 0;
for (const activeTarget of game.targets) { for (const activeTarget of game.targets) {
if (now - activeTarget.spawnedAt >= config.targetLifetimeMs) { if (now - activeTarget.spawnedAt >= config.targetLifetimeMs) {
expiredTargets += 1; if (activeTarget.target.kind === "standard") {
game.pendingSpawnAts.push(now + config.replacementDelayMs); expiredStandardTargets += 1;
game.pendingSpawnAts.push(now + config.replacementDelayMs);
}
continue; continue;
} }
survivingTargets.push({ survivingTargets.push({
target: stepAimTrainerTarget(activeTarget.target, dtSec, CANVAS_W, CANVAS_H), target: activeTarget.target,
spawnedAt: activeTarget.spawnedAt, spawnedAt: activeTarget.spawnedAt,
}); });
} }
game.targets = survivingTargets; const steppedTargets = stepAimTrainerTargets(
if (expiredTargets > 0) { survivingTargets.map((activeTarget) => activeTarget.target),
game.despawnedUnhitTargets += expiredTargets; dtSec,
CANVAS_W,
CANVAS_H,
);
game.targets = survivingTargets.map((activeTarget, index) => ({
target: steppedTargets[index] ?? activeTarget.target,
spawnedAt: activeTarget.spawnedAt,
}));
if (expiredStandardTargets > 0) {
game.despawnedUnhitTargets += expiredStandardTargets;
setLive(getAimTrainerLiveStats(game)); setLive(getAimTrainerLiveStats(game));
} }
while (game.targets.length < config.maxConcurrentTargets) { let standardCount = game.targets.filter(
(activeTarget) => activeTarget.target.kind === "standard",
).length;
let civilianCount = game.targets.length - standardCount;
while (standardCount < config.maxConcurrentTargets) {
const dueIndex = game.pendingSpawnAts.findIndex((spawnAt) => spawnAt <= now); const dueIndex = game.pendingSpawnAts.findIndex((spawnAt) => spawnAt <= now);
if (dueIndex === -1) break; if (dueIndex === -1) break;
game.pendingSpawnAts.splice(dueIndex, 1); game.pendingSpawnAts.splice(dueIndex, 1);
game.targets.push({ game.targets.push({
target: createAimTrainerTarget(Math.random, CANVAS_W, CANVAS_H, difficulty), target: createAimTrainerTarget(Math.random, CANVAS_W, CANVAS_H, difficulty, {
existingTargets: game.targets.map((activeTarget) => activeTarget.target),
}),
spawnedAt: now, spawnedAt: now,
}); });
standardCount += 1;
if (civilianCount < MAX_CONCURRENT_CIVILIANS && Math.random() < config.civilianChance) {
game.targets.push({
target: createAimTrainerTarget(Math.random, CANVAS_W, CANVAS_H, difficulty, {
civilian: true,
existingTargets: game.targets.map((activeTarget) => activeTarget.target),
}),
spawnedAt: now,
});
civilianCount += 1;
}
} }
} }
} }
@ -509,8 +564,9 @@ export function AimTrainerGame({
window.removeEventListener("keyup", onKeyUp, { capture: true }); window.removeEventListener("keyup", onKeyUp, { capture: true });
}; };
}, [ }, [
config.replacementDelayMs, config.civilianChance,
config.maxConcurrentTargets, config.maxConcurrentTargets,
config.replacementDelayMs,
config.targetLifetimeMs, config.targetLifetimeMs,
difficulty, difficulty,
handlePrimaryAction, handlePrimaryAction,
@ -571,7 +627,8 @@ export function AimTrainerGame({
Ready on the line Ready on the line
</span> </span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Acquire every silhouette before it clears the range. Acquire every silhouette before it clears the range — and never shoot the blue
civilians.
</span> </span>
</div> </div>
<Button <Button
@ -593,7 +650,7 @@ export function AimTrainerGame({
{phase === "over" && lastRun && ( {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="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"> <div className="flex items-center justify-center gap-2">
<Trophy aria-hidden="true" className="size-5 text-primary" /> <Trophy aria-hidden="true" className="size-5 text-amber-400" />
<span className="text-lg font-semibold tracking-wide">Course complete</span> <span className="text-lg font-semibold tracking-wide">Course complete</span>
</div> </div>
{lastRun.isNewRecord && <Badge className="mx-auto">New record!</Badge>} {lastRun.isNewRecord && <Badge className="mx-auto">New record!</Badge>}
@ -608,7 +665,8 @@ export function AimTrainerGame({
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground"> <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.shotsFired} shots fired</span>
<span>{lastRun.despawnedUnhitTargets} targets expired</span> <span>{lastRun.despawnedUnhitTargets} targets expired</span>
<span className="font-semibold text-primary">+{lastRun.xpEarned} XP</span> <span>{lastRun.civilianHits} civilian hits</span>
<span className="font-semibold text-emerald-400">+{lastRun.xpEarned} XP</span>
</div> </div>
{lastRun.error && <span className="text-xs text-destructive">{lastRun.error}</span>} {lastRun.error && <span className="text-xs text-destructive">{lastRun.error}</span>}
{submitting && ( {submitting && (
@ -669,7 +727,8 @@ export function AimTrainerGame({
</div> </div>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Headshots score {HEADSHOT_POINTS}. Torso hits score {TORSO_POINTS}. Headshots score {HEADSHOT_POINTS} · torso hits score {TORSO_POINTS} · civilians cost{" "}
{CIVILIAN_PENALTY}. Press R to restart the lane.
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -15,6 +15,7 @@ import {
type LeaderboardRow = { type LeaderboardRow = {
readonly id: number; readonly id: number;
readonly userId: number | null;
readonly name: string; readonly name: string;
readonly gamesPlayed: number; readonly gamesPlayed: number;
readonly bestScore: number; readonly bestScore: number;
@ -24,9 +25,9 @@ type LeaderboardRow = {
}; };
function rankClass(index: number): string { function rankClass(index: number): string {
if (index === 0) return "font-bold text-primary"; if (index === 0) return "font-bold text-amber-400";
if (index === 1) return "font-semibold text-foreground"; if (index === 1) return "font-semibold text-slate-300";
if (index === 2) return "font-semibold text-muted-foreground"; if (index === 2) return "font-semibold text-amber-700";
return "text-muted-foreground"; return "text-muted-foreground";
} }
@ -77,6 +78,7 @@ export function AimTrainerLeaderboard({
const aimTrainer = profile.aimTrainer; const aimTrainer = profile.aimTrainer;
return { return {
id: profile.id, id: profile.id,
userId,
name, name,
gamesPlayed: aimTrainer?.gamesPlayed ?? 0, gamesPlayed: aimTrainer?.gamesPlayed ?? 0,
bestScore: aimTrainer?.[tier.scoreField] ?? 0, bestScore: aimTrainer?.[tier.scoreField] ?? 0,
@ -111,7 +113,7 @@ export function AimTrainerLeaderboard({
<Card className="w-full max-w-3xl overflow-hidden"> <Card className="w-full max-w-3xl overflow-hidden">
<CardHeader className="gap-1 border-b border-border px-4 py-3"> <CardHeader className="gap-1 border-b border-border px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Trophy aria-hidden="true" className="size-4 text-primary" /> <Trophy aria-hidden="true" className="size-4 text-amber-400" />
<CardTitle className="text-sm">Qualification leaderboard</CardTitle> <CardTitle className="text-sm">Qualification leaderboard</CardTitle>
<span className="ml-auto text-xs text-muted-foreground">{tier.label} · Top 10</span> <span className="ml-auto text-xs text-muted-foreground">{tier.label} · Top 10</span>
</div> </div>
@ -121,7 +123,7 @@ export function AimTrainerLeaderboard({
className="border-b border-border px-4 py-2 text-xs text-muted-foreground" className="border-b border-border px-4 py-2 text-xs text-muted-foreground"
aria-live="polite" aria-live="polite"
> >
<span className="font-medium text-primary">{activeTrainees.length}</span>{" "} <span className="font-medium text-emerald-400">{activeTrainees.length}</span>{" "}
{activeTrainees.length === 1 ? "trainee is" : "trainees are"} on the line {activeTrainees.length === 1 ? "trainee is" : "trainees are"} on the line
{activeTrainees.length > 0 && ( {activeTrainees.length > 0 && (
<span className="ml-1 text-foreground"> <span className="ml-1 text-foreground">
@ -190,9 +192,9 @@ export function AimTrainerLeaderboard({
unranked unranked
</span> </span>
)} )}
{activeTrainees.some((trainee) => trainee.id === row.id) && ( {row.userId !== null && activeTrainees.some((trainee) => trainee.id === row.userId) && (
<span <span
className="size-2 rounded-full bg-primary" className="size-2 rounded-full bg-emerald-400"
title="Playing now" title="Playing now"
aria-label="Playing now" aria-label="Playing now"
/> />

View file

@ -109,6 +109,19 @@ export function playTorsoHit(): void {
}); });
} }
export function playCivilianHit(): void {
const ctx = getCtx();
if (!ctx) return;
playTone(ctx, {
frequency: 300,
endFrequency: 140,
duration: 0.22,
type: "sawtooth",
volume: 0.16,
});
playNoise(ctx, { duration: 0.12, volume: 0.12, lowpass: 520 });
}
export function playMiss(): void { export function playMiss(): void {
const ctx = getCtx(); const ctx = getCtx();
if (!ctx) return; if (!ctx) return;

View file

@ -41,6 +41,7 @@ interface ApiProfile {
interface LeaderboardRow { interface LeaderboardRow {
id: number; id: number;
userId: number | null;
name: string; name: string;
totalMessages: number; totalMessages: number;
bestScore: number; bestScore: number;
@ -134,6 +135,7 @@ export function RadioLeaderboard({
const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null; const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
return { return {
id: doc.id, id: doc.id,
userId: user?.id ?? null,
name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`, name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`,
totalMessages: totalMessages:
radioTraffic[mode === "sprint" ? "sprintTotalMessages" : "watchTotalMessages"] ?? 0, radioTraffic[mode === "sprint" ? "sprintTotalMessages" : "watchTotalMessages"] ?? 0,
@ -299,7 +301,7 @@ export function RadioLeaderboard({
unranked unranked
</span> </span>
)} )}
{activeOperators.some((operator) => operator.id === row.id) && ( {row.userId !== null && activeOperators.some((operator) => operator.id === row.userId) && (
<span <span
className="size-2 rounded-full bg-emerald-400" className="size-2 rounded-full bg-emerald-400"
title="Transmitting now" title="Transmitting now"

View file

@ -13,6 +13,8 @@ export interface AimTrainerTierConfig {
readonly torsoH: number; readonly torsoH: number;
readonly headRadius: number; readonly headRadius: number;
readonly movingRatio: number; readonly movingRatio: number;
/** Chance that a standard-target spawn event also puts a civilian on the range. */
readonly civilianChance: number;
readonly speed: number; readonly speed: number;
readonly maxConcurrentTargets: number; readonly maxConcurrentTargets: number;
readonly targetLifetimeMs: number; readonly targetLifetimeMs: number;
@ -27,6 +29,7 @@ export const AIM_TRAINER_DIFFICULTIES: Readonly<
torsoH: 118, torsoH: 118,
headRadius: 21, headRadius: 21,
movingRatio: 0.25, movingRatio: 0.25,
civilianChance: 0.2,
speed: 80, speed: 80,
maxConcurrentTargets: 1, maxConcurrentTargets: 1,
targetLifetimeMs: 2000, targetLifetimeMs: 2000,
@ -37,6 +40,7 @@ export const AIM_TRAINER_DIFFICULTIES: Readonly<
torsoH: 100, torsoH: 100,
headRadius: 18, headRadius: 18,
movingRatio: 0.55, movingRatio: 0.55,
civilianChance: 0.3,
speed: 140, speed: 140,
maxConcurrentTargets: 2, maxConcurrentTargets: 2,
targetLifetimeMs: 1500, targetLifetimeMs: 1500,
@ -47,6 +51,7 @@ export const AIM_TRAINER_DIFFICULTIES: Readonly<
torsoH: 82, torsoH: 82,
headRadius: 15, headRadius: 15,
movingRatio: 0.8, movingRatio: 0.8,
civilianChance: 0.35,
speed: 220, speed: 220,
maxConcurrentTargets: 2, maxConcurrentTargets: 2,
targetLifetimeMs: 1100, targetLifetimeMs: 1100,
@ -65,9 +70,14 @@ export const COMPLETION_XP = 1;
export const NEW_TIER_RECORD_XP = 2; export const NEW_TIER_RECORD_XP = 2;
export const XP_CAP = 50; export const XP_CAP = 50;
export const CIVILIAN_PENALTY = 100;
/** At most this many civilians are on the range at once. */
export const MAX_CONCURRENT_CIVILIANS = 1;
export const MAX_SHOTS_FIRED = 500; export const MAX_SHOTS_FIRED = 500;
export const MAX_HITS = 180; export const MAX_HITS = 180;
export const MAX_DESPAWNED_TARGETS = 60; export const MAX_DESPAWNED_TARGETS = 60;
export const MAX_CIVILIAN_HITS = 30;
const DIFFICULTY_VALUES: readonly AimTrainerDifficulty[] = ["recruit", "veteran", "elite"]; const DIFFICULTY_VALUES: readonly AimTrainerDifficulty[] = ["recruit", "veteran", "elite"];
@ -93,12 +103,15 @@ export function calculateAimTrainerScore(
torsoHits: number, torsoHits: number,
shotsFired: number, shotsFired: number,
despawnedUnhitTargets: number, despawnedUnhitTargets: number,
civilianHits = 0,
): number { ): number {
const accuracy = calculateAimTrainerAccuracy(headshots + torsoHits, shotsFired, despawnedUnhitTargets); const accuracy = calculateAimTrainerAccuracy(headshots + torsoHits, shotsFired, despawnedUnhitTargets);
return ( return Math.max(
0,
headshots * HEADSHOT_POINTS + headshots * HEADSHOT_POINTS +
torsoHits * TORSO_POINTS + torsoHits * TORSO_POINTS -
Math.round(accuracy * ACCURACY_BONUS_MAX) civilianHits * CIVILIAN_PENALTY +
Math.round(accuracy * ACCURACY_BONUS_MAX),
); );
} }
@ -110,7 +123,10 @@ export function calculateAimTrainerXp(score: number, isNewTierBest: boolean): nu
return Math.min(XP_CAP, xp); return Math.min(XP_CAP, xp);
} }
export type AimTrainerTargetKind = "standard" | "civilian";
export interface AimTrainerTarget { export interface AimTrainerTarget {
readonly kind: AimTrainerTargetKind;
readonly x: number; readonly x: number;
readonly y: number; readonly y: number;
readonly vx: number; readonly vx: number;
@ -126,36 +142,96 @@ export interface AimTrainerTargetHit {
readonly zone: "head" | "torso"; readonly zone: "head" | "torso";
} }
export interface AimTrainerTargetBoundsSource {
readonly x: number;
readonly y: number;
readonly torsoW: number;
readonly torsoH: number;
readonly headRadius: number;
}
export interface AimTrainerTargetBounds {
readonly left: number;
readonly right: number;
readonly top: number;
readonly bottom: number;
}
/**
* Axis-aligned bounding box covering a target's whole body: the torso
* rectangle plus the circular head above it. The head circle is centered at
* `(x, y - headRadius)`, so the vertical extent is `[y - 2*headRadius,
* y + torsoH]`.
*/
export function aimTrainerTargetBounds(target: AimTrainerTargetBoundsSource): AimTrainerTargetBounds {
const halfW = target.torsoW / 2;
return {
left: target.x - halfW,
right: target.x + halfW,
top: target.y - 2 * target.headRadius,
bottom: target.y + target.torsoH,
};
}
/** Whether two targets' bounding boxes overlap. */
export function boundsOverlap(a: AimTrainerTargetBounds, b: AimTrainerTargetBounds): boolean {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
export interface CreateAimTrainerTargetOptions {
readonly civilian?: boolean;
readonly existingTargets?: readonly AimTrainerTarget[];
}
/** Spawn position re-rolls before giving up and accepting the last roll. */
const SPAWN_POSITION_ATTEMPTS = 24;
/** /**
* Create a target whose whole body (torso rectangle plus the circular head * 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 * 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 * `(x, y - headRadius)` and the torso spans `[y, y + torsoH]`, so the target's
* vertical extent is `[y - 2*headRadius, y + torsoH]`. * vertical extent is `[y - 2*headRadius, y + torsoH]`.
*
* When `existingTargets` is provided, spawn positions overlapping an existing
* target's bounding box are re-rolled up to `SPAWN_POSITION_ATTEMPTS` times so
* targets never appear on top of each other.
*/ */
export function createAimTrainerTarget( export function createAimTrainerTarget(
rng: () => number, rng: () => number,
canvasW: number, canvasW: number,
canvasH: number, canvasH: number,
difficulty: AimTrainerDifficulty, difficulty: AimTrainerDifficulty,
options: CreateAimTrainerTargetOptions = {},
): AimTrainerTarget { ): AimTrainerTarget {
const config = AIM_TRAINER_DIFFICULTIES[difficulty]; const config = AIM_TRAINER_DIFFICULTIES[difficulty];
const kind: AimTrainerTargetKind = options.civilian ? "civilian" : "standard";
const minX = config.torsoW / 2; const minX = config.torsoW / 2;
const maxX = canvasW - config.torsoW / 2; const maxX = canvasW - config.torsoW / 2;
const minY = 2 * config.headRadius; const minY = 2 * config.headRadius;
const maxY = canvasH - config.torsoH; const maxY = canvasH - config.torsoH;
const x = minX + rng() * (maxX - minX); let x = minX + rng() * (maxX - minX);
const y = minY + rng() * (maxY - minY); let y = minY + rng() * (maxY - minY);
if (options.existingTargets && options.existingTargets.length > 0) {
for (let attempt = 1; attempt < SPAWN_POSITION_ATTEMPTS; attempt += 1) {
const candidateBounds = aimTrainerTargetBounds({ x, y, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius });
const overlapsExisting = options.existingTargets.some((existing) => boundsOverlap(candidateBounds, aimTrainerTargetBounds(existing)));
if (!overlapsExisting) break;
x = minX + rng() * (maxX - minX);
y = minY + rng() * (maxY - minY);
}
}
const moving = rng() < config.movingRatio; const moving = rng() < config.movingRatio;
if (!moving) { if (!moving) {
return { x, y, vx: 0, vy: 0, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius }; return { kind, x, y, vx: 0, vy: 0, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius };
} }
const angle = rng() * 2 * Math.PI; const angle = rng() * 2 * Math.PI;
const vx = Math.cos(angle) * config.speed; const vx = Math.cos(angle) * config.speed;
const vy = Math.sin(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 }; return { kind, x, y, vx, vy, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius };
} }
/** /**
@ -200,6 +276,101 @@ export function stepAimTrainerTarget(
return { ...target, x, y, vx, vy }; return { ...target, x, y, vx, vy };
} }
/** Extra separation applied when resolving target collisions, in px. */
export const COLLISION_SEPARATION_EPSILON_PX = 0.25;
function clampTargetX(x: number, torsoW: number, canvasW: number): number {
const minX = torsoW / 2;
const maxX = canvasW - torsoW / 2;
if (x < minX) return minX;
if (x > maxX) return maxX;
return x;
}
function clampTargetY(y: number, headRadius: number, torsoH: number, canvasH: number): number {
const minY = 2 * headRadius;
const maxY = canvasH - torsoH;
if (y < minY) return minY;
if (y > maxY) return maxY;
return y;
}
/**
* Resolve overlaps between all pairs of targets: each overlapping pair is
* separated along the axis of minimum penetration (both bodies shift by half
* the overlap plus a small epsilon), velocities are reflected only when they
* point into the other target, and every body is clamped back inside the
* canvas. Pure: returns new targets, never mutates the input.
*/
export function resolveAimTrainerTargetCollisions(
targets: readonly AimTrainerTarget[],
canvasW: number,
canvasH: number,
): AimTrainerTarget[] {
const resolved = targets.map((target) => ({ ...target }));
for (let i = 0; i < resolved.length; i += 1) {
for (let j = i + 1; j < resolved.length; j += 1) {
const a = resolved[i];
const b = resolved[j];
if (!a || !b) continue;
const boundsA = aimTrainerTargetBounds(a);
const boundsB = aimTrainerTargetBounds(b);
if (!boundsOverlap(boundsA, boundsB)) continue;
const overlapX = Math.min(boundsA.right, boundsB.right) - Math.max(boundsA.left, boundsB.left);
const overlapY = Math.min(boundsA.bottom, boundsB.bottom) - Math.max(boundsA.top, boundsB.top);
if (overlapX < overlapY) {
const shift = overlapX / 2 + COLLISION_SEPARATION_EPSILON_PX;
if (a.x < b.x) {
a.x -= shift;
b.x += shift;
if (a.vx > 0) a.vx = -a.vx;
if (b.vx < 0) b.vx = -b.vx;
} else {
a.x += shift;
b.x -= shift;
if (a.vx < 0) a.vx = -a.vx;
if (b.vx > 0) b.vx = -b.vx;
}
} else {
const shift = overlapY / 2 + COLLISION_SEPARATION_EPSILON_PX;
if (a.y < b.y) {
a.y -= shift;
b.y += shift;
if (a.vy > 0) a.vy = -a.vy;
if (b.vy < 0) b.vy = -b.vy;
} else {
a.y += shift;
b.y -= shift;
if (a.vy < 0) a.vy = -a.vy;
if (b.vy > 0) b.vy = -b.vy;
}
}
a.x = clampTargetX(a.x, a.torsoW, canvasW);
a.y = clampTargetY(a.y, a.headRadius, a.torsoH, canvasH);
b.x = clampTargetX(b.x, b.torsoW, canvasW);
b.y = clampTargetY(b.y, b.headRadius, b.torsoH, canvasH);
}
}
return resolved;
}
/** Step every target by `dt` seconds, then resolve collisions between them. */
export function stepAimTrainerTargets(
targets: readonly AimTrainerTarget[],
dt: number,
canvasW: number,
canvasH: number,
): AimTrainerTarget[] {
const stepped = targets.map((target) => stepAimTrainerTarget(target, dt, canvasW, canvasH));
return resolveAimTrainerTargetCollisions(stepped, canvasW, canvasH);
}
/** /**
* Resolve a click at `(x, y)` against a target. The circular head zone is * 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. * checked first, then the torso rectangle. Returns `null` for a miss.

View file

@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest";
import { import {
ACCURACY_BONUS_MAX, ACCURACY_BONUS_MAX,
AIM_TRAINER_DIFFICULTIES, AIM_TRAINER_DIFFICULTIES,
CIVILIAN_PENALTY,
COMPLETION_XP, COMPLETION_XP,
COURSE_DURATION_MS, COURSE_DURATION_MS,
HEADSHOT_POINTS, HEADSHOT_POINTS,
MAX_CONCURRENT_CIVILIANS,
MAX_CIVILIAN_HITS,
MAX_DESPAWNED_TARGETS, MAX_DESPAWNED_TARGETS,
MAX_HITS, MAX_HITS,
MAX_SHOTS_FIRED, MAX_SHOTS_FIRED,
@ -14,14 +17,18 @@ import {
TORSO_POINTS, TORSO_POINTS,
XP_CAP, XP_CAP,
type AimTrainerTarget, type AimTrainerTarget,
aimTrainerTargetBounds,
boundsOverlap,
calculateAimTrainerAccuracy, calculateAimTrainerAccuracy,
calculateAimTrainerScore, calculateAimTrainerScore,
calculateAimTrainerXp, calculateAimTrainerXp,
createAimTrainerTarget, createAimTrainerTarget,
isAimTrainerDifficulty, isAimTrainerDifficulty,
resolveAimTrainerHitZone, resolveAimTrainerHitZone,
resolveAimTrainerTargetCollisions,
resolveAimTrainerTargetHit, resolveAimTrainerTargetHit,
stepAimTrainerTarget, stepAimTrainerTarget,
stepAimTrainerTargets,
} from "@/lib/aim-trainer"; } from "@/lib/aim-trainer";
/** Deterministic LCG-style RNG so target creation is reproducible across runs. */ /** Deterministic LCG-style RNG so target creation is reproducible across runs. */
@ -38,6 +45,7 @@ describe("Aim Trainer constants", () => {
expect(COURSE_DURATION_MS).toBe(30_000); expect(COURSE_DURATION_MS).toBe(30_000);
expect(TORSO_POINTS).toBe(100); expect(TORSO_POINTS).toBe(100);
expect(HEADSHOT_POINTS).toBe(250); expect(HEADSHOT_POINTS).toBe(250);
expect(CIVILIAN_PENALTY).toBe(100);
expect(ACCURACY_BONUS_MAX).toBe(500); expect(ACCURACY_BONUS_MAX).toBe(500);
expect(POINTS_PER_XP).toBe(500); expect(POINTS_PER_XP).toBe(500);
expect(SCORE_XP_RATE).toBe(0.125); expect(SCORE_XP_RATE).toBe(0.125);
@ -50,6 +58,8 @@ describe("Aim Trainer constants", () => {
expect(MAX_SHOTS_FIRED).toBe(500); expect(MAX_SHOTS_FIRED).toBe(500);
expect(MAX_HITS).toBe(180); expect(MAX_HITS).toBe(180);
expect(MAX_DESPAWNED_TARGETS).toBe(60); expect(MAX_DESPAWNED_TARGETS).toBe(60);
expect(MAX_CONCURRENT_CIVILIANS).toBe(1);
expect(MAX_CIVILIAN_HITS).toBe(30);
}); });
}); });
@ -65,6 +75,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
targetLifetimeMs: 2000, targetLifetimeMs: 2000,
replacementDelayMs: 400, replacementDelayMs: 400,
maxConcurrentTargets: 1, maxConcurrentTargets: 1,
civilianChance: 0.2,
}, },
veteran: { veteran: {
torsoW: 62, torsoW: 62,
@ -75,6 +86,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
targetLifetimeMs: 1500, targetLifetimeMs: 1500,
replacementDelayMs: 300, replacementDelayMs: 300,
maxConcurrentTargets: 2, maxConcurrentTargets: 2,
civilianChance: 0.3,
}, },
elite: { elite: {
torsoW: 50, torsoW: 50,
@ -85,6 +97,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
targetLifetimeMs: 1100, targetLifetimeMs: 1100,
replacementDelayMs: 200, replacementDelayMs: 200,
maxConcurrentTargets: 2, maxConcurrentTargets: 2,
civilianChance: 0.35,
}, },
}); });
}); });
@ -99,6 +112,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
expect(recruit.targetLifetimeMs).toBe(2000); expect(recruit.targetLifetimeMs).toBe(2000);
expect(recruit.replacementDelayMs).toBe(400); expect(recruit.replacementDelayMs).toBe(400);
expect(recruit.maxConcurrentTargets).toBe(1); expect(recruit.maxConcurrentTargets).toBe(1);
expect(recruit.civilianChance).toBe(0.2);
const veteran = AIM_TRAINER_DIFFICULTIES.veteran; const veteran = AIM_TRAINER_DIFFICULTIES.veteran;
expect(veteran.torsoW).toBe(62); expect(veteran.torsoW).toBe(62);
@ -109,6 +123,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
expect(veteran.targetLifetimeMs).toBe(1500); expect(veteran.targetLifetimeMs).toBe(1500);
expect(veteran.replacementDelayMs).toBe(300); expect(veteran.replacementDelayMs).toBe(300);
expect(veteran.maxConcurrentTargets).toBe(2); expect(veteran.maxConcurrentTargets).toBe(2);
expect(veteran.civilianChance).toBe(0.3);
const elite = AIM_TRAINER_DIFFICULTIES.elite; const elite = AIM_TRAINER_DIFFICULTIES.elite;
expect(elite.torsoW).toBe(50); expect(elite.torsoW).toBe(50);
@ -119,6 +134,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
expect(elite.targetLifetimeMs).toBe(1100); expect(elite.targetLifetimeMs).toBe(1100);
expect(elite.replacementDelayMs).toBe(200); expect(elite.replacementDelayMs).toBe(200);
expect(elite.maxConcurrentTargets).toBe(2); expect(elite.maxConcurrentTargets).toBe(2);
expect(elite.civilianChance).toBe(0.35);
}); });
}); });
@ -166,6 +182,23 @@ describe("calculateAimTrainerScore", () => {
// 1 headshot, 1 shot, no despawned targets -> 250 + round(1*500) = 750. // 1 headshot, 1 shot, no despawned targets -> 250 + round(1*500) = 750.
expect(calculateAimTrainerScore(1, 0, 1, 0)).toBe(750); expect(calculateAimTrainerScore(1, 0, 1, 0)).toBe(750);
}); });
it("subtracts the civilian penalty from the hit score", () => {
// 1 headshot + 1 torso = 350, minus 2 civilian hits = -200.
// Accuracy counts only real hits: 2/2 shots -> round(1*500) = 500. Total 650.
expect(calculateAimTrainerScore(1, 1, 2, 0, 2)).toBe(650);
});
it("clamps the total to zero when civilian penalties outweigh the hits", () => {
// No real hits, 3 civilian hits -> -300 + round(0*500) clamped to 0.
expect(calculateAimTrainerScore(0, 0, 3, 0, 3)).toBe(0);
});
it("excludes civilian hits from the accuracy denominator numerator", () => {
// 1 torso + 1 civilian, 2 shots: accuracy = 1/2 (civilian not a hit).
// 100 - 100 + round(0.5*500) = 250.
expect(calculateAimTrainerScore(0, 1, 2, 0, 1)).toBe(250);
});
}); });
describe("calculateAimTrainerXp", () => { describe("calculateAimTrainerXp", () => {
@ -206,6 +239,36 @@ describe("createAimTrainerTarget", () => {
expect(typeof target.y).toBe("number"); expect(typeof target.y).toBe("number");
expect(typeof target.vx).toBe("number"); expect(typeof target.vx).toBe("number");
expect(typeof target.vy).toBe("number"); expect(typeof target.vy).toBe("number");
expect(target.kind).toBe("standard");
});
it("marks civilian targets when requested", () => {
const target = createAimTrainerTarget(seededRng(7), canvasW, canvasH, "veteran", {
civilian: true,
});
expect(target.kind).toBe("civilian");
});
it("avoids spawning on top of existing targets", () => {
const blocker: AimTrainerTarget = {
x: 400,
y: 300,
vx: 0,
vy: 0,
moving: false,
torsoW: 62,
torsoH: 100,
headRadius: 18,
kind: "standard",
};
for (let seed = 0; seed < 50; seed++) {
const target = createAimTrainerTarget(seededRng(seed), canvasW, canvasH, "veteran", {
existingTargets: [blocker],
});
expect(
boundsOverlap(aimTrainerTargetBounds(target), aimTrainerTargetBounds(blocker)),
).toBe(false);
}
}); });
it("is deterministic for the same seed across repeated calls", () => { it("is deterministic for the same seed across repeated calls", () => {
@ -254,7 +317,7 @@ describe("createAimTrainerTarget", () => {
}); });
describe("resolveAimTrainerHitZone", () => { describe("resolveAimTrainerHitZone", () => {
const target = { const target: AimTrainerTarget = {
x: 400, x: 400,
y: 300, y: 300,
vx: 0, vx: 0,
@ -263,6 +326,7 @@ describe("resolveAimTrainerHitZone", () => {
torsoW: 62, torsoW: 62,
torsoH: 100, torsoH: 100,
headRadius: 18, headRadius: 18,
kind: "standard",
}; };
it("resolves a head hit inside the head circle", () => { it("resolves a head hit inside the head circle", () => {
@ -296,6 +360,7 @@ describe("resolveAimTrainerTargetHit", () => {
torsoW: 62, torsoW: 62,
torsoH: 100, torsoH: 100,
headRadius: 18, headRadius: 18,
kind: "standard",
}; };
const frontTarget: AimTrainerTarget = { const frontTarget: AimTrainerTarget = {
...rearTarget, ...rearTarget,
@ -330,7 +395,7 @@ describe("stepAimTrainerTarget", () => {
const canvasH = 600; const canvasH = 600;
it("moves a moving target by velocity times dt", () => { it("moves a moving target by velocity times dt", () => {
const target = { const target: AimTrainerTarget = {
x: 400, x: 400,
y: 300, y: 300,
vx: 100, vx: 100,
@ -339,6 +404,7 @@ describe("stepAimTrainerTarget", () => {
torsoW: 62, torsoW: 62,
torsoH: 100, torsoH: 100,
headRadius: 18, headRadius: 18,
kind: "standard",
}; };
const stepped = stepAimTrainerTarget(target, 0.5, canvasW, canvasH); const stepped = stepAimTrainerTarget(target, 0.5, canvasW, canvasH);
expect(stepped.x).toBeCloseTo(450); expect(stepped.x).toBeCloseTo(450);
@ -346,7 +412,7 @@ describe("stepAimTrainerTarget", () => {
}); });
it("leaves a stationary target in place", () => { it("leaves a stationary target in place", () => {
const target = { const target: AimTrainerTarget = {
x: 400, x: 400,
y: 300, y: 300,
vx: 100, vx: 100,
@ -355,6 +421,7 @@ describe("stepAimTrainerTarget", () => {
torsoW: 62, torsoW: 62,
torsoH: 100, torsoH: 100,
headRadius: 18, headRadius: 18,
kind: "standard",
}; };
const stepped = stepAimTrainerTarget(target, 1, canvasW, canvasH); const stepped = stepAimTrainerTarget(target, 1, canvasW, canvasH);
expect(stepped.x).toBe(400); expect(stepped.x).toBe(400);
@ -362,7 +429,7 @@ describe("stepAimTrainerTarget", () => {
}); });
it("bounces off the horizontal bounds and keeps the target inside", () => { it("bounces off the horizontal bounds and keeps the target inside", () => {
const target = { const target: AimTrainerTarget = {
x: 400, x: 400,
y: 300, y: 300,
vx: 1000, vx: 1000,
@ -371,6 +438,7 @@ describe("stepAimTrainerTarget", () => {
torsoW: 62, torsoW: 62,
torsoH: 100, torsoH: 100,
headRadius: 18, headRadius: 18,
kind: "standard",
}; };
let current = target; let current = target;
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
@ -387,7 +455,7 @@ describe("stepAimTrainerTarget", () => {
}); });
it("bounces off the vertical bounds and keeps the target inside", () => { it("bounces off the vertical bounds and keeps the target inside", () => {
const target = { const target: AimTrainerTarget = {
x: 400, x: 400,
y: 300, y: 300,
vx: 0, vx: 0,
@ -396,6 +464,7 @@ describe("stepAimTrainerTarget", () => {
torsoW: 62, torsoW: 62,
torsoH: 100, torsoH: 100,
headRadius: 18, headRadius: 18,
kind: "standard",
}; };
let current = target; let current = target;
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
@ -407,3 +476,98 @@ describe("stepAimTrainerTarget", () => {
} }
}); });
}); });
describe("resolveAimTrainerTargetCollisions", () => {
const canvasW = 800;
const canvasH = 600;
it("bounces approaching targets apart without ever overlapping", () => {
let a: AimTrainerTarget = {
x: 300,
y: 300,
vx: 200,
vy: 0,
moving: true,
torsoW: 62,
torsoH: 100,
headRadius: 18,
kind: "standard",
};
let b: AimTrainerTarget = {
x: 400,
y: 300,
vx: -200,
vy: 0,
moving: true,
torsoW: 62,
torsoH: 100,
headRadius: 18,
kind: "standard",
};
for (let i = 0; i < 200; i++) {
const stepped = stepAimTrainerTargets([a, b], 0.05, canvasW, canvasH);
if (!stepped[0] || !stepped[1]) throw new Error("expected two stepped targets");
a = stepped[0];
b = stepped[1];
expect(boundsOverlap(aimTrainerTargetBounds(a), aimTrainerTargetBounds(b))).toBe(false);
}
});
it("reflects velocity components that point into the other target", () => {
const a: AimTrainerTarget = {
x: 380,
y: 300,
vx: 100,
vy: 0,
moving: true,
torsoW: 62,
torsoH: 100,
headRadius: 18,
kind: "standard",
};
const b: AimTrainerTarget = {
x: 420,
y: 300,
vx: -100,
vy: 0,
moving: true,
torsoW: 62,
torsoH: 100,
headRadius: 18,
kind: "standard",
};
const resolved = resolveAimTrainerTargetCollisions([a, b], canvasW, canvasH);
expect(resolved[0]?.vx).toBe(-100);
expect(resolved[1]?.vx).toBe(100);
});
it("separates stationary targets without giving them velocity", () => {
const a: AimTrainerTarget = {
x: 400,
y: 300,
vx: 0,
vy: 0,
moving: false,
torsoW: 62,
torsoH: 100,
headRadius: 18,
kind: "standard",
};
const b: AimTrainerTarget = {
x: 420,
y: 300,
vx: 0,
vy: 0,
moving: false,
torsoW: 62,
torsoH: 100,
headRadius: 18,
kind: "standard",
};
const resolved = resolveAimTrainerTargetCollisions([a, b], canvasW, canvasH);
expect(resolved[0]?.vx).toBe(0);
expect(resolved[0]?.vy).toBe(0);
expect(resolved[1]?.vx).toBe(0);
expect(resolved[1]?.vy).toBe(0);
});
});