1
0
Fork 0
polaris-task-force/src/lib/aim-trainer.ts
Z8MB1E 73d689a680 feat(qualification): add aim trainer rules
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-03 23:36:22 -04:00

251 lines
6.6 KiB
TypeScript

/**
* 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;
}