1
0
Fork 0
polaris-task-force/src/lib/aim-trainer.ts
Z8MB1E 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

422 lines
12 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;
/** Chance that a standard-target spawn event also puts a civilian on the range. */
readonly civilianChance: 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,
civilianChance: 0.2,
speed: 80,
maxConcurrentTargets: 1,
targetLifetimeMs: 2000,
replacementDelayMs: 400,
},
veteran: {
torsoW: 62,
torsoH: 100,
headRadius: 18,
movingRatio: 0.55,
civilianChance: 0.3,
speed: 140,
maxConcurrentTargets: 2,
targetLifetimeMs: 1500,
replacementDelayMs: 300,
},
elite: {
torsoW: 50,
torsoH: 82,
headRadius: 15,
movingRatio: 0.8,
civilianChance: 0.35,
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 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_HITS = 180;
export const MAX_DESPAWNED_TARGETS = 60;
export const MAX_CIVILIAN_HITS = 30;
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,
civilianHits = 0,
): number {
const accuracy = calculateAimTrainerAccuracy(headshots + torsoHits, shotsFired, despawnedUnhitTargets);
return Math.max(
0,
headshots * HEADSHOT_POINTS +
torsoHits * TORSO_POINTS -
civilianHits * CIVILIAN_PENALTY +
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 type AimTrainerTargetKind = "standard" | "civilian";
export interface AimTrainerTarget {
readonly kind: AimTrainerTargetKind;
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";
}
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
* 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]`.
*
* 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(
rng: () => number,
canvasW: number,
canvasH: number,
difficulty: AimTrainerDifficulty,
options: CreateAimTrainerTargetOptions = {},
): AimTrainerTarget {
const config = AIM_TRAINER_DIFFICULTIES[difficulty];
const kind: AimTrainerTargetKind = options.civilian ? "civilian" : "standard";
const minX = config.torsoW / 2;
const maxX = canvasW - config.torsoW / 2;
const minY = 2 * config.headRadius;
const maxY = canvasH - config.torsoH;
let x = minX + rng() * (maxX - minX);
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;
if (!moving) {
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 vx = Math.cos(angle) * config.speed;
const vy = Math.sin(angle) * config.speed;
return { kind, 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 };
}
/** 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
* 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;
}