/** * How a bot points a gun, tested as the pure maths it is, and then once * through a whole bot to prove the gate is actually wired to the trigger. */ 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"; import { botThink } from "./bots.ts"; import { newMatch, RUN_SPEED } from "./match.ts"; import type { MatchState, Player } from "./state.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); }); // ------------------------------------------------------------- through a bot const facing = (state: MatchState): { bot: Player; prey: Player } => { const bot = state.players.find((p) => p.side === "def")!; const prey = state.players.find((p) => p.side === "atk")!; // Nose to nose in the open, with nothing between them. bot.x = 1200; bot.y = 640; prey.x = 1200 + 200; prey.y = 640; bot.angle = 0; bot.input.angle = 0; bot.weapon = "vandal"; bot.ammo = 25; return { bot, prey }; }; const live = (): MatchState => { const state = newMatch(4242); state.phase = "live"; state.clock = 100; return state; }; test("a bot that has just seen somebody does not shoot for a reaction", () => { const state = live(); const { bot } = facing(state); let firstFire = -1; for (let i = 0; i < 30 && firstFire < 0; i++) { botThink(state, bot, 1 / 30); state.now += 1 / 30; if (bot.input.fire) firstFire = i / 30; } expect(firstFire).toBeGreaterThanOrEqual(REACTION_MIN); expect(firstFire).toBeLessThanOrEqual(REACTION_MAX + 0.15); }); test("a bot that has been tracking you is far tighter than one that just swung on", () => { const state = live(); const { bot, prey } = facing(state); const truth = () => Math.atan2(prey.y - bot.y, prey.x - bot.x); const spread: number[] = []; for (let i = 0; i < 120; i++) { botThink(state, bot, 1 / 30); state.now += 1 / 30; bot.angle = bot.input.angle; // the match loop's job, done by hand spread.push(Math.abs(bot.angle - truth())); } const swing = Math.max(...spread.slice(0, 15)); const held = Math.max(...spread.slice(80)); expect(swing).toBeGreaterThan(held * 2); expect(held).toBeLessThan(0.03); }); test("a strafing target keeps a settled bot honest", () => { const still = live(); const a = facing(still); const strafing = live(); const b = facing(strafing); let stillWorst = 0; let strafeWorst = 0; for (let i = 0; i < 180; i++) { botThink(still, a.bot, 1 / 30); botThink(strafing, b.bot, 1 / 30); still.now += 1 / 30; strafing.now += 1 / 30; a.bot.angle = a.bot.input.angle; b.bot.angle = b.bot.input.angle; // One stands in the open; the other runs across the sight line. b.prey.y = 640 + Math.sin(i / 8) * 90; if (i > 60) { stillWorst = Math.max(stillWorst, Math.abs(a.bot.angle - Math.atan2(a.prey.y - a.bot.y, a.prey.x - a.bot.x))); strafeWorst = Math.max(strafeWorst, Math.abs(b.bot.angle - Math.atan2(b.prey.y - b.bot.y, b.prey.x - b.bot.x))); } } expect(strafeWorst).toBeGreaterThan(stillWorst); }); test("the aim is still deterministic in the state: one seed, one round", () => { const play = (): number[] => { const state = live(); const { bot, prey } = facing(state); const out: number[] = []; for (let i = 0; i < 60; i++) { botThink(state, bot, 1 / 30); state.now += 1 / 30; bot.angle = bot.input.angle; prey.x = 1400 + i; out.push(bot.angle, bot.input.fire ? 1 : 0); } return out; }; expect(play()).toEqual(play()); }); test("a full run of speed is what the crossing term is measured against", () => { expect(RUN_SPEED).toBeGreaterThan(0); const track = emptyTrack(); acquire(track, "t", 0, { x: 400, y: 0 }, rolls(0, 0.5)); const rate = crossRate(track, { x: 0, y: 0 }, { x: 400, y: RUN_SPEED / 30 }, 1 / 30); expect(rate / RUN_SPEED).toBeCloseTo(1, 3); });