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>
This commit is contained in:
parent
cfa29a64ea
commit
7d6a4bd671
2 changed files with 347 additions and 12 deletions
|
|
@ -13,6 +13,8 @@ export interface AimTrainerTierConfig {
|
||||||
readonly torsoH: number;
|
readonly torsoH: number;
|
||||||
readonly headRadius: number;
|
readonly headRadius: number;
|
||||||
readonly movingRatio: 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 speed: number;
|
||||||
readonly maxConcurrentTargets: number;
|
readonly maxConcurrentTargets: number;
|
||||||
readonly targetLifetimeMs: number;
|
readonly targetLifetimeMs: number;
|
||||||
|
|
@ -27,6 +29,7 @@ export const AIM_TRAINER_DIFFICULTIES: Readonly<
|
||||||
torsoH: 118,
|
torsoH: 118,
|
||||||
headRadius: 21,
|
headRadius: 21,
|
||||||
movingRatio: 0.25,
|
movingRatio: 0.25,
|
||||||
|
civilianChance: 0.2,
|
||||||
speed: 80,
|
speed: 80,
|
||||||
maxConcurrentTargets: 1,
|
maxConcurrentTargets: 1,
|
||||||
targetLifetimeMs: 2000,
|
targetLifetimeMs: 2000,
|
||||||
|
|
@ -37,6 +40,7 @@ export const AIM_TRAINER_DIFFICULTIES: Readonly<
|
||||||
torsoH: 100,
|
torsoH: 100,
|
||||||
headRadius: 18,
|
headRadius: 18,
|
||||||
movingRatio: 0.55,
|
movingRatio: 0.55,
|
||||||
|
civilianChance: 0.3,
|
||||||
speed: 140,
|
speed: 140,
|
||||||
maxConcurrentTargets: 2,
|
maxConcurrentTargets: 2,
|
||||||
targetLifetimeMs: 1500,
|
targetLifetimeMs: 1500,
|
||||||
|
|
@ -47,6 +51,7 @@ export const AIM_TRAINER_DIFFICULTIES: Readonly<
|
||||||
torsoH: 82,
|
torsoH: 82,
|
||||||
headRadius: 15,
|
headRadius: 15,
|
||||||
movingRatio: 0.8,
|
movingRatio: 0.8,
|
||||||
|
civilianChance: 0.35,
|
||||||
speed: 220,
|
speed: 220,
|
||||||
maxConcurrentTargets: 2,
|
maxConcurrentTargets: 2,
|
||||||
targetLifetimeMs: 1100,
|
targetLifetimeMs: 1100,
|
||||||
|
|
@ -65,9 +70,14 @@ export const COMPLETION_XP = 1;
|
||||||
export const NEW_TIER_RECORD_XP = 2;
|
export const NEW_TIER_RECORD_XP = 2;
|
||||||
export const XP_CAP = 50;
|
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_SHOTS_FIRED = 500;
|
||||||
export const MAX_HITS = 180;
|
export const MAX_HITS = 180;
|
||||||
export const MAX_DESPAWNED_TARGETS = 60;
|
export const MAX_DESPAWNED_TARGETS = 60;
|
||||||
|
export const MAX_CIVILIAN_HITS = 30;
|
||||||
|
|
||||||
const DIFFICULTY_VALUES: readonly AimTrainerDifficulty[] = ["recruit", "veteran", "elite"];
|
const DIFFICULTY_VALUES: readonly AimTrainerDifficulty[] = ["recruit", "veteran", "elite"];
|
||||||
|
|
||||||
|
|
@ -93,12 +103,15 @@ export function calculateAimTrainerScore(
|
||||||
torsoHits: number,
|
torsoHits: number,
|
||||||
shotsFired: number,
|
shotsFired: number,
|
||||||
despawnedUnhitTargets: number,
|
despawnedUnhitTargets: number,
|
||||||
|
civilianHits = 0,
|
||||||
): number {
|
): number {
|
||||||
const accuracy = calculateAimTrainerAccuracy(headshots + torsoHits, shotsFired, despawnedUnhitTargets);
|
const accuracy = calculateAimTrainerAccuracy(headshots + torsoHits, shotsFired, despawnedUnhitTargets);
|
||||||
return (
|
return Math.max(
|
||||||
|
0,
|
||||||
headshots * HEADSHOT_POINTS +
|
headshots * HEADSHOT_POINTS +
|
||||||
torsoHits * TORSO_POINTS +
|
torsoHits * TORSO_POINTS -
|
||||||
Math.round(accuracy * ACCURACY_BONUS_MAX)
|
civilianHits * CIVILIAN_PENALTY +
|
||||||
|
Math.round(accuracy * ACCURACY_BONUS_MAX),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +123,10 @@ export function calculateAimTrainerXp(score: number, isNewTierBest: boolean): nu
|
||||||
return Math.min(XP_CAP, xp);
|
return Math.min(XP_CAP, xp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AimTrainerTargetKind = "standard" | "civilian";
|
||||||
|
|
||||||
export interface AimTrainerTarget {
|
export interface AimTrainerTarget {
|
||||||
|
readonly kind: AimTrainerTargetKind;
|
||||||
readonly x: number;
|
readonly x: number;
|
||||||
readonly y: number;
|
readonly y: number;
|
||||||
readonly vx: number;
|
readonly vx: number;
|
||||||
|
|
@ -126,36 +142,96 @@ export interface AimTrainerTargetHit {
|
||||||
readonly zone: "head" | "torso";
|
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
|
* 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
|
* 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
|
* `(x, y - headRadius)` and the torso spans `[y, y + torsoH]`, so the target's
|
||||||
* vertical extent is `[y - 2*headRadius, y + torsoH]`.
|
* 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(
|
export function createAimTrainerTarget(
|
||||||
rng: () => number,
|
rng: () => number,
|
||||||
canvasW: number,
|
canvasW: number,
|
||||||
canvasH: number,
|
canvasH: number,
|
||||||
difficulty: AimTrainerDifficulty,
|
difficulty: AimTrainerDifficulty,
|
||||||
|
options: CreateAimTrainerTargetOptions = {},
|
||||||
): AimTrainerTarget {
|
): AimTrainerTarget {
|
||||||
const config = AIM_TRAINER_DIFFICULTIES[difficulty];
|
const config = AIM_TRAINER_DIFFICULTIES[difficulty];
|
||||||
|
const kind: AimTrainerTargetKind = options.civilian ? "civilian" : "standard";
|
||||||
const minX = config.torsoW / 2;
|
const minX = config.torsoW / 2;
|
||||||
const maxX = canvasW - config.torsoW / 2;
|
const maxX = canvasW - config.torsoW / 2;
|
||||||
const minY = 2 * config.headRadius;
|
const minY = 2 * config.headRadius;
|
||||||
const maxY = canvasH - config.torsoH;
|
const maxY = canvasH - config.torsoH;
|
||||||
|
|
||||||
const x = minX + rng() * (maxX - minX);
|
let x = minX + rng() * (maxX - minX);
|
||||||
const y = minY + rng() * (maxY - minY);
|
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;
|
const moving = rng() < config.movingRatio;
|
||||||
if (!moving) {
|
if (!moving) {
|
||||||
return { x, y, vx: 0, vy: 0, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius };
|
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 angle = rng() * 2 * Math.PI;
|
||||||
const vx = Math.cos(angle) * config.speed;
|
const vx = Math.cos(angle) * config.speed;
|
||||||
const vy = Math.sin(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 };
|
return { kind, x, y, vx, vy, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -200,6 +276,101 @@ export function stepAimTrainerTarget(
|
||||||
return { ...target, x, y, vx, 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
|
* 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.
|
* checked first, then the torso rectangle. Returns `null` for a miss.
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
ACCURACY_BONUS_MAX,
|
ACCURACY_BONUS_MAX,
|
||||||
AIM_TRAINER_DIFFICULTIES,
|
AIM_TRAINER_DIFFICULTIES,
|
||||||
|
CIVILIAN_PENALTY,
|
||||||
COMPLETION_XP,
|
COMPLETION_XP,
|
||||||
COURSE_DURATION_MS,
|
COURSE_DURATION_MS,
|
||||||
HEADSHOT_POINTS,
|
HEADSHOT_POINTS,
|
||||||
|
MAX_CONCURRENT_CIVILIANS,
|
||||||
|
MAX_CIVILIAN_HITS,
|
||||||
MAX_DESPAWNED_TARGETS,
|
MAX_DESPAWNED_TARGETS,
|
||||||
MAX_HITS,
|
MAX_HITS,
|
||||||
MAX_SHOTS_FIRED,
|
MAX_SHOTS_FIRED,
|
||||||
|
|
@ -14,14 +17,18 @@ import {
|
||||||
TORSO_POINTS,
|
TORSO_POINTS,
|
||||||
XP_CAP,
|
XP_CAP,
|
||||||
type AimTrainerTarget,
|
type AimTrainerTarget,
|
||||||
|
aimTrainerTargetBounds,
|
||||||
|
boundsOverlap,
|
||||||
calculateAimTrainerAccuracy,
|
calculateAimTrainerAccuracy,
|
||||||
calculateAimTrainerScore,
|
calculateAimTrainerScore,
|
||||||
calculateAimTrainerXp,
|
calculateAimTrainerXp,
|
||||||
createAimTrainerTarget,
|
createAimTrainerTarget,
|
||||||
isAimTrainerDifficulty,
|
isAimTrainerDifficulty,
|
||||||
resolveAimTrainerHitZone,
|
resolveAimTrainerHitZone,
|
||||||
|
resolveAimTrainerTargetCollisions,
|
||||||
resolveAimTrainerTargetHit,
|
resolveAimTrainerTargetHit,
|
||||||
stepAimTrainerTarget,
|
stepAimTrainerTarget,
|
||||||
|
stepAimTrainerTargets,
|
||||||
} from "@/lib/aim-trainer";
|
} from "@/lib/aim-trainer";
|
||||||
|
|
||||||
/** Deterministic LCG-style RNG so target creation is reproducible across runs. */
|
/** Deterministic LCG-style RNG so target creation is reproducible across runs. */
|
||||||
|
|
@ -38,6 +45,7 @@ describe("Aim Trainer constants", () => {
|
||||||
expect(COURSE_DURATION_MS).toBe(30_000);
|
expect(COURSE_DURATION_MS).toBe(30_000);
|
||||||
expect(TORSO_POINTS).toBe(100);
|
expect(TORSO_POINTS).toBe(100);
|
||||||
expect(HEADSHOT_POINTS).toBe(250);
|
expect(HEADSHOT_POINTS).toBe(250);
|
||||||
|
expect(CIVILIAN_PENALTY).toBe(100);
|
||||||
expect(ACCURACY_BONUS_MAX).toBe(500);
|
expect(ACCURACY_BONUS_MAX).toBe(500);
|
||||||
expect(POINTS_PER_XP).toBe(500);
|
expect(POINTS_PER_XP).toBe(500);
|
||||||
expect(SCORE_XP_RATE).toBe(0.125);
|
expect(SCORE_XP_RATE).toBe(0.125);
|
||||||
|
|
@ -50,6 +58,8 @@ describe("Aim Trainer constants", () => {
|
||||||
expect(MAX_SHOTS_FIRED).toBe(500);
|
expect(MAX_SHOTS_FIRED).toBe(500);
|
||||||
expect(MAX_HITS).toBe(180);
|
expect(MAX_HITS).toBe(180);
|
||||||
expect(MAX_DESPAWNED_TARGETS).toBe(60);
|
expect(MAX_DESPAWNED_TARGETS).toBe(60);
|
||||||
|
expect(MAX_CONCURRENT_CIVILIANS).toBe(1);
|
||||||
|
expect(MAX_CIVILIAN_HITS).toBe(30);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -65,6 +75,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
|
||||||
targetLifetimeMs: 2000,
|
targetLifetimeMs: 2000,
|
||||||
replacementDelayMs: 400,
|
replacementDelayMs: 400,
|
||||||
maxConcurrentTargets: 1,
|
maxConcurrentTargets: 1,
|
||||||
|
civilianChance: 0.2,
|
||||||
},
|
},
|
||||||
veteran: {
|
veteran: {
|
||||||
torsoW: 62,
|
torsoW: 62,
|
||||||
|
|
@ -75,6 +86,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
|
||||||
targetLifetimeMs: 1500,
|
targetLifetimeMs: 1500,
|
||||||
replacementDelayMs: 300,
|
replacementDelayMs: 300,
|
||||||
maxConcurrentTargets: 2,
|
maxConcurrentTargets: 2,
|
||||||
|
civilianChance: 0.3,
|
||||||
},
|
},
|
||||||
elite: {
|
elite: {
|
||||||
torsoW: 50,
|
torsoW: 50,
|
||||||
|
|
@ -85,6 +97,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
|
||||||
targetLifetimeMs: 1100,
|
targetLifetimeMs: 1100,
|
||||||
replacementDelayMs: 200,
|
replacementDelayMs: 200,
|
||||||
maxConcurrentTargets: 2,
|
maxConcurrentTargets: 2,
|
||||||
|
civilianChance: 0.35,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -99,6 +112,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
|
||||||
expect(recruit.targetLifetimeMs).toBe(2000);
|
expect(recruit.targetLifetimeMs).toBe(2000);
|
||||||
expect(recruit.replacementDelayMs).toBe(400);
|
expect(recruit.replacementDelayMs).toBe(400);
|
||||||
expect(recruit.maxConcurrentTargets).toBe(1);
|
expect(recruit.maxConcurrentTargets).toBe(1);
|
||||||
|
expect(recruit.civilianChance).toBe(0.2);
|
||||||
|
|
||||||
const veteran = AIM_TRAINER_DIFFICULTIES.veteran;
|
const veteran = AIM_TRAINER_DIFFICULTIES.veteran;
|
||||||
expect(veteran.torsoW).toBe(62);
|
expect(veteran.torsoW).toBe(62);
|
||||||
|
|
@ -109,6 +123,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
|
||||||
expect(veteran.targetLifetimeMs).toBe(1500);
|
expect(veteran.targetLifetimeMs).toBe(1500);
|
||||||
expect(veteran.replacementDelayMs).toBe(300);
|
expect(veteran.replacementDelayMs).toBe(300);
|
||||||
expect(veteran.maxConcurrentTargets).toBe(2);
|
expect(veteran.maxConcurrentTargets).toBe(2);
|
||||||
|
expect(veteran.civilianChance).toBe(0.3);
|
||||||
|
|
||||||
const elite = AIM_TRAINER_DIFFICULTIES.elite;
|
const elite = AIM_TRAINER_DIFFICULTIES.elite;
|
||||||
expect(elite.torsoW).toBe(50);
|
expect(elite.torsoW).toBe(50);
|
||||||
|
|
@ -119,6 +134,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => {
|
||||||
expect(elite.targetLifetimeMs).toBe(1100);
|
expect(elite.targetLifetimeMs).toBe(1100);
|
||||||
expect(elite.replacementDelayMs).toBe(200);
|
expect(elite.replacementDelayMs).toBe(200);
|
||||||
expect(elite.maxConcurrentTargets).toBe(2);
|
expect(elite.maxConcurrentTargets).toBe(2);
|
||||||
|
expect(elite.civilianChance).toBe(0.35);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -166,6 +182,23 @@ describe("calculateAimTrainerScore", () => {
|
||||||
// 1 headshot, 1 shot, no despawned targets -> 250 + round(1*500) = 750.
|
// 1 headshot, 1 shot, no despawned targets -> 250 + round(1*500) = 750.
|
||||||
expect(calculateAimTrainerScore(1, 0, 1, 0)).toBe(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", () => {
|
describe("calculateAimTrainerXp", () => {
|
||||||
|
|
@ -206,6 +239,36 @@ describe("createAimTrainerTarget", () => {
|
||||||
expect(typeof target.y).toBe("number");
|
expect(typeof target.y).toBe("number");
|
||||||
expect(typeof target.vx).toBe("number");
|
expect(typeof target.vx).toBe("number");
|
||||||
expect(typeof target.vy).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", () => {
|
it("is deterministic for the same seed across repeated calls", () => {
|
||||||
|
|
@ -254,7 +317,7 @@ describe("createAimTrainerTarget", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveAimTrainerHitZone", () => {
|
describe("resolveAimTrainerHitZone", () => {
|
||||||
const target = {
|
const target: AimTrainerTarget = {
|
||||||
x: 400,
|
x: 400,
|
||||||
y: 300,
|
y: 300,
|
||||||
vx: 0,
|
vx: 0,
|
||||||
|
|
@ -263,6 +326,7 @@ describe("resolveAimTrainerHitZone", () => {
|
||||||
torsoW: 62,
|
torsoW: 62,
|
||||||
torsoH: 100,
|
torsoH: 100,
|
||||||
headRadius: 18,
|
headRadius: 18,
|
||||||
|
kind: "standard",
|
||||||
};
|
};
|
||||||
|
|
||||||
it("resolves a head hit inside the head circle", () => {
|
it("resolves a head hit inside the head circle", () => {
|
||||||
|
|
@ -296,6 +360,7 @@ describe("resolveAimTrainerTargetHit", () => {
|
||||||
torsoW: 62,
|
torsoW: 62,
|
||||||
torsoH: 100,
|
torsoH: 100,
|
||||||
headRadius: 18,
|
headRadius: 18,
|
||||||
|
kind: "standard",
|
||||||
};
|
};
|
||||||
const frontTarget: AimTrainerTarget = {
|
const frontTarget: AimTrainerTarget = {
|
||||||
...rearTarget,
|
...rearTarget,
|
||||||
|
|
@ -330,7 +395,7 @@ describe("stepAimTrainerTarget", () => {
|
||||||
const canvasH = 600;
|
const canvasH = 600;
|
||||||
|
|
||||||
it("moves a moving target by velocity times dt", () => {
|
it("moves a moving target by velocity times dt", () => {
|
||||||
const target = {
|
const target: AimTrainerTarget = {
|
||||||
x: 400,
|
x: 400,
|
||||||
y: 300,
|
y: 300,
|
||||||
vx: 100,
|
vx: 100,
|
||||||
|
|
@ -339,6 +404,7 @@ describe("stepAimTrainerTarget", () => {
|
||||||
torsoW: 62,
|
torsoW: 62,
|
||||||
torsoH: 100,
|
torsoH: 100,
|
||||||
headRadius: 18,
|
headRadius: 18,
|
||||||
|
kind: "standard",
|
||||||
};
|
};
|
||||||
const stepped = stepAimTrainerTarget(target, 0.5, canvasW, canvasH);
|
const stepped = stepAimTrainerTarget(target, 0.5, canvasW, canvasH);
|
||||||
expect(stepped.x).toBeCloseTo(450);
|
expect(stepped.x).toBeCloseTo(450);
|
||||||
|
|
@ -346,7 +412,7 @@ describe("stepAimTrainerTarget", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a stationary target in place", () => {
|
it("leaves a stationary target in place", () => {
|
||||||
const target = {
|
const target: AimTrainerTarget = {
|
||||||
x: 400,
|
x: 400,
|
||||||
y: 300,
|
y: 300,
|
||||||
vx: 100,
|
vx: 100,
|
||||||
|
|
@ -355,6 +421,7 @@ describe("stepAimTrainerTarget", () => {
|
||||||
torsoW: 62,
|
torsoW: 62,
|
||||||
torsoH: 100,
|
torsoH: 100,
|
||||||
headRadius: 18,
|
headRadius: 18,
|
||||||
|
kind: "standard",
|
||||||
};
|
};
|
||||||
const stepped = stepAimTrainerTarget(target, 1, canvasW, canvasH);
|
const stepped = stepAimTrainerTarget(target, 1, canvasW, canvasH);
|
||||||
expect(stepped.x).toBe(400);
|
expect(stepped.x).toBe(400);
|
||||||
|
|
@ -362,7 +429,7 @@ describe("stepAimTrainerTarget", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bounces off the horizontal bounds and keeps the target inside", () => {
|
it("bounces off the horizontal bounds and keeps the target inside", () => {
|
||||||
const target = {
|
const target: AimTrainerTarget = {
|
||||||
x: 400,
|
x: 400,
|
||||||
y: 300,
|
y: 300,
|
||||||
vx: 1000,
|
vx: 1000,
|
||||||
|
|
@ -371,6 +438,7 @@ describe("stepAimTrainerTarget", () => {
|
||||||
torsoW: 62,
|
torsoW: 62,
|
||||||
torsoH: 100,
|
torsoH: 100,
|
||||||
headRadius: 18,
|
headRadius: 18,
|
||||||
|
kind: "standard",
|
||||||
};
|
};
|
||||||
let current = target;
|
let current = target;
|
||||||
for (let i = 0; i < 1000; i++) {
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
|
@ -387,7 +455,7 @@ describe("stepAimTrainerTarget", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bounces off the vertical bounds and keeps the target inside", () => {
|
it("bounces off the vertical bounds and keeps the target inside", () => {
|
||||||
const target = {
|
const target: AimTrainerTarget = {
|
||||||
x: 400,
|
x: 400,
|
||||||
y: 300,
|
y: 300,
|
||||||
vx: 0,
|
vx: 0,
|
||||||
|
|
@ -396,6 +464,7 @@ describe("stepAimTrainerTarget", () => {
|
||||||
torsoW: 62,
|
torsoW: 62,
|
||||||
torsoH: 100,
|
torsoH: 100,
|
||||||
headRadius: 18,
|
headRadius: 18,
|
||||||
|
kind: "standard",
|
||||||
};
|
};
|
||||||
let current = target;
|
let current = target;
|
||||||
for (let i = 0; i < 1000; i++) {
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
|
@ -407,3 +476,98 @@ describe("stepAimTrainerTarget", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue