1
0
Fork 0

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:
Jason Fraley 2026-09-08 10:23:11 -04:00
parent caca11a60b
commit f1d7a6774c
2 changed files with 554 additions and 0 deletions

317
src/lib/call-for-fire.ts Normal file
View 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}`;
}

View 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);
});
});