diff --git a/src/lib/aim-trainer.ts b/src/lib/aim-trainer.ts new file mode 100644 index 0000000..1282000 --- /dev/null +++ b/src/lib/aim-trainer.ts @@ -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 +> = { + 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; +} diff --git a/tests/int/aim-trainer.int.spec.ts b/tests/int/aim-trainer.int.spec.ts new file mode 100644 index 0000000..9a3cfef --- /dev/null +++ b/tests/int/aim-trainer.int.spec.ts @@ -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); + } + }); +});