Public: anyone can read this; the crew can change it.
forked from trevato/cs
Take updates from trevato/cs #388
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/385-take-updates-from-trevato-cs → main 3 commits fork
5 files
changed +180
−5
README.md +14
| @@ -1556,6 +1556,20 @@ bullets in it (`outOfAmmo`), and only reloads — and runs for cover — once th | ||
| 1556 | 1556 | too is what is left in its hands. Neither reads the skill, and neither touches |
| 1557 | 1557 | the arena's scripted baseline. |
| 1558 | 1558 | |
| 1559 | **A bot with the AWP scopes it.** Past `SCOPE_RANGE` (500 units) it zooms in, | |
| 1560 | and it stays zoomed until the target is inside `UNSCOPE_RANGE` (350). The gap | |
| 1561 | between the two is there so that an enemy stepping back and forth across one | |
| 1562 | line can't flip the scope every tick. Once zoomed it barely moves (at most | |
| 1563 | `SCOPED_MOVE`, 15% of its stick, on either axis), and it doesn't fire until | |
| 1564 | its feet are under `STEADY_SPEED` (60 u/s). That makes the shot 0.11°, where | |
| 1565 | the same gun fired from the hip while strafing is 7.7°. Inside 500 units it | |
| 1566 | keeps its speed and fires from the hip. Below `SCOPE_SKILL` (0.3), which | |
| 1567 | covers most of Easy, it never scopes at all. All of this sits in `snipe` in | |
| 1568 | `bots.ts` and runs after the policy has decided. The policy has no scope | |
| 1569 | action and its inputs have not changed, so the shipped weights mean what they | |
| 1570 | were trained on — and, like reloading, it never touches the arena's scripted | |
| 1571 | baseline. | |
| 1572 | ||
| 1559 | 1573 | - **What it sees** — 33 numbers, all egocentric (`src/client/observe.ts`): the |
| 1560 | 1574 | sine and cosine of its aim error, the range, the enemy's health and whether it |
| 1561 | 1575 | is looking back, its own health, armour, magazine, recoil, current inaccuracy |
scripts/build.test.ts +1 −1
| @@ -49,7 +49,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri | ||
| 49 | 49 | |
| 50 | 50 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 51 | 51 | expect(ENTRIES).toEqual([ |
| 52 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 132_000, gzip: 51_500 } }, | |
| 52 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 140_500, gzip: 54_500 } }, | |
| 53 | 53 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 44_000, gzip: 18_200 } }, |
| 54 | 54 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 26_800, gzip: 11_400 } }, |
| 55 | 55 | ]); |
src/client/bots.test.ts +90 −2
| @@ -3,7 +3,7 @@ import { PACKED, useShipped } from "./shipped.ts"; | ||
| 3 | 3 | import { unpack, type Policy } from "../engine/index.ts"; |
| 4 | 4 | import { |
| 5 | 5 | applyIntent, blindness, botBuy, botThink, dry, flashed, makeBrain, nextWeapon, NO_POLICY, pathTo, policy, |
| 6 | ride, roundPlan, think, usePlan, usePolicy, | |
| 6 | ride, roundPlan, SCOPE_RANGE, SCOPE_SKILL, SCOPED_MOVE, think, UNSCOPE_RANGE, usePlan, usePolicy, | |
| 7 | 7 | } from "./bots.ts"; |
| 8 | 8 | import { radio } from "./radio.ts"; |
| 9 | 9 | import { CALL_LIFE } from "../plan/call.ts"; |
| @@ -12,7 +12,8 @@ import { grip } from "./spray.ts"; | ||
| 12 | 12 | import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts"; |
| 13 | 13 | import { dist, type Vec3 } from "../engine/index.ts"; |
| 14 | 14 | import { OBS_SIZE } from "./observe.ts"; |
| 15 | import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts"; | |
| 15 | import { FLASH, inaccuracy, WEAPONS, type WeaponId } from "./weapons.ts"; | |
| 16 | import { CS_MOVE } from "./movement.ts"; | |
| 16 | 17 | import { DE_FORGE } from "./map.ts"; |
| 17 | 18 | import { COMPETITIVE } from "./match.ts"; |
| 18 | 19 | import { aimAt, applyInput, bodyCentre, createWorld, deployTicks, type Player } from "./world.ts"; |
| @@ -148,6 +149,93 @@ test("a bot that has only just seen you does not shoot yet", () => { | ||
| 148 | 149 | expect(immediate.fire).toBe(false); |
| 149 | 150 | }); |
| 150 | 151 | |
| 152 | // --- sniping --------------------------------------------------------------- | |
| 153 | ||
| 154 | /** | |
| 155 | * A bot with an AWP `range` units down the long open line north of the | |
| 156 | * person, looking straight at them and past its reaction beat. The person | |
| 157 | * stands still: what is measured is the bot's shot, not a moving target. | |
| 158 | */ | |
| 159 | function sniper(range: number, skill = 1, learned = true): ReturnType<typeof duel> { | |
| 160 | const d = duel(); | |
| 161 | d.bot.pos = [0, range, 2]; | |
| 162 | d.bot.brain = makeBrain(skill, 99, learned); | |
| 163 | d.bot.brain.reset = false; | |
| 164 | d.bot.brain.seenAt = -10; | |
| 165 | d.bot.primary = "awp"; | |
| 166 | d.bot.weapon = "awp"; | |
| 167 | d.bot.ammo = { ...d.bot.ammo, awp: { mag: 10, reserve: 30 } }; | |
| 168 | d.bot.nextShotAt = 0; | |
| 169 | d.bot.yaw = aimAt(d.bot, bodyCentre(d.me)).yaw; | |
| 170 | return d; | |
| 171 | } | |
| 172 | ||
| 173 | test("a bot with an AWP and an enemy at long range asks for the scope", () => { | |
| 174 | const { world, bot } = sniper(1200); | |
| 175 | expect(botThink(world, bot, 1 / 64).scope).toBe(true); | |
| 176 | }); | |
| 177 | ||
| 178 | test("the same bot with the enemy close does not", () => { | |
| 179 | const { world, bot } = sniper(200); | |
| 180 | expect(botThink(world, bot, 1 / 64).scope).toBe(false); | |
| 181 | }); | |
| 182 | ||
| 183 | test("a rifle is never scoped, and an easy bot fires the AWP from the hip", () => { | |
| 184 | const rifle = sniper(1200); | |
| 185 | rifle.bot.weapon = "ak47"; | |
| 186 | rifle.bot.primary = "ak47"; | |
| 187 | rifle.bot.ammo = { ...rifle.bot.ammo, ak47: { mag: 30, reserve: 90 } }; | |
| 188 | expect(botThink(rifle.world, rifle.bot, 1 / 64).scope).toBe(false); | |
| 189 | const easy = sniper(1200, SCOPE_SKILL - 0.05); | |
| 190 | expect(botThink(easy.world, easy.bot, 1 / 64).scope).toBe(false); | |
| 191 | }); | |
| 192 | ||
| 193 | test("the scripted baseline is not taught to scope: it does not move", () => { | |
| 194 | const { world, bot } = sniper(1200, 1, false); | |
| 195 | expect(botThink(world, bot, 1 / 64).scope).toBe(false); | |
| 196 | }); | |
| 197 | ||
| 198 | test("the scope decision holds across the band between the two ranges", () => { | |
| 199 | const { world, bot } = sniper(1200); | |
| 200 | expect(botThink(world, bot, 1 / 64).scope).toBe(true); | |
| 201 | // Closing to between the two lines keeps it zoomed: no flip per tick. | |
| 202 | bot.pos = [0, (SCOPE_RANGE + UNSCOPE_RANGE) / 2, 2]; | |
| 203 | for (let t = 0; t < 32; t++) expect(botThink(world, bot, 1 / 64).scope).toBe(true); | |
| 204 | bot.pos = [0, UNSCOPE_RANGE - 50, 2]; | |
| 205 | expect(botThink(world, bot, 1 / 64).scope).toBe(false); | |
| 206 | // And from out of it, the same band does not zoom it back in. | |
| 207 | bot.pos = [0, (SCOPE_RANGE + UNSCOPE_RANGE) / 2, 2]; | |
| 208 | for (let t = 0; t < 32; t++) expect(botThink(world, bot, 1 / 64).scope).toBe(false); | |
| 209 | }); | |
| 210 | ||
| 211 | test("a scoped bot stops dancing and takes a steady shot", () => { | |
| 212 | const { world, bot } = sniper(1200); | |
| 213 | const dt = 1 / 64; | |
| 214 | const scopedSides: number[] = []; | |
| 215 | const shots: number[] = []; | |
| 216 | for (let t = 0; t < 64 * 3 && shots.length === 0; t++) { | |
| 217 | const input = botThink(world, bot, dt); | |
| 218 | const before = bot.lastShotAt; | |
| 219 | applyInput(world, bot, input, dt); | |
| 220 | if (bot.scoped) scopedSides.push(input.side, input.forward); | |
| 221 | if (bot.lastShotAt !== before) { | |
| 222 | shots.push(inaccuracy(WEAPONS.awp, { | |
| 223 | moving: Math.min(1, Math.hypot(bot.vel[0], bot.vel[1]) / CS_MOVE.maxSpeed), | |
| 224 | inAir: !bot.onGround, | |
| 225 | ducking: bot.duckAmount > 0.5, | |
| 226 | scoped: bot.scoped, | |
| 227 | })); | |
| 228 | } | |
| 229 | world.time += dt; | |
| 230 | } | |
| 231 | expect(scopedSides.length).toBeGreaterThan(0); | |
| 232 | for (const stick of scopedSides) expect(Math.abs(stick)).toBeLessThanOrEqual(SCOPED_MOVE); | |
| 233 | // Hip-fired on the move this is 7.7°; scoped at a walk, 0.49°. A shot from | |
| 234 | // a near stop is a couple of units wide at 1000. | |
| 235 | expect(shots.length).toBe(1); | |
| 236 | expect(shots[0]!).toBeLessThan(0.15); | |
| 237 | }); | |
| 238 | ||
| 151 | 239 | test("a goal nothing can walk to is nudged to a cell something can", () => { |
| 152 | 240 | // A crate is solid to the search and standing on it is not: a bomb dropped |
| 153 | 241 | // up there is a point A* answers nothing about. |
src/client/bots.ts +68 −1
| @@ -36,7 +36,7 @@ | ||
| 36 | 36 | |
| 37 | 37 | import { decode, HEADS, STILL, type Intent } from "./action.ts"; |
| 38 | 38 | import { |
| 39 | angleDelta, clamp, dist, forward, newActivations, rng, sample, | |
| 39 | angleDelta, clamp, dist, dist2d, forward, newActivations, rng, sample, | |
| 40 | 40 | type Activations, type Policy, type Vec3, |
| 41 | 41 | } from "../engine/index.ts"; |
| 42 | 42 | import { assign, DEFAULT_JOB, type Job, type RoundPlan, type TeamPlan } from "../plan/plan.ts"; |
| @@ -142,6 +142,8 @@ export type BotBrain = { | ||
| 142 | 142 | jitter: number; // the shaky-hands offset on the view, in degrees |
| 143 | 143 | jitterApplied: number; |
| 144 | 144 | jitterUntil: number; |
| 145 | /** A sniper's standing decision to be zoomed in, held across ticks: see `snipe`. */ | |
| 146 | scoping: boolean; | |
| 145 | 147 | /** False runs the pre-policy fight: the arena's opponent and its baseline. */ |
| 146 | 148 | useLearned: boolean; |
| 147 | 149 | /** |
| @@ -182,6 +184,7 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr | ||
| 182 | 184 | jitter: 0, |
| 183 | 185 | jitterApplied: 0, |
| 184 | 186 | jitterUntil: 0, |
| 187 | scoping: false, | |
| 185 | 188 | useLearned, |
| 186 | 189 | cover: null, |
| 187 | 190 | coverStep: 0, |
| @@ -616,6 +619,65 @@ export function scriptedFight( | ||
| 616 | 619 | return input; |
| 617 | 620 | } |
| 618 | 621 | |
| 622 | /** | |
| 623 | * Beyond this range, in map units, a bot holding a scoped weapon zooms in to | |
| 624 | * take the fight. Hip-firing the AWP on the move is a cone of 7.7° — four | |
| 625 | * bodies wide at 1000 units — and the scope takes 85% of it off (`spreadOf` in | |
| 626 | * `weapons.ts`), so wherever a target is more than a body or two wide in that | |
| 627 | * cone the scope is worth its price. The price is the feet: a scoped player | |
| 628 | * walks, and a walker is a slow target. Closer than this the hip cone is | |
| 629 | * already about a body wide, and a bot keeps its speed instead. | |
| 630 | */ | |
| 631 | export const SCOPE_RANGE = 500; | |
| 632 | ||
| 633 | /** | |
| 634 | * Nearer than this a bot already zoomed in comes back out. It is below | |
| 635 | * `SCOPE_RANGE` on purpose: an enemy stepping back and forth across one line | |
| 636 | * would otherwise flip the request every tick, fighting the gate `applyInput` | |
| 637 | * puts on the scope while the bolt cycles, and a bot that cannot decide | |
| 638 | * whether it is zoomed never takes the steady shot either way. | |
| 639 | */ | |
| 640 | export const UNSCOPE_RANGE = 350; | |
| 641 | ||
| 642 | /** Below this skill a bot fires a sniper rifle the way it fires anything: from the hip. */ | |
| 643 | export const SCOPE_SKILL = 0.3; | |
| 644 | ||
| 645 | /** | |
| 646 | * The most of its stick a scoped bot moves with, on either axis. Enough to | |
| 647 | * shuffle off an angle, not enough to slide: at walk speed it is ~20 u/s. | |
| 648 | */ | |
| 649 | export const SCOPED_MOVE = 0.15; | |
| 650 | ||
| 651 | /** | |
| 652 | * A scoped bot holds the trigger until its feet are this slow, in units a | |
| 653 | * second. At 60 the shot is 0.11° — a two-unit cone at 1000 units — where the | |
| 654 | * same shot at the 130 of a scoped walk is 0.49°. | |
| 655 | */ | |
| 656 | export const STEADY_SPEED = 60; | |
| 657 | ||
| 658 | /** | |
| 659 | * Sniper discipline, on top of what the policy asked for: zoom in on a target | |
| 660 | * past `SCOPE_RANGE`, stop moving once zoomed, and wait for the feet to settle | |
| 661 | * before the shot. It only ever reads and trims an `Input` — the policy's | |
| 662 | * action space has no scope in it and its observation is unchanged, so the | |
| 663 | * shipped weights still mean what they were trained on. `holding` is the gun | |
| 664 | * the input is about, which is the one a swap this tick is handing over. | |
| 665 | */ | |
| 666 | export function snipe(bot: Player, brain: BotBrain, holding: WeaponId, target: Player, input: Input): Input { | |
| 667 | if (WEAPONS[holding]?.zoom === undefined || brain.skill < SCOPE_SKILL) { | |
| 668 | brain.scoping = false; | |
| 669 | return input; | |
| 670 | } | |
| 671 | const range = dist2d(bot.pos, target.pos); | |
| 672 | if (brain.scoping ? range < UNSCOPE_RANGE : range > SCOPE_RANGE) brain.scoping = !brain.scoping; | |
| 673 | input.scope = brain.scoping; | |
| 674 | if (!brain.scoping && !bot.scoped) return input; | |
| 675 | input.forward = clamp(input.forward, -SCOPED_MOVE, SCOPED_MOVE); | |
| 676 | input.side = clamp(input.side, -SCOPED_MOVE, SCOPED_MOVE); | |
| 677 | if (Math.hypot(bot.vel[0], bot.vel[1]) > STEADY_SPEED) input.fire = false; | |
| 678 | return input; | |
| 679 | } | |
| 680 | ||
| 619 | 681 | /** |
| 620 | 682 | * A bot with nothing left to fire, which on this map means a bot down to its |
| 621 | 683 | * knife. The policy is not asked: it learned a rifle duel and is content to |
| @@ -711,6 +773,7 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 711 | 773 | brain.cover = null; |
| 712 | 774 | brain.coverFor = -1; |
| 713 | 775 | brain.coverLook = null; |
| 776 | brain.scoping = false; | |
| 714 | 777 | input.switchTo = bot.primary ?? bot.secondary; |
| 715 | 778 | } |
| 716 | 779 | const job = jobOf(world, bot, brain); |
| @@ -774,8 +837,12 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 774 | 837 | input.side = intent.side; |
| 775 | 838 | input.walk = intent.walk; |
| 776 | 839 | input.duck = intent.duck; |
| 840 | // The scope is not the policy's either: it trained without one. Not the | |
| 841 | // scripted brain — it is the baseline, and it does not move. | |
| 842 | snipe(bot, brain, holding, target, input); | |
| 777 | 843 | return withBomb(world, bot, input); |
| 778 | 844 | } |
| 845 | brain.scoping = false; | |
| 779 | 846 | |
| 780 | 847 | // --- topping up, with nobody in sight. Not the scripted brain: it is the |
| 781 | 848 | // baseline, and it does not move. |
src/games.ts +7 −1
| @@ -112,8 +112,14 @@ export const GAMES: readonly Game[] = [ | ||
| 112 | 112 | * its own number a few hundred bytes clear, and three of them in a row is how |
| 113 | 113 | * a ceiling ends up tighter than the rule above, so this one is measured plus |
| 114 | 114 | * seven per cent again rather than a hair over today. |
| 115 | * | |
| 116 | * Raised by #385, the sync that took upstream #233 (a bot scopes the AWP): | |
| 117 | * #383's prediction and #386's radio had already left `main` 1,226 raw and | |
| 118 | * 178 gzipped bytes over, and `snipe` is 439 raw and 161 gzipped more. | |
| 119 | * 133,665 raw and 51,839 gzipped, measured by `bun run scripts/build.ts`; | |
| 120 | * the ceiling is that plus about five per cent. | |
| 115 | 121 | */ |
| 116 | ceiling: { raw: 132_000, gzip: 51_500 }, | |
| 122 | ceiling: { raw: 140_500, gzip: 54_500 }, | |
| 117 | 123 | assets: [ |
| 118 | 124 | { |
| 119 | 125 | name: "weights", |
new above
offline