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>
This commit is contained in:
parent
72ef20a044
commit
73d689a680
2 changed files with 660 additions and 0 deletions
251
src/lib/aim-trainer.ts
Normal file
251
src/lib/aim-trainer.ts
Normal 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;
|
||||
}
|
||||
409
tests/int/aim-trainer.int.spec.ts
Normal file
409
tests/int/aim-trainer.int.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue