1
0
Fork 0
polaris-task-force/tests/int/call-for-fire.int.spec.ts
2026-09-08 10:23:11 -04:00

237 lines
8.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
BEST_MISSIONS_FIELDS,
CALL_FOR_FIRE_DIFFICULTIES,
MAX_MISSIONS_PER_RUN,
METERS_PER_CELL,
applyCorrection,
bearingOf,
calculateScore,
calculateXp,
createScenario,
distanceMeters,
euclideanDistance,
gridDesignation,
isCallForFireDifficulty,
isEffectiveHit,
observerReport,
randomTargetPosition,
rangeBandLabel,
rangeBandOf,
} from "@/lib/call-for-fire";
function seededRng(seed: number): () => number {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
}
describe("call-for-fire difficulty configs", () => {
it("has three difficulties with sane bounds", () => {
expect(Object.keys(CALL_FOR_FIRE_DIFFICULTIES).sort()).toEqual([
"checkpoint",
"compound",
"patrol",
]);
for (const key of Object.keys(CALL_FOR_FIRE_DIFFICULTIES) as Array<
keyof typeof CALL_FOR_FIRE_DIFFICULTIES
>) {
const config = CALL_FOR_FIRE_DIFFICULTIES[key];
expect(config.maxRounds).toBeLessThanOrEqual(MAX_MISSIONS_PER_RUN);
expect(config.parRounds).toBeLessThan(config.maxRounds);
expect(config.lethalRadiusM).toBeGreaterThan(0);
expect(config.lethalRadiusM).toBeLessThan((config.size / 2) * METERS_PER_CELL);
expect(config.targetInset).toBeGreaterThan(config.lethalRadiusM / METERS_PER_CELL);
expect(config.parSeconds).toBeLessThan(config.maxSeconds);
expect(config.spotMinCells).toBeGreaterThan(0);
expect(config.spotMaxCells).toBeGreaterThan(config.spotMinCells);
}
});
it("recognizes valid difficulty keys only", () => {
expect(isCallForFireDifficulty("patrol")).toBe(true);
expect(isCallForFireDifficulty("checkpoint")).toBe(true);
expect(isCallForFireDifficulty("compound")).toBe(true);
expect(isCallForFireDifficulty("training")).toBe(false);
expect(isCallForFireDifficulty("")).toBe(false);
});
it("maps difficulties to profile stat fields", () => {
expect(BEST_MISSIONS_FIELDS.patrol).toBe("bestPatrolMissions");
expect(BEST_MISSIONS_FIELDS.checkpoint).toBe("bestCheckpointMissions");
expect(BEST_MISSIONS_FIELDS.compound).toBe("bestCompoundMissions");
});
});
describe("call-for-fire geometry", () => {
it("computes euclidean distance", () => {
expect(euclideanDistance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5);
expect(euclideanDistance({ x: 2, y: 2 }, { x: 2, y: 2 })).toBe(0);
});
it("converts cells to metres", () => {
expect(distanceMeters({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(250);
expect(METERS_PER_CELL).toBe(50);
});
it("counts impacts within the lethal radius as effective", () => {
const target = { x: 5, y: 5 };
// One cell = 50m; the diagonal of one cell is ~70.7m, inside the 75m patrol radius.
expect(isEffectiveHit(target, { x: 5, y: 5 }, 75)).toBe(true);
expect(isEffectiveHit(target, { x: 5, y: 4 }, 75)).toBe(true);
expect(isEffectiveHit(target, { x: 6, y: 6 }, 75)).toBe(true);
// Two cells straight out is 100m, well outside the radius.
expect(isEffectiveHit(target, { x: 7, y: 5 }, 75)).toBe(false);
expect(isEffectiveHit(target, { x: 7, y: 6 }, 75)).toBe(false);
});
it("reports compass bearings with north up", () => {
const target = { x: 5, y: 5 };
expect(bearingOf(target, { x: 5, y: 6 })).toBe("N");
expect(bearingOf(target, { x: 5, y: 4 })).toBe("S");
expect(bearingOf(target, { x: 4, y: 5 })).toBe("E");
expect(bearingOf(target, { x: 6, y: 5 })).toBe("W");
expect(bearingOf(target, { x: 4, y: 4 })).toBe("SE");
expect(bearingOf(target, { x: 6, y: 6 })).toBe("NW");
});
it("returns north for a direct overlay impact", () => {
expect(bearingOf({ x: 3, y: 3 }, { x: 3, y: 3 })).toBe("N");
});
it("buckets ranges into bands", () => {
const target = { x: 0, y: 0 };
expect(rangeBandOf(target, { x: 2, y: 0 })).toBe("dangerClose");
expect(rangeBandOf(target, { x: 3, y: 0 })).toBe("close");
expect(rangeBandOf(target, { x: 4, y: 4 })).toBe("medium");
expect(rangeBandOf(target, { x: 9, y: 0 })).toBe("far");
expect(rangeBandLabel("dangerClose")).toBe("danger close");
});
it("builds a radio-style report for misses", () => {
const report = observerReport({ x: 5, y: 5 }, { x: 5, y: 8 });
expect(report.bearing).toBe("N");
expect(report.band).toBe("close");
expect(report.message).toBe("Shot, over. Target bearing N, range close.");
});
});
describe("call-for-fire corrections", () => {
it("moves impacts north on ADD and east on RIGHT", () => {
const size = 10;
// ADD 200 (4 cells north) and RIGHT 100 (2 cells east).
expect(applyCorrection({ x: 5, y: 7 }, { rangeM: 200, deflectionM: 100 }, size)).toEqual({
x: 7,
y: 3,
});
// DROP 100 (2 cells south) and LEFT 150 (3 cells west) move the opposite way.
expect(applyCorrection({ x: 5, y: 5 }, { rangeM: -100, deflectionM: -150 }, size)).toEqual({
x: 2,
y: 7,
});
});
it("rounds metres to the nearest cell", () => {
expect(applyCorrection({ x: 5, y: 5 }, { rangeM: 74, deflectionM: 26 }, 10)).toEqual({
x: 6,
y: 4,
});
});
it("clamps corrected impacts to the board", () => {
expect(applyCorrection({ x: 0, y: 0 }, { rangeM: 5000, deflectionM: -5000 }, 10)).toEqual({
x: 0,
y: 0,
});
expect(applyCorrection({ x: 9, y: 9 }, { rangeM: -5000, deflectionM: 5000 }, 10)).toEqual({
x: 9,
y: 9,
});
});
});
describe("call-for-fire scenarios", () => {
it("places the spotting round within the difficulty offset band", () => {
for (const key of ["patrol", "checkpoint", "compound"] as const) {
const { size, spotMinCells, spotMaxCells, targetInset } = CALL_FOR_FIRE_DIFFICULTIES[key];
for (let i = 0; i < 200; i++) {
const { target, spotting } = createScenario(key);
const offsetCells = Math.max(
Math.abs(target.x - spotting.x),
Math.abs(target.y - spotting.y),
);
// Chebyshev distance stays in band (rounding may add one cell).
expect(offsetCells).toBeLessThanOrEqual(spotMaxCells + 1);
// Targets stay interior; spotting rounds stay on the board.
expect(target.x).toBeGreaterThanOrEqual(targetInset);
expect(spotting.x).toBeGreaterThanOrEqual(0);
expect(spotting.x).toBeLessThan(size);
expect(spotting.y).toBeGreaterThanOrEqual(0);
expect(spotting.y).toBeLessThan(size);
}
}
});
it("is deterministic for a seeded rng", () => {
const a = createScenario("patrol", seededRng(42));
const b = createScenario("patrol", seededRng(42));
expect(a).toEqual(b);
});
it("generates different scenarios across seeds", () => {
const seen = new Set<string>();
for (let seed = 1; seed <= 20; seed++) {
seen.add(JSON.stringify(createScenario("checkpoint", seededRng(seed))));
}
expect(seen.size).toBeGreaterThan(5);
});
it("keeps target placement inside the interior inset region", () => {
for (const key of ["patrol", "checkpoint", "compound"] as const) {
for (let i = 0; i < 200; i++) {
const point = randomTargetPosition(key);
const { size, targetInset } = CALL_FOR_FIRE_DIFFICULTIES[key];
expect(point.x).toBeGreaterThanOrEqual(targetInset);
expect(point.x).toBeLessThanOrEqual(size - 1 - targetInset);
expect(point.y).toBeGreaterThanOrEqual(targetInset);
expect(point.y).toBeLessThanOrEqual(size - 1 - targetInset);
}
}
});
it("formats grid designations", () => {
expect(gridDesignation({ x: 0, y: 0 })).toBe("A1");
expect(gridDesignation({ x: 4, y: 6 })).toBe("E7");
});
});
describe("call-for-fire scoring", () => {
it("awards base plus round, time, and accuracy bonuses on a win", () => {
// Patrol: base 100, 4 rounds under par 4? none; 60s under par 90; 75m accuracy.
expect(calculateScore("patrol", 4, 30, 0, true)).toBe(100 + 60 + 75);
// Checkpoint: base 300 + 2 rounds under par * 40 + 0 time + 50 accuracy.
expect(calculateScore("checkpoint", 4, 120, 0, true)).toBe(300 + 2 * 40 + 50);
// Compound: base 600 + 3 rounds under par * 60 + 20s + 25 accuracy.
expect(calculateScore("compound", 5, 130, 25, true)).toBe(600 + 3 * 60 + 20 + 25);
});
it("never awards negative bonuses for slow or wasteful runs", () => {
expect(calculateScore("patrol", 8, 180, 75, true)).toBe(100);
});
it("scores losses as zero", () => {
expect(calculateScore("patrol", 2, 30, 0, false)).toBe(0);
});
it("derives xp from score with a floor of one on a win", () => {
expect(calculateXp(149, true)).toBe(2);
expect(calculateXp(100, true)).toBe(2);
expect(calculateXp(50, true)).toBe(1);
expect(calculateXp(10, true)).toBe(1);
expect(calculateXp(600, false)).toBe(0);
});
});