/** * How a bot points a gun, tested as the pure maths it is. That the gate is * actually wired to a trigger is the game's to prove: `src/aim.test.ts`. */ import { expect, test } from "bun:test"; import { acquire, aimCone, canFire, CONE_FLOOR, crossRate, drift, emptyTrack, FORGET, heldFor, lose, REACTION_MAX, REACTION_MIN, reactionTime, } from "./aim.ts"; const rolls = (...values: number[]) => { let at = 0; return () => values[at++ % values.length]!; }; test("the reaction a bot draws is between 200 and 350ms, whatever the roll", () => { expect(reactionTime(0)).toBeCloseTo(REACTION_MIN, 9); expect(reactionTime(1)).toBeCloseTo(REACTION_MAX, 9); expect(reactionTime(0.5)).toBeCloseTo((REACTION_MIN + REACTION_MAX) / 2, 9); // A roll out of range is still a reaction, not a bot that never shoots. expect(reactionTime(-3)).toBe(REACTION_MIN); expect(reactionTime(9)).toBe(REACTION_MAX); }); test("the trigger gate is a pure function of when acquisition happened", () => { const track = emptyTrack(); // Nothing in sight is never a shot, however long the round has run. expect(canFire(track, 100)).toBe(false); acquire(track, "bot-red-1", 10, { x: 0, y: 0 }, rolls(0, 0.5)); expect(track.reaction).toBeCloseTo(REACTION_MIN, 9); expect(canFire(track, 10)).toBe(false); expect(canFire(track, 10.19)).toBe(false); expect(canFire(track, 10.21)).toBe(true); expect(canFire(track, 12)).toBe(true); expect(heldFor(track, 12)).toBeCloseTo(2, 9); }); test("a new target starts the reaction again; the same one does not", () => { const track = emptyTrack(); acquire(track, "one", 5, { x: 100, y: 100 }, rolls(1, 0.5)); expect(track.since).toBe(5); acquire(track, "one", 5.3, { x: 180, y: 100 }, rolls(0, 0.5)); expect(track.since).toBe(5); // the hold carries on expect(canFire(track, 5.4)).toBe(true); acquire(track, "two", 5.4, { x: 0, y: 0 }, rolls(0, 0.5)); expect(track.since).toBe(5.4); // swinging onto somebody else costs again expect(canFire(track, 5.5)).toBe(false); }); test("a hold survives a blink out of sight, and no longer", () => { const track = emptyTrack(); acquire(track, "one", 1, { x: 0, y: 0 }, rolls(0, 0.5)); lose(track, 1 + FORGET / 2); expect(track.id).toBe("one"); // Back a moment later: the same hold, already through its reaction. acquire(track, "one", 1 + FORGET / 2, { x: 0, y: 0 }, rolls(0, 0.5)); expect(track.since).toBe(1); // Gone for longer than that and it is a fresh acquisition. lose(track, 3); expect(track.id).toBe(null); acquire(track, "one", 3, { x: 0, y: 0 }, rolls(0, 0.5)); expect(track.since).toBe(3); expect(canFire(track, 3.1)).toBe(false); }); test("the cone starts wide, settles the longer one target is held, and never closes", () => { const swing = aimCone(0, 0); const second = aimCone(1, 0); const settled = aimCone(3, 0); expect(swing).toBeGreaterThan(0.08); // several degrees on the swing expect(second).toBeLessThan(swing / 2); expect(settled).toBeLessThan(second); expect(settled).toBeGreaterThanOrEqual(CONE_FLOOR); expect(aimCone(99, 0)).toBeCloseTo(CONE_FLOOR, 6); }); test("a target crossing the view costs the bot accuracy", () => { expect(aimCone(3, 1)).toBeGreaterThan(aimCone(3, 0) * 3); expect(aimCone(3, 0.5)).toBeGreaterThan(aimCone(3, 0)); expect(aimCone(3, 0.5)).toBeLessThan(aimCone(3, 1)); // Nonsense from the caller is clamped, not multiplied out. expect(aimCone(3, 50)).toBeCloseTo(aimCone(3, 1), 9); }); test("crossing is measured across the sight line, not along it", () => { const bot = { x: 0, y: 0 }; const walking = emptyTrack(); acquire(walking, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5)); // Straight down the barrel: nothing to track. expect(crossRate(walking, bot, { x: 260, y: 0 }, 0.1)).toBeCloseTo(0, 6); const strafing = emptyTrack(); acquire(strafing, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5)); // Straight across it at a run: all of it but the turn of the sight line. expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(250, -1); // And it remembers where the target went, so the next tick measures again. expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(0, 6); }); test("the error wanders inside the cone instead of being redrawn every tick", () => { const track = emptyTrack(); acquire(track, "one", 0, { x: 0, y: 0 }, rolls(0, 1)); // Drawn at the edge of the swing cone on acquisition. expect(Math.abs(track.error)).toBeCloseTo(aimCone(0, 0), 9); // One tick moves it a little, not to somewhere unrelated. const before = track.error; const after = drift(track, aimCone(0, 0), 1 / 30, rolls(0.9)); expect(Math.abs(after - before)).toBeLessThan(aimCone(0, 0) / 4); // And it is squeezed toward the barrel as the cone closes around it. for (let i = 0; i < 90; i++) drift(track, aimCone(3, 0), 1 / 30, rolls(0.5, 1, 0)); expect(Math.abs(track.error)).toBeLessThanOrEqual(aimCone(3, 0) + 1e-12); });