feat(minigames): add Call for Fire game rules lib with integration tests
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
caca11a60b
commit
f1d7a6774c
2 changed files with 554 additions and 0 deletions
317
src/lib/call-for-fire.ts
Normal file
317
src/lib/call-for-fire.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
/**
|
||||
* Call for Fire: artillery observer bracketing game.
|
||||
*
|
||||
* A hostile target sits at a hidden grid position. The observer (player)
|
||||
* receives a spotting round that lands offset from the target, then issues
|
||||
* iterative fire corrections ("add 200, right 100") within a limited number
|
||||
* of rounds and a time limit. Each impact returns an observer report
|
||||
* (bearing + range band) until a round lands within the lethal radius.
|
||||
* Score rewards accuracy of the final round (distance from target) and
|
||||
* efficiency (rounds used, time taken).
|
||||
*
|
||||
* Pure module: client-safe, no Payload imports. Server actions use the caps
|
||||
* here to validate submitted results.
|
||||
*/
|
||||
|
||||
export const CALL_FOR_FIRE_GRID_MIN = 0;
|
||||
|
||||
/** Absolute cap on rounds per run across all difficulties (validation). */
|
||||
export const MAX_MISSIONS_PER_RUN = 24;
|
||||
|
||||
/** Grid cells per axis bounds across all difficulties (validation). */
|
||||
export const MIN_GRID_SIZE = 8;
|
||||
export const MAX_GRID_SIZE = 20;
|
||||
|
||||
/** Real-world metres represented by one grid cell (correction granularity). */
|
||||
export const METERS_PER_CELL = 50;
|
||||
|
||||
export type CallForFireDifficulty = "patrol" | "checkpoint" | "compound";
|
||||
|
||||
export interface CallForFireDifficultyConfig {
|
||||
label: string;
|
||||
/** Grid is size x size cells. */
|
||||
size: number;
|
||||
/** Impact achieves effect when distance from target (metres) <= lethalRadiusM. */
|
||||
lethalRadiusM: number;
|
||||
/** Expected round count; score bonus for each round under par. */
|
||||
parRounds: number;
|
||||
/** Hard cap on rounds per run (spotting round included). */
|
||||
maxRounds: number;
|
||||
/** Base score awarded on an effective mission. */
|
||||
baseScore: number;
|
||||
/** Score per round under par. */
|
||||
perRoundBonus: number;
|
||||
/** Expected completion time in seconds; 1 score point per second under par. */
|
||||
parSeconds: number;
|
||||
/** Hard time limit in seconds; exceeding it fails the run. */
|
||||
maxSeconds: number;
|
||||
/** Hidden target is placed at least this many cells from any edge. */
|
||||
targetInset: number;
|
||||
/** Spotting round lands this many cells from the target (min). */
|
||||
spotMinCells: number;
|
||||
/** Spotting round lands this many cells from the target (max). */
|
||||
spotMaxCells: number;
|
||||
}
|
||||
|
||||
export const CALL_FOR_FIRE_DIFFICULTIES: Record<
|
||||
CallForFireDifficulty,
|
||||
CallForFireDifficultyConfig
|
||||
> = {
|
||||
patrol: {
|
||||
label: "Patrol Support",
|
||||
size: 10,
|
||||
lethalRadiusM: 75,
|
||||
parRounds: 4,
|
||||
maxRounds: 8,
|
||||
baseScore: 100,
|
||||
perRoundBonus: 25,
|
||||
parSeconds: 90,
|
||||
maxSeconds: 180,
|
||||
targetInset: 3,
|
||||
spotMinCells: 3,
|
||||
spotMaxCells: 6,
|
||||
},
|
||||
checkpoint: {
|
||||
label: "Checkpoint Defence",
|
||||
size: 12,
|
||||
lethalRadiusM: 50,
|
||||
parRounds: 6,
|
||||
maxRounds: 10,
|
||||
baseScore: 300,
|
||||
perRoundBonus: 40,
|
||||
parSeconds: 120,
|
||||
maxSeconds: 240,
|
||||
targetInset: 4,
|
||||
spotMinCells: 4,
|
||||
spotMaxCells: 8,
|
||||
},
|
||||
compound: {
|
||||
label: "Compound Strike",
|
||||
size: 14,
|
||||
lethalRadiusM: 50,
|
||||
parRounds: 8,
|
||||
maxRounds: 12,
|
||||
baseScore: 600,
|
||||
perRoundBonus: 60,
|
||||
parSeconds: 150,
|
||||
maxSeconds: 300,
|
||||
targetInset: 5,
|
||||
spotMinCells: 5,
|
||||
spotMaxCells: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const DIFFICULTY_KEYS: CallForFireDifficulty[] = ["patrol", "checkpoint", "compound"];
|
||||
|
||||
export function isCallForFireDifficulty(value: string): value is CallForFireDifficulty {
|
||||
return DIFFICULTY_KEYS.includes(value as CallForFireDifficulty);
|
||||
}
|
||||
|
||||
/** Per-difficulty profile field for the fewest rounds used to achieve effect. */
|
||||
export const BEST_MISSIONS_FIELDS: Record<CallForFireDifficulty, string> = {
|
||||
patrol: "bestPatrolMissions",
|
||||
checkpoint: "bestCheckpointMissions",
|
||||
compound: "bestCompoundMissions",
|
||||
};
|
||||
|
||||
export interface GridPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function euclideanDistance(a: GridPoint, b: GridPoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
|
||||
/** Distance from an impact to the target in metres. */
|
||||
export function distanceMeters(target: GridPoint, impact: GridPoint): number {
|
||||
return euclideanDistance(target, impact) * METERS_PER_CELL;
|
||||
}
|
||||
|
||||
/** True when an impact at `impact` achieves effect on a target at `target`. */
|
||||
export function isEffectiveHit(
|
||||
target: GridPoint,
|
||||
impact: GridPoint,
|
||||
lethalRadiusM: number,
|
||||
): boolean {
|
||||
return distanceMeters(target, impact) <= lethalRadiusM;
|
||||
}
|
||||
|
||||
const COMPASS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const;
|
||||
|
||||
export type CompassBearing = (typeof COMPASS)[number];
|
||||
|
||||
/**
|
||||
* Bearing from an impact toward the target. Grid y grows downward, so north
|
||||
* is negative dy. Due north is "N"; ties split clockwise from due north.
|
||||
*/
|
||||
export function bearingOf(target: GridPoint, impact: GridPoint): CompassBearing {
|
||||
const dx = target.x - impact.x;
|
||||
const dy = target.y - impact.y;
|
||||
|
||||
if (dx === 0 && dy === 0) {
|
||||
return "N";
|
||||
}
|
||||
|
||||
// atan2 expects y up: negate dy. Angle 0 = north, clockwise positive.
|
||||
const angle = (Math.atan2(dx, -dy) * 180) / Math.PI;
|
||||
const normalized = (angle + 360) % 360;
|
||||
const index = Math.round(normalized / 45) % 8;
|
||||
return COMPASS[index];
|
||||
}
|
||||
|
||||
export type RangeBand = "dangerClose" | "close" | "medium" | "far";
|
||||
|
||||
export const RANGE_BANDS: { band: RangeBand; label: string; maxDistance: number }[] = [
|
||||
{ band: "dangerClose", label: "danger close", maxDistance: 2 },
|
||||
{ band: "close", label: "close", maxDistance: 4 },
|
||||
{ band: "medium", label: "mid", maxDistance: 7 },
|
||||
{ band: "far", label: "far", maxDistance: Number.POSITIVE_INFINITY },
|
||||
];
|
||||
|
||||
/** Human range band from an impact to the target (euclidean cells). */
|
||||
export function rangeBandOf(target: GridPoint, impact: GridPoint): RangeBand {
|
||||
const distance = euclideanDistance(target, impact);
|
||||
for (const entry of RANGE_BANDS) {
|
||||
if (distance <= entry.maxDistance) {
|
||||
return entry.band;
|
||||
}
|
||||
}
|
||||
return "far";
|
||||
}
|
||||
|
||||
export function rangeBandLabel(band: RangeBand): string {
|
||||
return RANGE_BANDS.find((entry) => entry.band === band)?.label ?? "far";
|
||||
}
|
||||
|
||||
export interface ObserverReport {
|
||||
bearing: CompassBearing;
|
||||
band: RangeBand;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Radio-style observer report for a missed impact. */
|
||||
export function observerReport(target: GridPoint, impact: GridPoint): ObserverReport {
|
||||
const bearing = bearingOf(target, impact);
|
||||
const band = rangeBandOf(target, impact);
|
||||
return {
|
||||
bearing,
|
||||
band,
|
||||
message: `Shot, over. Target bearing ${bearing}, range ${rangeBandLabel(band)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** A fire correction in metres: range (+ADD / -DROP) and deflection (+RIGHT / -LEFT). */
|
||||
export interface Correction {
|
||||
rangeM: number;
|
||||
deflectionM: number;
|
||||
}
|
||||
|
||||
/** Clamp a grid point into the size x size board. */
|
||||
export function clampPoint(point: GridPoint, size: number): GridPoint {
|
||||
return {
|
||||
x: Math.min(size - 1, Math.max(0, point.x)),
|
||||
y: Math.min(size - 1, Math.max(0, point.y)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a correction to the last impact. ADD moves the impact further from
|
||||
* the observer (north, -y); RIGHT shifts it east (+x). Metres round to the
|
||||
* nearest cell and the result clamps to the board.
|
||||
*/
|
||||
export function applyCorrection(
|
||||
lastImpact: GridPoint,
|
||||
correction: Correction,
|
||||
size: number,
|
||||
): GridPoint {
|
||||
const dx = Math.round(correction.deflectionM / METERS_PER_CELL);
|
||||
const dy = -Math.round(correction.rangeM / METERS_PER_CELL);
|
||||
return clampPoint({ x: lastImpact.x + dx, y: lastImpact.y + dy }, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Score for a run: base, plus a bonus per round under par, plus a bonus per
|
||||
* second under par, plus a bonus for final-round accuracy (metres inside the
|
||||
* lethal radius). Losses score 0.
|
||||
*/
|
||||
export function calculateScore(
|
||||
difficulty: CallForFireDifficulty,
|
||||
roundsUsed: number,
|
||||
seconds: number,
|
||||
finalDistanceM: number,
|
||||
won: boolean,
|
||||
): number {
|
||||
if (!won) {
|
||||
return 0;
|
||||
}
|
||||
const config = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||
const roundsBonus = Math.max(0, config.parRounds - roundsUsed) * config.perRoundBonus;
|
||||
const timeBonus = Math.max(0, config.parSeconds - seconds);
|
||||
const accuracyBonus = Math.max(0, Math.round(config.lethalRadiusM - finalDistanceM));
|
||||
return config.baseScore + roundsBonus + timeBonus + accuracyBonus;
|
||||
}
|
||||
|
||||
/** XP for a run: 1 XP per 50 score, minimum 1 on a win. Losses earn nothing. */
|
||||
export function calculateXp(score: number, won: boolean): number {
|
||||
if (!won) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(1, Math.floor(score / 50));
|
||||
}
|
||||
|
||||
/** Uniform random point in the interior region reserved for the hidden target. */
|
||||
export function randomTargetPosition(
|
||||
difficulty: CallForFireDifficulty,
|
||||
rng: () => number = Math.random,
|
||||
): GridPoint {
|
||||
const { size, targetInset } = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||
const min = targetInset;
|
||||
const max = size - 1 - targetInset;
|
||||
return {
|
||||
x: min + Math.floor(rng() * (max - min + 1)),
|
||||
y: min + Math.floor(rng() * (max - min + 1)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Random compass direction; magnitude uniform in [min, max] cells. */
|
||||
export function randomSpottingOffset(
|
||||
difficulty: CallForFireDifficulty,
|
||||
rng: () => number = Math.random,
|
||||
): GridPoint {
|
||||
const { spotMinCells, spotMaxCells } = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||
const magnitude = spotMinCells + rng() * (spotMaxCells - spotMinCells);
|
||||
const angle = rng() * Math.PI * 2;
|
||||
return {
|
||||
x: Math.round(Math.cos(angle) * magnitude),
|
||||
y: Math.round(Math.sin(angle) * magnitude),
|
||||
};
|
||||
}
|
||||
|
||||
export interface FireScenario {
|
||||
/** Hidden target position. */
|
||||
target: GridPoint;
|
||||
/** Where the initial spotting round lands. */
|
||||
spotting: GridPoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a randomized scenario: hidden target plus a spotting round landing
|
||||
* a random distance and direction from it, clamped to the board.
|
||||
*/
|
||||
export function createScenario(
|
||||
difficulty: CallForFireDifficulty,
|
||||
rng: () => number = Math.random,
|
||||
): FireScenario {
|
||||
const { size } = CALL_FOR_FIRE_DIFFICULTIES[difficulty];
|
||||
const target = randomTargetPosition(difficulty, rng);
|
||||
const offset = randomSpottingOffset(difficulty, rng);
|
||||
const spotting = clampPoint({ x: target.x + offset.x, y: target.y + offset.y }, size);
|
||||
return { target, spotting };
|
||||
}
|
||||
|
||||
/** Grid designation like "E5" for display (column letter + 1-based row). */
|
||||
export function gridDesignation(point: GridPoint): string {
|
||||
const column = String.fromCharCode(65 + (point.x % 26));
|
||||
return `${column}${point.y + 1}`;
|
||||
}
|
||||
237
tests/int/call-for-fire.int.spec.ts
Normal file
237
tests/int/call-for-fire.int.spec.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue