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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/194-a-bot-with-an-awp-fires-it-from-the-hip → main 2 commits fork
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 | ||
| 752 | 752 | off its path (`worthTheDetour` in `plan.ts` is the whole arithmetic) but never |
| 753 | 753 | crosses the map, the site, or the bomb carrier's own plan for one. |
| 754 | 754 | |
| 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 | ||
| 755 | 775 | A bot tops up its own magazine, too — but only the moment `pickTarget` finds |
| 756 | 776 | nobody to see, which is the same trace the fight already runs, so this can |
| 757 | 777 | 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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | 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, | |
| 4 | 5 | } from "./bots.ts"; |
| 5 | 6 | import { DE_FORGE, placeAt, worldOf } from "./map.ts"; |
| 6 | 7 | import { dist2d } from "./math.ts"; |
| 8 | import { MOVE } from "./physics.ts"; | |
| 9 | import { inaccuracy, WEAPONS } from "./weapons.ts"; | |
| 7 | 10 | import { COMPETITIVE, SHORT } from "./match.ts"; |
| 8 | 11 | import { CALL_LIFE, HUNT_AT, otherSite } from "./plan.ts"; |
| 9 | 12 | import { |
| 10 | aimAt, bodyCentre, createWorld, NO_INPUT, radioCall, setDifficulty, startRound, update, | |
| 13 | aimAt, applyInput, bodyCentre, createWorld, NO_INPUT, radioCall, setDifficulty, startRound, update, | |
| 11 | 14 | type Player, type World, |
| 12 | 15 | } from "./world.ts"; |
| 13 | 16 | |
| @@ -88,6 +91,105 @@ test("a bot that has only just seen you does not shoot yet", () => { | ||
| 88 | 91 | expect(immediate.fire).toBe(false); |
| 89 | 92 | }); |
| 90 | 93 | |
| 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 | ||
| 91 | 193 | // --- reloading -------------------------------------------------------------- |
| 92 | 194 | |
| 93 | 195 | 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 = { | ||
| 99 | 99 | jitter: number; // the shaky-hands offset on the view, in degrees |
| 100 | 100 | jitterApplied: number; |
| 101 | 101 | jitterUntil: number; |
| 102 | /** A sniper's standing decision to be zoomed in, held across ticks: see `snipe`. */ | |
| 103 | scoping: boolean; | |
| 102 | 104 | /** False runs the pre-policy fight: the arena's opponent and its baseline. */ |
| 103 | 105 | useLearned: boolean; |
| 104 | 106 | }; |
| @@ -123,6 +125,7 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr | ||
| 123 | 125 | jitter: 0, |
| 124 | 126 | jitterApplied: 0, |
| 125 | 127 | jitterUntil: 0, |
| 128 | scoping: false, | |
| 126 | 129 | useLearned, |
| 127 | 130 | }; |
| 128 | 131 | } |
| @@ -518,6 +521,65 @@ export function scriptedFight( | ||
| 518 | 521 | return input; |
| 519 | 522 | } |
| 520 | 523 | |
| 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 | ||
| 521 | 583 | /** |
| 522 | 584 | * Ask the policy what to do about `target`. It decides once every SKIP ticks |
| 523 | 585 | * 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 { | ||
| 635 | 697 | brain.memory.sinceSeen = 9; |
| 636 | 698 | brain.hold = 0; |
| 637 | 699 | brain.intent = { ...STILL }; |
| 700 | brain.scoping = false; | |
| 638 | 701 | input.switchTo = bot.primary ?? bot.secondary; |
| 639 | 702 | } |
| 640 | 703 | if (world.match.phase === "freeze") { |
| @@ -667,7 +730,9 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 667 | 730 | |
| 668 | 731 | // --- fighting, which is the policy's job |
| 669 | 732 | 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 | } | |
| 671 | 736 | const intent = think(world, bot, brain, target, dt); |
| 672 | 737 | applyIntent(world, bot, brain, intent, dt); |
| 673 | 738 | outOfAmmo(bot, input); |
| @@ -686,14 +751,17 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 686 | 751 | follow(bot, brain, input); |
| 687 | 752 | input.walk = false; |
| 688 | 753 | input.duck = false; |
| 754 | brain.scoping = false; | |
| 689 | 755 | } else { |
| 690 | 756 | input.forward = intent.forward; |
| 691 | 757 | input.side = intent.side; |
| 692 | 758 | input.walk = intent.walk; |
| 693 | 759 | input.duck = intent.duck; |
| 760 | snipe(world, bot, brain, target, input); | |
| 694 | 761 | } |
| 695 | 762 | return withBomb(world, bot, input); |
| 696 | 763 | } |
| 764 | brain.scoping = false; | |
| 697 | 765 | |
| 698 | 766 | // --- moving |
| 699 | 767 | repath(world, bot, brain); |
src/readme.test.ts +11
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | 9 | import { readFileSync } from "node:fs"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { SCOPE_RANGE, SCOPE_SKILL, SCOPED_MOVE, STEADY_SPEED, UNSCOPE_RANGE } from "./client/bots.ts"; | |
| 11 | 12 | import { DEFAULT_MAP, MAP_NAMES } from "./client/map.ts"; |
| 12 | 13 | import { MOTION_LABELS, MOTIONS } from "./client/motion.ts"; |
| 13 | 14 | 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 | ||
| 139 | 140 | for (const name of MOTIONS) expect(playing).toContain(`**${MOTION_LABELS[name]}**`); |
| 140 | 141 | expect(MOTIONS.length).toBe(2); |
| 141 | 142 | }); |
| 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 | }); |