Public: anyone can read this; the crew can change it.

forked from trevato/cs

Take updates from trevato/cs #388

Merged opened this in answer to

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

Merged

torque/cs-engine:torque/385-take-updates-from-trevato-cs → main 3 commits fork

  • Merged. torque/385-take-updates-from-trevato-cs was merged into main as a645b77.

Review

Sign in to review this change.
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
15561556 too is what is left in its hands. Neither reads the skill, and neither touches
15571557 the arena's scripted baseline.
15581558
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
15591573 - **What it sees** — 33 numbers, all egocentric (`src/client/observe.ts`): the
15601574 sine and cosine of its aim error, the range, the enemy's health and whether it
15611575 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
4949
5050 test("the entry table is what the app ships today, each with the size it may not pass", () => {
5151 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 } },
5353 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 44_000, gzip: 18_200 } },
5454 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 26_800, gzip: 11_400 } },
5555 ]);
src/client/bots.test.ts +90 −2
@@ -3,7 +3,7 @@ import { PACKED, useShipped } from "./shipped.ts";
33 import { unpack, type Policy } from "../engine/index.ts";
44 import {
55 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,
77 } from "./bots.ts";
88 import { radio } from "./radio.ts";
99 import { CALL_LIFE } from "../plan/call.ts";
@@ -12,7 +12,8 @@ import { grip } from "./spray.ts";
1212 import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts";
1313 import { dist, type Vec3 } from "../engine/index.ts";
1414 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";
1617 import { DE_FORGE } from "./map.ts";
1718 import { COMPETITIVE } from "./match.ts";
1819 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", () => {
148149 expect(immediate.fire).toBe(false);
149150 });
150151
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
151239 test("a goal nothing can walk to is nudged to a cell something can", () => {
152240 // A crate is solid to the search and standing on it is not: a bomb dropped
153241 // up there is a point A* answers nothing about.
src/client/bots.ts +68 −1
@@ -36,7 +36,7 @@
3636
3737 import { decode, HEADS, STILL, type Intent } from "./action.ts";
3838 import {
39 angleDelta, clamp, dist, forward, newActivations, rng, sample,
39 angleDelta, clamp, dist, dist2d, forward, newActivations, rng, sample,
4040 type Activations, type Policy, type Vec3,
4141 } from "../engine/index.ts";
4242 import { assign, DEFAULT_JOB, type Job, type RoundPlan, type TeamPlan } from "../plan/plan.ts";
@@ -142,6 +142,8 @@ export type BotBrain = {
142142 jitter: number; // the shaky-hands offset on the view, in degrees
143143 jitterApplied: number;
144144 jitterUntil: number;
145 /** A sniper's standing decision to be zoomed in, held across ticks: see `snipe`. */
146 scoping: boolean;
145147 /** False runs the pre-policy fight: the arena's opponent and its baseline. */
146148 useLearned: boolean;
147149 /**
@@ -182,6 +184,7 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
182184 jitter: 0,
183185 jitterApplied: 0,
184186 jitterUntil: 0,
187 scoping: false,
185188 useLearned,
186189 cover: null,
187190 coverStep: 0,
@@ -616,6 +619,65 @@ export function scriptedFight(
616619 return input;
617620 }
618621
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
619681 /**
620682 * A bot with nothing left to fire, which on this map means a bot down to its
621683 * 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 {
711773 brain.cover = null;
712774 brain.coverFor = -1;
713775 brain.coverLook = null;
776 brain.scoping = false;
714777 input.switchTo = bot.primary ?? bot.secondary;
715778 }
716779 const job = jobOf(world, bot, brain);
@@ -774,8 +837,12 @@ export function botThink(world: World, bot: Player, dt: number): Input {
774837 input.side = intent.side;
775838 input.walk = intent.walk;
776839 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);
777843 return withBomb(world, bot, input);
778844 }
845 brain.scoping = false;
779846
780847 // --- topping up, with nobody in sight. Not the scripted brain: it is the
781848 // baseline, and it does not move.
src/games.ts +7 −1
@@ -112,8 +112,14 @@ export const GAMES: readonly Game[] = [
112112 * its own number a few hundred bytes clear, and three of them in a row is how
113113 * a ceiling ends up tighter than the rule above, so this one is measured plus
114114 * 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.
115121 */
116 ceiling: { raw: 132_000, gzip: 51_500 },
122 ceiling: { raw: 140_500, gzip: 54_500 },
117123 assets: [
118124 {
119125 name: "weights",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs