Split The code is public; the app answers only to the crew.

A bot with an AWP fires it from the hip: no bot ever scopes, so the most accurate gun in the game sprays a 7.7° cone #233

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs:vesper/194-a-bot-with-an-awp-fires-it-from-the-hip → main 2 commits fork

  • Merged. vesper/194-a-bot-with-an-awp-fires-it-from-the-hip was merged into main as 0e93c82.

Review

Sign in to review this change.
4 files changed +204 −3
README.md +20
@@ -752,6 +752,26 @@ A bot with an empty primary slot now detours to a dropped rifle a few steps
752752 off its path (`worthTheDetour` in `plan.ts` is the whole arithmetic) but never
753753 crosses the map, the site, or the bomb carrier's own plan for one.
754754
755 **A bot with the AWP scopes it.** Past `SCOPE_RANGE` (500 units) it zooms in,
756 and it stays zoomed until the target is inside `UNSCOPE_RANGE` (350). The gap
757 between the two is there so that an enemy stepping back and forth across one
758 line can't flip the scope every tick. Once zoomed it barely moves (at most
759 `SCOPED_MOVE`, 15% of its stick, on either axis), and it doesn't fire until
760 its feet are under `STEADY_SPEED` (60 u/s). That makes the shot 0.11°, where
761 the same gun fired from the hip while strafing is 7.7°. Inside 500 units, or
762 while the terrorists are out of clock and running for the site, it keeps its
763 speed and fires from the hip. Below `SCOPE_SKILL` (0.3), which covers most of
764 Easy, it never scopes at all. All of this sits in `snipe` in `bots.ts` and runs
765 after whichever fight made the decision, learned or scripted. The policy has no
766 scope action and its inputs have not changed, so the shipped weights mean what
767 they were trained on. In throwaway duels down one long line (a skill-0.8 AWP
768 bot against a skill-0.8 AK bot, 80–250 duels a range), scoping from 500 never
769 lost. The scripted fight gained 14 points at 700 units and 40 at 1000. The
770 learned fight was level from 450 to 1100 units, because at that range the
771 bot's own aim error decides the duel more than the spread does, and it gained
772 14 points at 1400. Over 40 seeded `SHORT` matches on seed 7 the side split held
773 at T 63% before and after.
774
755775 A bot tops up its own magazine, too — but only the moment `pickTarget` finds
756776 nobody to see, which is the same trace the fight already runs, so this can
757777 never be the thing that starts a reload in front of someone. `TOP_UP_SHARE`
src/client/bots.test.ts +104 −2
@@ -1,13 +1,16 @@
11 import { expect, test } from "bun:test";
22 import {
3 applyIntent, botThink, CARD, DIFFICULTIES, gunGoal, makeBrain, policy, restyleBrain, skillBand, think,
3 applyIntent, botThink, CARD, DIFFICULTIES, gunGoal, makeBrain, policy, restyleBrain, SCOPE_RANGE,
4 SCOPE_SKILL, SCOPED_MOVE, skillBand, think, UNSCOPE_RANGE,
45 } from "./bots.ts";
56 import { DE_FORGE, placeAt, worldOf } from "./map.ts";
67 import { dist2d } from "./math.ts";
8 import { MOVE } from "./physics.ts";
9 import { inaccuracy, WEAPONS } from "./weapons.ts";
710 import { COMPETITIVE, SHORT } from "./match.ts";
811 import { CALL_LIFE, HUNT_AT, otherSite } from "./plan.ts";
912 import {
10 aimAt, bodyCentre, createWorld, NO_INPUT, radioCall, setDifficulty, startRound, update,
13 aimAt, applyInput, bodyCentre, createWorld, NO_INPUT, radioCall, setDifficulty, startRound, update,
1114 type Player, type World,
1215 } from "./world.ts";
1316
@@ -88,6 +91,105 @@ test("a bot that has only just seen you does not shoot yet", () => {
8891 expect(immediate.fire).toBe(false);
8992 });
9093
94 // --- sniping -------------------------------------------------------------
95
96 /**
97 * A bot with an AWP `range` units down the long open line north of the
98 * person, looking straight at them and past its reaction beat. The person
99 * stands still: what is measured is the bot's shot, not a moving target.
100 */
101 function sniper(range: number, skill = 1, learned = true): { world: World; me: Player; bot: Player } {
102 const { world, me, bot } = duel();
103 world.match.timer = COMPETITIVE.roundTime; // a full clock: nobody is running for a site
104 bot.pos = [0, range, 2];
105 bot.brain = makeBrain(skill, 99, learned);
106 bot.brain.reset = false;
107 bot.brain.seenAt = -10;
108 bot.primary = "awp";
109 bot.weapon = "awp";
110 bot.ammo = { ...bot.ammo, awp: { mag: 10, reserve: 30 } };
111 bot.nextShotAt = 0;
112 bot.yaw = aimAt(bot, bodyCentre(me)).yaw;
113 return { world, me, bot };
114 }
115
116 test("a bot with an AWP and an enemy at long range asks for the scope", () => {
117 for (const learned of [true, false]) {
118 const { world, bot } = sniper(1200, 1, learned);
119 expect(botThink(world, bot, 1 / 64).scope).toBe(true);
120 }
121 });
122
123 test("the same bot with the enemy at knife range does not", () => {
124 for (const learned of [true, false]) {
125 const { world, bot } = sniper(200, 1, learned);
126 expect(botThink(world, bot, 1 / 64).scope).toBe(false);
127 }
128 });
129
130 test("a rifle is never scoped, and an easy bot fires the AWP from the hip", () => {
131 const rifle = sniper(1200);
132 rifle.bot.weapon = "ak47";
133 rifle.bot.primary = "ak47";
134 expect(botThink(rifle.world, rifle.bot, 1 / 64).scope).toBe(false);
135 const easy = sniper(1200, SCOPE_SKILL - 0.05);
136 expect(botThink(easy.world, easy.bot, 1 / 64).scope).toBe(false);
137 });
138
139 test("the scope decision holds across the band between the two ranges", () => {
140 const { world, bot } = sniper(1200);
141 expect(botThink(world, bot, 1 / 64).scope).toBe(true);
142 // Closing to between the two lines keeps it zoomed: no flip per tick.
143 bot.pos = [0, (SCOPE_RANGE + UNSCOPE_RANGE) / 2, 2];
144 for (let t = 0; t < 32; t++) expect(botThink(world, bot, 1 / 64).scope).toBe(true);
145 bot.pos = [0, UNSCOPE_RANGE - 50, 2];
146 expect(botThink(world, bot, 1 / 64).scope).toBe(false);
147 // And from out of it, the same band does not zoom it back in.
148 bot.pos = [0, (SCOPE_RANGE + UNSCOPE_RANGE) / 2, 2];
149 for (let t = 0; t < 32; t++) expect(botThink(world, bot, 1 / 64).scope).toBe(false);
150 });
151
152 /**
153 * Play the sniper's side of the fight tick by tick for up to three seconds —
154 * the bot decides, `applyInput` carries it out, the clock moves — and hand
155 * back what it asked of its feet on every tick it was zoomed, and the
156 * inaccuracy of the first shot it actually took.
157 */
158 function snipeFor(learned: boolean): { scopedSides: number[]; shots: number[] } {
159 const { world, bot } = sniper(1200, 1, learned);
160 const dt = 1 / 64;
161 const scopedSides: number[] = [];
162 const shots: number[] = [];
163 for (let t = 0; t < 64 * 3 && shots.length === 0; t++) {
164 const input = botThink(world, bot, dt);
165 const before = bot.lastShotAt;
166 applyInput(world, bot, input, dt);
167 if (bot.scoped) scopedSides.push(input.side);
168 if (bot.lastShotAt !== before) {
169 shots.push(inaccuracy(WEAPONS.awp, {
170 moving: Math.min(1, Math.hypot(bot.vel[0], bot.vel[1]) / MOVE.maxSpeed),
171 inAir: !bot.onGround,
172 ducking: bot.duckAmount > 0.5,
173 scoped: bot.scoped,
174 }));
175 }
176 world.time += dt;
177 }
178 return { scopedSides, shots };
179 }
180
181 test("a scoped bot stops dancing and takes a steady shot", () => {
182 for (const learned of [true, false]) {
183 const { scopedSides, shots } = snipeFor(learned);
184 expect(scopedSides.length).toBeGreaterThan(0);
185 for (const side of scopedSides) expect(Math.abs(side)).toBeLessThanOrEqual(SCOPED_MOVE);
186 // Hip-fired on the move this is 7.7°; scoped at a walk, 0.49°. A shot
187 // from a near stop is a couple of units wide at 1000.
188 expect(shots.length).toBe(1);
189 expect(shots[0]!).toBeLessThan(0.15);
190 }
191 });
192
91193 // --- reloading --------------------------------------------------------------
92194
93195 test("a bot alone with a half-empty magazine tops it up", () => {
src/client/bots.ts +69 −1
@@ -99,6 +99,8 @@ export type BotBrain = {
9999 jitter: number; // the shaky-hands offset on the view, in degrees
100100 jitterApplied: number;
101101 jitterUntil: number;
102 /** A sniper's standing decision to be zoomed in, held across ticks: see `snipe`. */
103 scoping: boolean;
102104 /** False runs the pre-policy fight: the arena's opponent and its baseline. */
103105 useLearned: boolean;
104106 };
@@ -123,6 +125,7 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
123125 jitter: 0,
124126 jitterApplied: 0,
125127 jitterUntil: 0,
128 scoping: false,
126129 useLearned,
127130 };
128131 }
@@ -518,6 +521,65 @@ export function scriptedFight(
518521 return input;
519522 }
520523
524 /**
525 * Beyond this range, in map units, a bot holding a scoped weapon zooms in to
526 * take the fight. Hip-firing the AWP on the move is a cone of 7.7° — four
527 * bodies wide at 1000 units — and the scope takes 85% of it off, so wherever
528 * a target is more than a body or two wide in that cone the scope is worth
529 * its price. The price is the feet: a scoped player walks, and a walker is a
530 * slow target. Closer than this the hip cone is already about a body wide, and
531 * a bot keeps its speed instead.
532 */
533 export const SCOPE_RANGE = 500;
534
535 /**
536 * Nearer than this a bot already zoomed in comes back out. It is below
537 * `SCOPE_RANGE` on purpose: an enemy stepping back and forth across one line
538 * would otherwise flip the request every tick, fighting the gate `applyInput`
539 * puts on the scope while the bolt cycles, and a bot that cannot decide
540 * whether it is zoomed never takes the steady shot either way.
541 */
542 export const UNSCOPE_RANGE = 350;
543
544 /** Below this skill a bot fires a sniper rifle the way it fires anything: from the hip. */
545 export const SCOPE_SKILL = 0.3;
546
547 /**
548 * The most of its stick a scoped bot moves with, on either axis. Enough to
549 * shuffle off an angle, not enough to slide: at walk speed it is ~20 u/s.
550 */
551 export const SCOPED_MOVE = 0.15;
552
553 /**
554 * A scoped bot holds the trigger until its feet are this slow, in units a
555 * second. At 60 the shot is 0.11° — a two-unit cone at 1000 units — where the
556 * same shot at the 130 of a scoped walk is 0.49°.
557 */
558 export const STEADY_SPEED = 60;
559
560 /**
561 * Sniper discipline, on top of whichever fight asked for the rest: zoom in on
562 * a target past `SCOPE_RANGE`, stop moving once zoomed, and wait for the feet
563 * to settle before the shot. It only ever reads and trims an `Input` — the
564 * policy's action space has no scope in it and its observation is unchanged,
565 * so the shipped weights still mean what they were trained on.
566 */
567 export function snipe(world: World, bot: Player, brain: BotBrain, target: Player, input: Input): Input {
568 const weapon = WEAPONS[bot.weapon];
569 if (weapon?.zoom === undefined || brain.skill < SCOPE_SKILL) {
570 brain.scoping = false;
571 return input;
572 }
573 const range = dist2d(bot.pos, target.pos);
574 if (brain.scoping ? range < UNSCOPE_RANGE : range > SCOPE_RANGE) brain.scoping = !brain.scoping;
575 input.scope = brain.scoping;
576 if (!brain.scoping && !bot.scoped) return input;
577 input.forward = clamp(input.forward, -SCOPED_MOVE, SCOPED_MOVE);
578 input.side = clamp(input.side, -SCOPED_MOVE, SCOPED_MOVE);
579 if (Math.hypot(bot.vel[0], bot.vel[1]) > STEADY_SPEED) input.fire = false;
580 return input;
581 }
582
521583 /**
522584 * Ask the policy what to do about `target`. It decides once every SKIP ticks
523585 * and the answer is held in between, which is how it was trained and what a
@@ -635,6 +697,7 @@ export function botThink(world: World, bot: Player, dt: number): Input {
635697 brain.memory.sinceSeen = 9;
636698 brain.hold = 0;
637699 brain.intent = { ...STILL };
700 brain.scoping = false;
638701 input.switchTo = bot.primary ?? bot.secondary;
639702 }
640703 if (world.match.phase === "freeze") {
@@ -667,7 +730,9 @@ export function botThink(world: World, bot: Player, dt: number): Input {
667730
668731 // --- fighting, which is the policy's job
669732 if (target !== null) {
670 if (!brain.useLearned) return withBomb(world, bot, scriptedFight(world, bot, brain, target, input, dt));
733 if (!brain.useLearned) {
734 return withBomb(world, bot, snipe(world, bot, brain, target, scriptedFight(world, bot, brain, target, input, dt)));
735 }
671736 const intent = think(world, bot, brain, target, dt);
672737 applyIntent(world, bot, brain, intent, dt);
673738 outOfAmmo(bot, input);
@@ -686,14 +751,17 @@ export function botThink(world: World, bot: Player, dt: number): Input {
686751 follow(bot, brain, input);
687752 input.walk = false;
688753 input.duck = false;
754 brain.scoping = false;
689755 } else {
690756 input.forward = intent.forward;
691757 input.side = intent.side;
692758 input.walk = intent.walk;
693759 input.duck = intent.duck;
760 snipe(world, bot, brain, target, input);
694761 }
695762 return withBomb(world, bot, input);
696763 }
764 brain.scoping = false;
697765
698766 // --- moving
699767 repath(world, bot, brain);
src/readme.test.ts +11
@@ -8,6 +8,7 @@
88 import { expect, test } from "bun:test";
99 import { readFileSync } from "node:fs";
1010 import { join } from "node:path";
11 import { SCOPE_RANGE, SCOPE_SKILL, SCOPED_MOVE, STEADY_SPEED, UNSCOPE_RANGE } from "./client/bots.ts";
1112 import { DEFAULT_MAP, MAP_NAMES } from "./client/map.ts";
1213 import { MOTION_LABELS, MOTIONS } from "./client/motion.ts";
1314 import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV } from "./client/zoom.ts";
@@ -139,3 +140,13 @@ test("the readme says where the motion switch is, what it changes and what it ma
139140 for (const name of MOTIONS) expect(playing).toContain(`**${MOTION_LABELS[name]}**`);
140141 expect(MOTIONS.length).toBe(2);
141142 });
143
144 test("the readme says a sniper bot scopes, and quotes the ranges the code holds", () => {
145 const bots = section("The bots");
146 expect(bots).toContain("**A bot with the AWP scopes it.**");
147 expect(bots).toContain(`\`SCOPE_RANGE\` (${SCOPE_RANGE} units)`);
148 expect(bots).toContain(`\`UNSCOPE_RANGE\` (${UNSCOPE_RANGE})`);
149 expect(bots).toContain(`\`SCOPED_MOVE\`, ${Math.round(SCOPED_MOVE * 100)}% of its stick`);
150 expect(bots).toContain(`\`STEADY_SPEED\` (${STEADY_SPEED} u/s)`);
151 expect(bots).toContain(`\`SCOPE_SKILL\` (${SCOPE_SKILL})`);
152 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs