wren/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

Give bots a reaction, and an aim that has to settle

A bot pointed with atan2 to the target's exact position, recomputed every
tick and blurred by a flat ±1.7°, and pulled the trigger the moment the
barrel was within 4°. That is no reaction time and no tracking error: it
acquires the instant you are visible and follows you perfectly, which is
what read as an aimbot even though the shot itself went through the same
spread as everyone's.

src/aim.ts holds a bot's hold on one target as plain data and the maths
over it: a 200–350ms reaction drawn on acquisition and gating the trigger,
a cone that opens to ~6° on the swing and settles toward 0.5° the longer
one target is held, and a term for how fast the target is crossing the
sight line. The error wanders inside the cone rather than being redrawn
every tick — noise at 30Hz averages to a perfect barrel over a burst. A
hold survives a blink out of sight (0.4s) so a smoke edge does not cost a
fresh reaction.

The spread stays shared in weapons.ts, and every roll comes from the
match's seeded random, so a seed still replays the same round.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 9fbef6e Browse files
5 files changed +375 −3
src/aim.test.ts added +230
@@ -0,0 +1,230 @@
1 /**
2 * How a bot points a gun, tested as the pure maths it is, and then once
3 * through a whole bot to prove the gate is actually wired to the trigger.
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 import { botThink } from "./bots.ts";
23 import { newMatch, RUN_SPEED } from "./match.ts";
24 import type { MatchState, Player } from "./state.ts";
25
26 const rolls = (...values: number[]) => {
27 let at = 0;
28 return () => values[at++ % values.length]!;
29 };
30
31 test("the reaction a bot draws is between 200 and 350ms, whatever the roll", () => {
32 expect(reactionTime(0)).toBeCloseTo(REACTION_MIN, 9);
33 expect(reactionTime(1)).toBeCloseTo(REACTION_MAX, 9);
34 expect(reactionTime(0.5)).toBeCloseTo((REACTION_MIN + REACTION_MAX) / 2, 9);
35 // A roll out of range is still a reaction, not a bot that never shoots.
36 expect(reactionTime(-3)).toBe(REACTION_MIN);
37 expect(reactionTime(9)).toBe(REACTION_MAX);
38 });
39
40 test("the trigger gate is a pure function of when acquisition happened", () => {
41 const track = emptyTrack();
42 // Nothing in sight is never a shot, however long the round has run.
43 expect(canFire(track, 100)).toBe(false);
44
45 acquire(track, "bot-red-1", 10, { x: 0, y: 0 }, rolls(0, 0.5));
46 expect(track.reaction).toBeCloseTo(REACTION_MIN, 9);
47 expect(canFire(track, 10)).toBe(false);
48 expect(canFire(track, 10.19)).toBe(false);
49 expect(canFire(track, 10.21)).toBe(true);
50 expect(canFire(track, 12)).toBe(true);
51 expect(heldFor(track, 12)).toBeCloseTo(2, 9);
52 });
53
54 test("a new target starts the reaction again; the same one does not", () => {
55 const track = emptyTrack();
56 acquire(track, "one", 5, { x: 100, y: 100 }, rolls(1, 0.5));
57 expect(track.since).toBe(5);
58 acquire(track, "one", 5.3, { x: 180, y: 100 }, rolls(0, 0.5));
59 expect(track.since).toBe(5); // the hold carries on
60 expect(canFire(track, 5.4)).toBe(true);
61 acquire(track, "two", 5.4, { x: 0, y: 0 }, rolls(0, 0.5));
62 expect(track.since).toBe(5.4); // swinging onto somebody else costs again
63 expect(canFire(track, 5.5)).toBe(false);
64 });
65
66 test("a hold survives a blink out of sight, and no longer", () => {
67 const track = emptyTrack();
68 acquire(track, "one", 1, { x: 0, y: 0 }, rolls(0, 0.5));
69 lose(track, 1 + FORGET / 2);
70 expect(track.id).toBe("one");
71 // Back a moment later: the same hold, already through its reaction.
72 acquire(track, "one", 1 + FORGET / 2, { x: 0, y: 0 }, rolls(0, 0.5));
73 expect(track.since).toBe(1);
74 // Gone for longer than that and it is a fresh acquisition.
75 lose(track, 3);
76 expect(track.id).toBe(null);
77 acquire(track, "one", 3, { x: 0, y: 0 }, rolls(0, 0.5));
78 expect(track.since).toBe(3);
79 expect(canFire(track, 3.1)).toBe(false);
80 });
81
82 test("the cone starts wide, settles the longer one target is held, and never closes", () => {
83 const swing = aimCone(0, 0);
84 const second = aimCone(1, 0);
85 const settled = aimCone(3, 0);
86 expect(swing).toBeGreaterThan(0.08); // several degrees on the swing
87 expect(second).toBeLessThan(swing / 2);
88 expect(settled).toBeLessThan(second);
89 expect(settled).toBeGreaterThanOrEqual(CONE_FLOOR);
90 expect(aimCone(99, 0)).toBeCloseTo(CONE_FLOOR, 6);
91 });
92
93 test("a target crossing the view costs the bot accuracy", () => {
94 expect(aimCone(3, 1)).toBeGreaterThan(aimCone(3, 0) * 3);
95 expect(aimCone(3, 0.5)).toBeGreaterThan(aimCone(3, 0));
96 expect(aimCone(3, 0.5)).toBeLessThan(aimCone(3, 1));
97 // Nonsense from the caller is clamped, not multiplied out.
98 expect(aimCone(3, 50)).toBeCloseTo(aimCone(3, 1), 9);
99 });
100
101 test("crossing is measured across the sight line, not along it", () => {
102 const bot = { x: 0, y: 0 };
103 const walking = emptyTrack();
104 acquire(walking, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5));
105 // Straight down the barrel: nothing to track.
106 expect(crossRate(walking, bot, { x: 260, y: 0 }, 0.1)).toBeCloseTo(0, 6);
107 const strafing = emptyTrack();
108 acquire(strafing, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5));
109 // Straight across it at a run: all of it but the turn of the sight line.
110 expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(250, -1);
111 // And it remembers where the target went, so the next tick measures again.
112 expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(0, 6);
113 });
114
115 test("the error wanders inside the cone instead of being redrawn every tick", () => {
116 const track = emptyTrack();
117 acquire(track, "one", 0, { x: 0, y: 0 }, rolls(0, 1));
118 // Drawn at the edge of the swing cone on acquisition.
119 expect(Math.abs(track.error)).toBeCloseTo(aimCone(0, 0), 9);
120 // One tick moves it a little, not to somewhere unrelated.
121 const before = track.error;
122 const after = drift(track, aimCone(0, 0), 1 / 30, rolls(0.9));
123 expect(Math.abs(after - before)).toBeLessThan(aimCone(0, 0) / 4);
124 // And it is squeezed toward the barrel as the cone closes around it.
125 for (let i = 0; i < 90; i++) drift(track, aimCone(3, 0), 1 / 30, rolls(0.5, 1, 0));
126 expect(Math.abs(track.error)).toBeLessThanOrEqual(aimCone(3, 0) + 1e-12);
127 });
128
129 // ------------------------------------------------------------- through a bot
130
131 const facing = (state: MatchState): { bot: Player; prey: Player } => {
132 const bot = state.players.find((p) => p.side === "def")!;
133 const prey = state.players.find((p) => p.side === "atk")!;
134 // Nose to nose in the open, with nothing between them.
135 bot.x = 1200;
136 bot.y = 640;
137 prey.x = 1200 + 200;
138 prey.y = 640;
139 bot.angle = 0;
140 bot.input.angle = 0;
141 bot.weapon = "vandal";
142 bot.ammo = 25;
143 return { bot, prey };
144 };
145
146 const live = (): MatchState => {
147 const state = newMatch(4242);
148 state.phase = "live";
149 state.clock = 100;
150 return state;
151 };
152
153 test("a bot that has just seen somebody does not shoot for a reaction", () => {
154 const state = live();
155 const { bot } = facing(state);
156 let firstFire = -1;
157 for (let i = 0; i < 30 && firstFire < 0; i++) {
158 botThink(state, bot, 1 / 30);
159 state.now += 1 / 30;
160 if (bot.input.fire) firstFire = i / 30;
161 }
162 expect(firstFire).toBeGreaterThanOrEqual(REACTION_MIN);
163 expect(firstFire).toBeLessThanOrEqual(REACTION_MAX + 0.15);
164 });
165
166 test("a bot that has been tracking you is far tighter than one that just swung on", () => {
167 const state = live();
168 const { bot, prey } = facing(state);
169 const truth = () => Math.atan2(prey.y - bot.y, prey.x - bot.x);
170 const spread: number[] = [];
171 for (let i = 0; i < 120; i++) {
172 botThink(state, bot, 1 / 30);
173 state.now += 1 / 30;
174 bot.angle = bot.input.angle; // the match loop's job, done by hand
175 spread.push(Math.abs(bot.angle - truth()));
176 }
177 const swing = Math.max(...spread.slice(0, 15));
178 const held = Math.max(...spread.slice(80));
179 expect(swing).toBeGreaterThan(held * 2);
180 expect(held).toBeLessThan(0.03);
181 });
182
183 test("a strafing target keeps a settled bot honest", () => {
184 const still = live();
185 const a = facing(still);
186 const strafing = live();
187 const b = facing(strafing);
188 let stillWorst = 0;
189 let strafeWorst = 0;
190 for (let i = 0; i < 180; i++) {
191 botThink(still, a.bot, 1 / 30);
192 botThink(strafing, b.bot, 1 / 30);
193 still.now += 1 / 30;
194 strafing.now += 1 / 30;
195 a.bot.angle = a.bot.input.angle;
196 b.bot.angle = b.bot.input.angle;
197 // One stands in the open; the other runs across the sight line.
198 b.prey.y = 640 + Math.sin(i / 8) * 90;
199 if (i > 60) {
200 stillWorst = Math.max(stillWorst, Math.abs(a.bot.angle - Math.atan2(a.prey.y - a.bot.y, a.prey.x - a.bot.x)));
201 strafeWorst = Math.max(strafeWorst, Math.abs(b.bot.angle - Math.atan2(b.prey.y - b.bot.y, b.prey.x - b.bot.x)));
202 }
203 }
204 expect(strafeWorst).toBeGreaterThan(stillWorst);
205 });
206
207 test("the aim is still deterministic in the state: one seed, one round", () => {
208 const play = (): number[] => {
209 const state = live();
210 const { bot, prey } = facing(state);
211 const out: number[] = [];
212 for (let i = 0; i < 60; i++) {
213 botThink(state, bot, 1 / 30);
214 state.now += 1 / 30;
215 bot.angle = bot.input.angle;
216 prey.x = 1400 + i;
217 out.push(bot.angle, bot.input.fire ? 1 : 0);
218 }
219 return out;
220 };
221 expect(play()).toEqual(play());
222 });
223
224 test("a full run of speed is what the crossing term is measured against", () => {
225 expect(RUN_SPEED).toBeGreaterThan(0);
226 const track = emptyTrack();
227 acquire(track, "t", 0, { x: 400, y: 0 }, rolls(0, 0.5));
228 const rate = crossRate(track, { x: 0, y: 0 }, { x: 400, y: RUN_SPEED / 30 }, 1 / 30);
229 expect(rate / RUN_SPEED).toBeCloseTo(1, 3);
230 });
src/aim.ts added +128
@@ -0,0 +1,128 @@
1 /**
2 * How a bot points a gun.
3 *
4 * The shot itself is shared with people — weapon spread, the move penalty,
5 * the spray, the blind wobble all live in `weapons.ts` and apply to everyone
6 * the same way. What lives here is the part a person is bad at and a bot was
7 * not: seeing somebody, and getting the barrel onto them.
8 *
9 * A hold on a target has three honest costs. It takes a moment to notice
10 * (`reaction`), the barrel starts somewhere wrong and only settles the longer
11 * the same target is held (`aimCone`), and a target crossing the view is
12 * harder to stay on than one walking down the barrel (`crossRate`).
13 *
14 * Everything here is a pure function of a track and a roll, so the caller
15 * supplies the randomness — the match's seeded `random`, so a replay of a
16 * seed still plays the same round.
17 */
18
19 export type Point = { x: number; y: number };
20
21 /** One bot's hold on one target. Scratch space, carried on the player. */
22 export type Track = {
23 /** The target being held, or null when nothing is in sight. */
24 id: string | null;
25 /** Match time the hold started: every cost here is measured from it. */
26 since: number;
27 /** Match time the target was last actually seen. */
28 seen: number;
29 /** Seconds between seeing this target and being allowed to pull. */
30 reaction: number;
31 /** How wrong the barrel is, in radians, signed. */
32 error: number;
33 /** Where the target was last seen, to measure how it is moving. */
34 x: number;
35 y: number;
36 };
37
38 export const emptyTrack = (): Track => ({ id: null, since: 0, seen: 0, reaction: 0, error: 0, x: 0, y: 0 });
39
40 /** A bot notices somebody somewhere in here, never sooner. */
41 export const REACTION_MIN = 0.2;
42 export const REACTION_MAX = 0.35;
43 /** A hold survives a blink — a smoke edge, a corner — without re-reacting. */
44 export const FORGET = 0.4;
45 /** Seconds for the cone to fall by 1/e while the same target is held. */
46 export const SETTLE = 0.8;
47 /** The cone a bot settles to: under a Vandal's own spread, never zero. */
48 export const CONE_FLOOR = 0.008;
49 /** What the cone opens to the instant a target is acquired (~6°). */
50 export const CONE_SWING = 0.105;
51 /** What a target crossing the view at a full run adds to it (~3.4°). */
52 export const CONE_MOVE = 0.06;
53 /** How fast the error wanders inside the cone, in cone-widths a second. */
54 export const DRIFT = 2.4;
55
56 const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n);
57
58 /** The reaction a bot draws on acquiring a target: 200–350ms. */
59 export const reactionTime = (roll: number): number =>
60 REACTION_MIN + (REACTION_MAX - REACTION_MIN) * clamp01(roll);
61
62 /** Seconds this target has been held. */
63 export const heldFor = (track: Track, now: number): number => Math.max(0, now - track.since);
64
65 /**
66 * The trigger gate, as a pure function of when acquisition happened: no
67 * target, or a target seen less than a reaction ago, is no shot.
68 */
69 export const canFire = (track: Track, now: number): boolean =>
70 track.id !== null && now - track.since >= track.reaction;
71
72 /**
73 * The half-angle the barrel may be wrong by: wide on the swing, settling
74 * toward the floor the longer one target is held, and wider again for a
75 * target crossing the view (`crossing` is 0 for still, 1 for a full run).
76 */
77 export function aimCone(held: number, crossing: number): number {
78 return CONE_FLOOR + CONE_SWING * Math.exp(-Math.max(0, held) / SETTLE) + CONE_MOVE * clamp01(crossing);
79 }
80
81 /**
82 * Point the track at a target. A different target — or the same one after
83 * the hold was forgotten — starts a fresh hold: a new reaction to serve and
84 * an error drawn anywhere in the swing cone.
85 */
86 export function acquire(track: Track, id: string, now: number, at: Point, roll: () => number): void {
87 if (track.id !== id || now - track.seen > FORGET || now < track.since) {
88 track.id = id;
89 track.since = now;
90 track.reaction = reactionTime(roll());
91 track.error = (roll() - 0.5) * 2 * aimCone(0, 0);
92 track.x = at.x;
93 track.y = at.y;
94 }
95 track.seen = now;
96 }
97
98 /** Nothing in sight. The hold survives a blink, then it is gone. */
99 export function lose(track: Track, now: number): void {
100 if (track.id !== null && now - track.seen > FORGET) track.id = null;
101 }
102
103 /**
104 * How fast the target is crossing the bot's line of sight, in units a
105 * second. Walking down the barrel is easy to stay on; strafing is not, and
106 * it is the part `atan2` to an exact position used to give away for free.
107 */
108 export function crossRate(track: Track, bot: Point, at: Point, dt: number): number {
109 const moved = { x: at.x - track.x, y: at.y - track.y };
110 track.x = at.x;
111 track.y = at.y;
112 if (dt <= 0) return 0;
113 const ax = at.x - bot.x;
114 const ay = at.y - bot.y;
115 const len = Math.hypot(ax, ay) || 1;
116 return Math.abs(moved.x * (-ay / len) + moved.y * (ax / len)) / dt;
117 }
118
119 /**
120 * Wander the error inside the cone and hand it back. It is a held mistake,
121 * not fresh noise every tick: noise resampled at 30Hz averages to a perfect
122 * barrel over a burst, which is the thing that felt like an aimbot.
123 */
124 export function drift(track: Track, cone: number, dt: number, roll: () => number): number {
125 track.error += (roll() - 0.5) * 2 * cone * DRIFT * dt;
126 track.error = Math.max(-cone, Math.min(cone, track.error));
127 return track.error;
128 }
src/bots.ts +11 −3
@@ -8,8 +8,9 @@
88 */
99
1010 import { agentById, castable } from "./abilities.ts";
11 import { acquire, aimCone, canFire, crossRate, drift, heldFor, lose } from "./aim.ts";
1112 import { pathStep, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts";
12 import { buyAbility, buyShield, buyWeapon, random, SPIKE_REACH, visible } from "./match.ts";
13 import { buyAbility, buyShield, buyWeapon, random, RUN_SPEED, SPIKE_REACH, visible } from "./match.ts";
1314 import type { MatchState, Player, Slot } from "./state.ts";
1415 import { WEAPONS, type WeaponId } from "./weapons.ts";
1516
@@ -158,17 +159,24 @@ export function botThink(state: MatchState, bot: Player, dt: number): void {
158159 // --- the gun
159160 bot.input.reload = bot.ammo === 0;
160161 if (!target) {
162 lose(bot.aim, state.now);
161163 bot.input.fire = false;
162164 if (Math.hypot(bot.input.mx, bot.input.my) > 0.01)
163165 turn(bot, Math.atan2(bot.input.my, bot.input.mx), 6 * dt);
164166 return;
165167 }
168 // A hold costs what a hold costs: a moment to notice, a barrel that starts
169 // wrong and settles, and more of both when the target is crossing the view.
170 const roll = () => random(state);
171 acquire(bot.aim, target.id, state.now, target, roll);
172 const crossing = crossRate(bot.aim, bot, target, dt) / RUN_SPEED;
173 const cone = aimCone(heldFor(bot.aim, state.now), crossing);
166174 const range = Math.hypot(target.x - bot.x, target.y - bot.y);
167 const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + (random(state) - 0.5) * 0.06;
175 const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + drift(bot.aim, cone, dt, roll);
168176 turn(bot, aim, 7 * dt);
169177 const off = Math.abs(((aim - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
170178 const weapon = WEAPONS[bot.weapon];
171 bot.input.fire = off < 0.07 && range < weapon.range && bot.ammo > 0;
179 bot.input.fire = canFire(bot.aim, state.now) && off < 0.07 && range < weapon.range && bot.ammo > 0;
172180 // Stand still to shoot at range, close the gap up front.
173181 if (range > 420) {
174182 bot.input.mx *= 0.15;
src/match.ts +3
@@ -9,6 +9,7 @@
99 */
1010
1111 import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts";
12 import { emptyTrack } from "./aim.ts";
1213 import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts";
1314 import {
1415 blockedCircle,
@@ -137,6 +138,7 @@ export function makePlayer(
137138 input: emptyInput(),
138139 goal: null,
139140 think: 0,
141 aim: emptyTrack(),
140142 spray: 0,
141143 joinedAt: 0,
142144 };
@@ -229,6 +231,7 @@ export function startRound(state: MatchState, first = false): void {
229231 player.spray = 0;
230232 player.goal = null;
231233 player.think = 0;
234 player.aim = emptyTrack();
232235 player.input = emptyInput();
233236 // Basics are bought fresh every round; the signature comes back free.
234237 player.charges = { c: 0, q: 0, e: 1, x: 0 };
src/state.ts +3
@@ -3,6 +3,7 @@
33 * can both speak about a player without importing each other.
44 */
55
6 import type { Track } from "./aim.ts";
67 import type { Point, Site } from "./map.ts";
78 import type { WeaponId } from "./weapons.ts";
89
@@ -97,6 +98,8 @@ export type Player = {
9798 /** Bot scratch space: where it is walking and when it last thought. */
9899 goal: Point | null;
99100 think: number;
101 /** Bot scratch space: the target it is holding, and how badly. */
102 aim: Track;
100103 spray: number;
101104 joinedAt: number;
102105 };
↑↓ navigate ↵ open esc close

Keyboard shortcuts