From f1d7a6774ca1b492af013dafbac7828cf5cfaa19 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 8 Sep 2026 10:23:11 -0400 Subject: [PATCH] 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 --- src/lib/call-for-fire.ts | 317 ++++++++++++++++++++++++++++ tests/int/call-for-fire.int.spec.ts | 237 +++++++++++++++++++++ 2 files changed, 554 insertions(+) create mode 100644 src/lib/call-for-fire.ts create mode 100644 tests/int/call-for-fire.int.spec.ts diff --git a/src/lib/call-for-fire.ts b/src/lib/call-for-fire.ts new file mode 100644 index 0000000..dd648b2 --- /dev/null +++ b/src/lib/call-for-fire.ts @@ -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 = { + 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}`; +} diff --git a/tests/int/call-for-fire.int.spec.ts b/tests/int/call-for-fire.int.spec.ts new file mode 100644 index 0000000..c325c00 --- /dev/null +++ b/tests/int/call-for-fire.int.spec.ts @@ -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(); + 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); + }); +});