import { describe, expect, it } from "vitest"; import { ACCURACY_BONUS_MAX, AIM_TRAINER_DIFFICULTIES, CIVILIAN_PENALTY, COMPLETION_XP, COURSE_DURATION_MS, HEADSHOT_POINTS, MAX_CONCURRENT_CIVILIANS, MAX_CIVILIAN_HITS, MAX_DESPAWNED_TARGETS, MAX_HITS, MAX_SHOTS_FIRED, NEW_TIER_RECORD_XP, POINTS_PER_XP, SCORE_XP_RATE, TORSO_POINTS, XP_CAP, type AimTrainerTarget, aimTrainerTargetBounds, boundsOverlap, calculateAimTrainerAccuracy, calculateAimTrainerScore, calculateAimTrainerXp, createAimTrainerTarget, isAimTrainerDifficulty, resolveAimTrainerHitZone, resolveAimTrainerTargetCollisions, resolveAimTrainerTargetHit, stepAimTrainerTarget, stepAimTrainerTargets, } 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(CIVILIAN_PENALTY).toBe(100); 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); expect(MAX_CONCURRENT_CIVILIANS).toBe(1); expect(MAX_CIVILIAN_HITS).toBe(30); }); }); 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, civilianChance: 0.2, }, veteran: { torsoW: 62, torsoH: 100, headRadius: 18, movingRatio: 0.55, speed: 140, targetLifetimeMs: 1500, replacementDelayMs: 300, maxConcurrentTargets: 2, civilianChance: 0.3, }, elite: { torsoW: 50, torsoH: 82, headRadius: 15, movingRatio: 0.8, speed: 220, targetLifetimeMs: 1100, replacementDelayMs: 200, maxConcurrentTargets: 2, civilianChance: 0.35, }, }); }); 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); expect(recruit.civilianChance).toBe(0.2); 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); expect(veteran.civilianChance).toBe(0.3); 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); expect(elite.civilianChance).toBe(0.35); }); }); 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); }); 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", () => { 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"); 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", () => { // 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: AimTrainerTarget = { x: 400, y: 300, vx: 0, vy: 0, moving: false, torsoW: 62, torsoH: 100, headRadius: 18, kind: "standard", }; 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, kind: "standard", }; 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: AimTrainerTarget = { x: 400, y: 300, vx: 100, vy: 0, moving: true, torsoW: 62, torsoH: 100, headRadius: 18, kind: "standard", }; 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: AimTrainerTarget = { x: 400, y: 300, vx: 100, vy: 50, moving: false, torsoW: 62, torsoH: 100, headRadius: 18, kind: "standard", }; 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: AimTrainerTarget = { x: 400, y: 300, vx: 1000, vy: 0, moving: true, torsoW: 62, torsoH: 100, headRadius: 18, kind: "standard", }; 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: AimTrainerTarget = { x: 400, y: 300, vx: 0, vy: 1000, moving: true, torsoW: 62, torsoH: 100, headRadius: 18, kind: "standard", }; 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); } }); }); 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); }); });