pike/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files af76783003008f39b49ed099fd4a39b3eb6afc55
| 1 | /** |
| 2 | * How a bot points a gun, tested as the pure maths it is. That the gate is |
| 3 | * actually wired to a trigger is the game's to prove: `src/aim.test.ts`. |
| 4 | */ |
| 5 | |
| 6 | import { expect, test } from "bun:test"; |
| 7 | import { |
| 8 | acquire, |
| 9 | aimCone, |
| 10 | canFire, |
| 11 | CONE_FLOOR, |
| 12 | crossRate, |
| 13 | drift, |
| 14 | emptyTrack, |
| 15 | FORGET, |
| 16 | heldFor, |
| 17 | lose, |
| 18 | REACTION_MAX, |
| 19 | REACTION_MIN, |
| 20 | reactionTime, |
| 21 | } from "./aim.ts"; |
| 22 | |
| 23 | const rolls = (...values: number[]) => { |
| 24 | let at = 0; |
| 25 | return () => values[at++ % values.length]!; |
| 26 | }; |
| 27 | |
| 28 | test("the reaction a bot draws is between 200 and 350ms, whatever the roll", () => { |
| 29 | expect(reactionTime(0)).toBeCloseTo(REACTION_MIN, 9); |
| 30 | expect(reactionTime(1)).toBeCloseTo(REACTION_MAX, 9); |
| 31 | expect(reactionTime(0.5)).toBeCloseTo((REACTION_MIN + REACTION_MAX) / 2, 9); |
| 32 | // A roll out of range is still a reaction, not a bot that never shoots. |
| 33 | expect(reactionTime(-3)).toBe(REACTION_MIN); |
| 34 | expect(reactionTime(9)).toBe(REACTION_MAX); |
| 35 | }); |
| 36 | |
| 37 | test("the trigger gate is a pure function of when acquisition happened", () => { |
| 38 | const track = emptyTrack(); |
| 39 | // Nothing in sight is never a shot, however long the round has run. |
| 40 | expect(canFire(track, 100)).toBe(false); |
| 41 | |
| 42 | acquire(track, "bot-red-1", 10, { x: 0, y: 0 }, rolls(0, 0.5)); |
| 43 | expect(track.reaction).toBeCloseTo(REACTION_MIN, 9); |
| 44 | expect(canFire(track, 10)).toBe(false); |
| 45 | expect(canFire(track, 10.19)).toBe(false); |
| 46 | expect(canFire(track, 10.21)).toBe(true); |
| 47 | expect(canFire(track, 12)).toBe(true); |
| 48 | expect(heldFor(track, 12)).toBeCloseTo(2, 9); |
| 49 | }); |
| 50 | |
| 51 | test("a new target starts the reaction again; the same one does not", () => { |
| 52 | const track = emptyTrack(); |
| 53 | acquire(track, "one", 5, { x: 100, y: 100 }, rolls(1, 0.5)); |
| 54 | expect(track.since).toBe(5); |
| 55 | acquire(track, "one", 5.3, { x: 180, y: 100 }, rolls(0, 0.5)); |
| 56 | expect(track.since).toBe(5); // the hold carries on |
| 57 | expect(canFire(track, 5.4)).toBe(true); |
| 58 | acquire(track, "two", 5.4, { x: 0, y: 0 }, rolls(0, 0.5)); |
| 59 | expect(track.since).toBe(5.4); // swinging onto somebody else costs again |
| 60 | expect(canFire(track, 5.5)).toBe(false); |
| 61 | }); |
| 62 | |
| 63 | test("a hold survives a blink out of sight, and no longer", () => { |
| 64 | const track = emptyTrack(); |
| 65 | acquire(track, "one", 1, { x: 0, y: 0 }, rolls(0, 0.5)); |
| 66 | lose(track, 1 + FORGET / 2); |
| 67 | expect(track.id).toBe("one"); |
| 68 | // Back a moment later: the same hold, already through its reaction. |
| 69 | acquire(track, "one", 1 + FORGET / 2, { x: 0, y: 0 }, rolls(0, 0.5)); |
| 70 | expect(track.since).toBe(1); |
| 71 | // Gone for longer than that and it is a fresh acquisition. |
| 72 | lose(track, 3); |
| 73 | expect(track.id).toBe(null); |
| 74 | acquire(track, "one", 3, { x: 0, y: 0 }, rolls(0, 0.5)); |
| 75 | expect(track.since).toBe(3); |
| 76 | expect(canFire(track, 3.1)).toBe(false); |
| 77 | }); |
| 78 | |
| 79 | test("the cone starts wide, settles the longer one target is held, and never closes", () => { |
| 80 | const swing = aimCone(0, 0); |
| 81 | const second = aimCone(1, 0); |
| 82 | const settled = aimCone(3, 0); |
| 83 | expect(swing).toBeGreaterThan(0.08); // several degrees on the swing |
| 84 | expect(second).toBeLessThan(swing / 2); |
| 85 | expect(settled).toBeLessThan(second); |
| 86 | expect(settled).toBeGreaterThanOrEqual(CONE_FLOOR); |
| 87 | expect(aimCone(99, 0)).toBeCloseTo(CONE_FLOOR, 6); |
| 88 | }); |
| 89 | |
| 90 | test("a target crossing the view costs the bot accuracy", () => { |
| 91 | expect(aimCone(3, 1)).toBeGreaterThan(aimCone(3, 0) * 3); |
| 92 | expect(aimCone(3, 0.5)).toBeGreaterThan(aimCone(3, 0)); |
| 93 | expect(aimCone(3, 0.5)).toBeLessThan(aimCone(3, 1)); |
| 94 | // Nonsense from the caller is clamped, not multiplied out. |
| 95 | expect(aimCone(3, 50)).toBeCloseTo(aimCone(3, 1), 9); |
| 96 | }); |
| 97 | |
| 98 | test("crossing is measured across the sight line, not along it", () => { |
| 99 | const bot = { x: 0, y: 0 }; |
| 100 | const walking = emptyTrack(); |
| 101 | acquire(walking, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5)); |
| 102 | // Straight down the barrel: nothing to track. |
| 103 | expect(crossRate(walking, bot, { x: 260, y: 0 }, 0.1)).toBeCloseTo(0, 6); |
| 104 | const strafing = emptyTrack(); |
| 105 | acquire(strafing, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5)); |
| 106 | // Straight across it at a run: all of it but the turn of the sight line. |
| 107 | expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(250, -1); |
| 108 | // And it remembers where the target went, so the next tick measures again. |
| 109 | expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(0, 6); |
| 110 | }); |
| 111 | |
| 112 | test("the error wanders inside the cone instead of being redrawn every tick", () => { |
| 113 | const track = emptyTrack(); |
| 114 | acquire(track, "one", 0, { x: 0, y: 0 }, rolls(0, 1)); |
| 115 | // Drawn at the edge of the swing cone on acquisition. |
| 116 | expect(Math.abs(track.error)).toBeCloseTo(aimCone(0, 0), 9); |
| 117 | // One tick moves it a little, not to somewhere unrelated. |
| 118 | const before = track.error; |
| 119 | const after = drift(track, aimCone(0, 0), 1 / 30, rolls(0.9)); |
| 120 | expect(Math.abs(after - before)).toBeLessThan(aimCone(0, 0) / 4); |
| 121 | // And it is squeezed toward the barrel as the cone closes around it. |
| 122 | for (let i = 0; i < 90; i++) drift(track, aimCone(3, 0), 1 / 30, rolls(0.5, 1, 0)); |
| 123 | expect(Math.abs(track.error)).toBeLessThanOrEqual(aimCone(3, 0) + 1e-12); |
| 124 | }); |