benjivers/val Public Public: anyone can read this; the crew can change it.
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]>
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 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { agentById, castable } from "./abilities.ts"; |
| 11 | import { acquire, aimCone, canFire, crossRate, drift, heldFor, lose } from "./aim.ts"; | |
| 11 | 12 | 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"; | |
| 13 | 14 | import type { MatchState, Player, Slot } from "./state.ts"; |
| 14 | 15 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 15 | 16 | |
| @@ -158,17 +159,24 @@ export function botThink(state: MatchState, bot: Player, dt: number): void { | ||
| 158 | 159 | // --- the gun |
| 159 | 160 | bot.input.reload = bot.ammo === 0; |
| 160 | 161 | if (!target) { |
| 162 | lose(bot.aim, state.now); | |
| 161 | 163 | bot.input.fire = false; |
| 162 | 164 | if (Math.hypot(bot.input.mx, bot.input.my) > 0.01) |
| 163 | 165 | turn(bot, Math.atan2(bot.input.my, bot.input.mx), 6 * dt); |
| 164 | 166 | return; |
| 165 | 167 | } |
| 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); | |
| 166 | 174 | 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); | |
| 168 | 176 | turn(bot, aim, 7 * dt); |
| 169 | 177 | const off = Math.abs(((aim - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI); |
| 170 | 178 | 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; | |
| 172 | 180 | // Stand still to shoot at range, close the gap up front. |
| 173 | 181 | if (range > 420) { |
| 174 | 182 | bot.input.mx *= 0.15; |
src/match.ts +3
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts"; |
| 12 | import { emptyTrack } from "./aim.ts"; | |
| 12 | 13 | import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts"; |
| 13 | 14 | import { |
| 14 | 15 | blockedCircle, |
| @@ -137,6 +138,7 @@ export function makePlayer( | ||
| 137 | 138 | input: emptyInput(), |
| 138 | 139 | goal: null, |
| 139 | 140 | think: 0, |
| 141 | aim: emptyTrack(), | |
| 140 | 142 | spray: 0, |
| 141 | 143 | joinedAt: 0, |
| 142 | 144 | }; |
| @@ -229,6 +231,7 @@ export function startRound(state: MatchState, first = false): void { | ||
| 229 | 231 | player.spray = 0; |
| 230 | 232 | player.goal = null; |
| 231 | 233 | player.think = 0; |
| 234 | player.aim = emptyTrack(); | |
| 232 | 235 | player.input = emptyInput(); |
| 233 | 236 | // Basics are bought fresh every round; the signature comes back free. |
| 234 | 237 | player.charges = { c: 0, q: 0, e: 1, x: 0 }; |
src/state.ts +3
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * can both speak about a player without importing each other. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import type { Track } from "./aim.ts"; | |
| 6 | 7 | import type { Point, Site } from "./map.ts"; |
| 7 | 8 | import type { WeaponId } from "./weapons.ts"; |
| 8 | 9 | |
| @@ -97,6 +98,8 @@ export type Player = { | ||
| 97 | 98 | /** Bot scratch space: where it is walking and when it last thought. */ |
| 98 | 99 | goal: Point | null; |
| 99 | 100 | think: number; |
| 101 | /** Bot scratch space: the target it is holding, and how badly. */ | |
| 102 | aim: Track; | |
| 100 | 103 | spray: number; |
| 101 | 104 | joinedAt: number; |
| 102 | 105 | }; |