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

forked from trevato/cs · proposes

bot spray control is pretty mid #192

Merged opened this in answer to

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

Merged

vesper/cs-engine:vesper/190-bot-spray-control-is-pretty-mid → main 1 commit fork

  • Merged. vesper/190-bot-spray-control-is-pretty-mid was merged into main as b0e54bc.

Review

Sign in to review this change.
16 files changed +548 −107
README.md +32 −5
@@ -780,12 +780,12 @@ already solves it, so that stayed as it was: pathing, buying, planting,
780780 defusing, listening for shots through walls. The fight is a reflex, and that is
781781 now a policy learned with PPO.
782782
783 - **What it sees** — 32 numbers, all egocentric (`src/client/observe.ts`): the
783 - **What it sees** — 33 numbers, all egocentric (`src/client/observe.ts`): the
784784 sine and cosine of its aim error, the range, the enemy's health and whether it
785785 is looking back, its own health, armour, magazine, recoil, current inaccuracy
786786 and how long since it last saw anything, plus how much open floor is ahead and
787 to each side. Nothing in map coordinates, so what it learns in one corridor
788 works in the next one.
787 to each side, and how much of its own spray pattern its hands hold. Nothing in
788 map coordinates, so what it learns in one corridor works in the next one.
789789 - **What it does** — six small categorical heads (`src/client/action.ts`): a
790790 turn rate, a pitch rate, forward, sideways, fire, and stance. Flattening a
791791 structured action into independent heads instead of one 3402-way choice is the
@@ -827,12 +827,39 @@ now a policy learned with PPO.
827827
828828 Skill is still a handicap laid on top, so the ladder the game already had
829829 survives: a low-skill bot gets the same intent from the policy but cannot turn
830 as fast, has a wobble on its aim it cannot see, and waits longer before it
831 shoots. `src/client/bots.ts` keeps the pre-policy fight as `scriptedFight`,
830 as fast, has a wobble on its aim it cannot see, waits longer before it shoots,
831 and holds less of its own spray. `src/client/bots.ts` keeps the pre-policy fight as `scriptedFight`,
832832 unused by the game, because it is the opponent the policy trains against and the
833833 baseline it is measured against — a baseline that moves with the policy measures
834834 nothing.
835835
836 ### Spray control
837
838 A rifle held down climbs a fixed pattern and six tenths of that climb is where
839 the round actually goes, so the twentieth bullet of an AK burst leaves the
840 barrel about six degrees above the crosshair. A person answers that by pulling
841 the mouse down. `src/client/spray.ts` is what a bot's hands do about it: a
842 *grip* — the share of the pattern they cancel, from 0.22 for fodder to 0.9 for
843 the best hands in the game, never 1 — and a *rate*, how fast that pull catches
844 up with the climb, because a pull is a reflex chasing a gun and not a rail. The
845 pull is on the view and nowhere else, so the bullets that come out are the ones
846 a person pulling down would have sent; a flashed bot holds none of it; and a
847 burst that ends gives the whole pull back, so nothing of it outlives the spray.
848
849 The half that is not in that file is the aim. Before this, the shaped reward in
850 the arena was paid on *yaw* alone — how squarely the bot was turned towards the
851 enemy — and pitch was left to the damage term to work out. What it worked out
852 was to point at the floor and let the climb walk the spray up a body: measured
853 over 120 duels, the crosshair sat five to seven degrees below the chest through
854 the whole magazine, the first round of a burst hit 7% of the time and the burst
855 as a whole hit 22%. That is a way to win a fight, and it is the reason the
856 answer here was not to bolt a pull onto the old policy — hands that pull down
857 under a head that already aims low just miss lower. So the arena now gives the
858 learner the same hands the game's bots have, with the grip among the numbers it
859 observes, and pays the shaping on `muzzleError`: the angle between the line to
860 the enemy's chest and where the *next round* would actually go, recoil and all.
861 Aim at the body, hold the pattern, and the spray lands.
862
836863 The site at <https://puffer.ai/> could not be reached from this sandbox (the
837864 network here is the forge and the package registry), so the influence is from
838865 what PufferLib is known for rather than from its documentation: flat observation
src/client/bots.test.ts +42 −2
@@ -1,7 +1,8 @@
11 import { expect, test } from "bun:test";
22 import {
3 applyIntent, blindness, botThink, CARD, flashed, makeBrain, pathTo, policy, think,
3 applyIntent, blindness, botThink, CARD, flashed, makeBrain, pathTo, policy, ride, think,
44 } from "./bots.ts";
5 import { grip } from "./spray.ts";
56 import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts";
67 import { dist, type Vec3 } from "../engine/index.ts";
78 import { OBS_SIZE } from "./observe.ts";
@@ -60,12 +61,47 @@ test("a bot turns towards a target it can see, and shoots at it", () => {
6061 expect(fired).toBeGreaterThan(0);
6162 });
6263
64 test("the hands hold the gun down through a spray and give the whole pull back", () => {
65 const { world, bot } = duel();
66 const brain = bot.brain!;
67 bot.weapon = "ak47";
68 bot.pitch = 0;
69 bot.yaw = 180;
70 // Ten rounds into a spray: the pattern has climbed and the hands are on it.
71 bot.shotIndex = 10;
72 for (let t = 0; t < 32; t++) ride(bot, brain, brain.grip, 1 / 64);
73 expect(bot.pitch).toBeGreaterThan(1); // pointing below where it was aiming
74 expect(brain.pull[1]).toBeCloseTo(bot.pitch, 9);
75 expect(brain.pull[0]).toBeCloseTo(bot.yaw - 180, 9);
76 // The world puts shotIndex back a third of a second after the trigger comes
77 // up, and the pull unwinds with it: nothing of it outlives the burst.
78 bot.shotIndex = 0;
79 for (let t = 0; t < 64; t++) ride(bot, brain, brain.grip, 1 / 64);
80 expect(bot.pitch).toBeCloseTo(0, 2);
81 expect(bot.yaw).toBeCloseTo(180, 2);
82 expect(Math.hypot(brain.pull[0], brain.pull[1])).toBeLessThan(0.01);
83
84 // And the scripted brain never rides at all: it is the baseline the policy is
85 // measured against, and a baseline that moves measures nothing.
86 const scripted = duel();
87 scripted.bot.brain = makeBrain(1, 99, false);
88 scripted.bot.weapon = "ak47";
89 scripted.bot.shotIndex = 10;
90 for (let t = 0; t < 32; t++) botThink(scripted.world, scripted.bot, 1 / 64);
91 expect(scripted.bot.brain.pull).toEqual([0, 0]);
92 });
93
6394 test("skill is a handicap on the hands, not on the policy", () => {
6495 const fast = makeBrain(1, 7);
6596 const slow = makeBrain(0, 7);
6697 expect(slow.aimSpeed).toBeLessThan(fast.aimSpeed);
6798 expect(slow.aimError).toBeGreaterThan(fast.aimError);
6899 expect(slow.reaction).toBeGreaterThan(fast.reaction);
100 // Spray control is on the same ladder: fodder holds a fifth of the pattern
101 // and chases it slowly, the best hands in the game hold nine tenths.
102 expect(slow.grip).toBeLessThan(fast.grip);
103 expect(fast.grip).toBeCloseTo(grip(1), 12);
104 expect(slow.gripRate).toBeLessThan(fast.gripRate);
69105
70106 // The same intent, asked of both: the slow one cannot turn as far.
71107 const { world, bot } = duel();
@@ -138,6 +174,7 @@ test("a flashed bot keeps its policy and loses its hands: slower, wilder, later,
138174 expect(clear.aimSpeed).toBe(brain.aimSpeed);
139175 expect(clear.aimError).toBe(brain.aimError);
140176 expect(clear.reaction).toBe(brain.reaction);
177 expect(clear.grip).toBe(brain.grip);
141178
142179 bot.blind = FLASH.flash.maxBlind;
143180 const hit = flashed(bot, brain);
@@ -146,6 +183,9 @@ test("a flashed bot keeps its policy and loses its hands: slower, wilder, later,
146183 expect(hit.aimError).toBeCloseTo(brain.aimError * 7, 6);
147184 expect(hit.reaction).toBeCloseTo(brain.reaction * 4, 6);
148185 expect(hit.sight).toBeLessThan(clear.sight);
186 // And it holds none of its spray: a pair of hands that cannot see the gun
187 // cannot ride the pattern either.
188 expect(hit.grip).toBe(0);
149189 // Half way through the flash it is half of each: the number in the world is
150190 // the only thing that decides, and it is the same one the HUD paints.
151191 bot.blind = FLASH.flash.maxBlind / 2;
@@ -153,7 +193,7 @@ test("a flashed bot keeps its policy and loses its hands: slower, wilder, later,
153193
154194 // Its head is untouched: the observation, the heads and the weights know
155195 // nothing about any of this.
156 expect(OBS_SIZE).toBe(32);
196 expect(OBS_SIZE).toBe(33);
157197 expect(brain.obs.length).toBe(OBS_SIZE);
158198 });
159199
src/client/bots.ts +50 −5
@@ -18,6 +18,7 @@ import {
1818 } from "../engine/index.ts";
1919 import { colOf, findPath, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts";
2020 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
21 import { ease, grip, gripRate, sprayPull } from "./spray.ts";
2122 import { CARD, PACKED } from "./weights.ts";
2223 import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts";
2324 import { buy } from "./economy.ts";
@@ -41,6 +42,10 @@ export type BotBrain = {
4142 reaction: number; // seconds between seeing and shooting
4243 aimSpeed: number; // degrees a second
4344 aimError: number; // degrees of standing error
45 grip: number; // 0..1, the share of the spray pattern these hands pull back
46 gripRate: number; // how fast that pull catches up, e-foldings a second
47 /** Where the pull is holding the view right now: [yaw, pitch] degrees. */
48 pull: [number, number];
4449 goal: Vec3 | null;
4550 path: Vec3[];
4651 step: number;
@@ -76,6 +81,9 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
7681 reaction: 0.5 - skill * 0.33,
7782 aimSpeed: 180 + skill * 620,
7883 aimError: 4.5 - skill * 3.6,
84 grip: grip(skill),
85 gripRate: gripRate(skill),
86 pull: [0, 0],
7987 goal: null, path: [], step: 0, repathAt: 0,
8088 targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0,
8189 strafe: 0, strafeUntil: 0, suspect: null, plannedFor: "",
@@ -111,21 +119,51 @@ export const blindness = (bot: Player): number => clamp(bot.blind / FLASH.flash.
111119 /**
112120 * What a flash leaves a bot with. Fully blind it turns at a fifth of its usual
113121 * speed, its hands wander seven times as far off centre, it takes four times as
114 * long to decide to shoot at all, and it only notices an enemy inside a
115 * twelfth of its usual sight — which is roughly what a person flashed in the
116 * face does, and is the whole of the disadvantage. Its policy is untouched:
117 * `OBS_SIZE`, the heads and the shipped weights know nothing about this.
122 * long to decide to shoot at all, it holds none of its spray pattern, and it
123 * only notices an enemy inside a twelfth of its usual sight — which is roughly
124 * what a person flashed in the face does, and is the whole of the disadvantage.
125 * Its policy is untouched: `OBS_SIZE`, the heads and the shipped weights know
126 * nothing about this.
118127 */
119 export function flashed(bot: Player, brain: BotBrain): { aimSpeed: number; aimError: number; reaction: number; sight: number } {
128 export function flashed(
129 bot: Player,
130 brain: BotBrain,
131 ): { aimSpeed: number; aimError: number; reaction: number; sight: number; grip: number } {
120132 const b = blindness(bot);
121133 return {
122134 aimSpeed: brain.aimSpeed * (1 - 0.8 * b),
123135 aimError: brain.aimError * (1 + 6 * b),
124136 reaction: brain.reaction * (1 + 3 * b),
125137 sight: SIGHT * (1 - 0.92 * b),
138 grip: brain.grip * (1 - b),
126139 };
127140 }
128141
142 /**
143 * One tick of the bot riding its own recoil: the pull eases towards what would
144 * cancel the shot it is about to take, and the *change* in it is what moves the
145 * view — exactly the way the wobble below is applied, and for the same reason.
146 * The view carries the pull, nothing else does, so the bullets that leave the
147 * barrel are the ones a person pulling down would have sent.
148 *
149 * `bot.shotIndex` is where the next round lands in the pattern, and the world
150 * puts it back to zero a third of a second after the trigger comes up — so a
151 * bot that stops spraying eases back up to where it was aiming, with the whole
152 * of the pull given back. Nothing here is a net offset that outlives a burst.
153 *
154 * The policy sees the pulled view, because the arena it learned in pulls the
155 * same way (`src/rl/arena.ts`) and its own grip is one of the 33 numbers it is
156 * handed. A policy told nothing about its hands would aim for a recoil nobody
157 * was about to make.
158 */
159 export function ride(bot: Player, brain: BotBrain, grip: number, dt: number): void {
160 const want = sprayPull(bot.weapon, bot.shotIndex, grip);
161 const next = ease(brain.pull, want, brain.gripRate, dt);
162 bot.yaw += next[0] - brain.pull[0];
163 bot.pitch = clamp(bot.pitch + next[1] - brain.pull[1], -89, 89);
164 brain.pull = next;
165 }
166
129167 /** How far a bot notices an enemy at all, in units, with its eyes working. */
130168 const SIGHT = 4000;
131169
@@ -350,12 +388,19 @@ export function botThink(world: World, bot: Player, dt: number): Input {
350388 brain.memory.sinceSeen = 9;
351389 brain.hold = 0;
352390 brain.intent = { ...STILL };
391 // A fresh round hands the body a fresh view; the pull is not owed back.
392 brain.pull = [0, 0];
353393 input.switchTo = bot.primary ?? bot.secondary;
354394 }
355395 if (world.match.phase === "freeze") {
356396 return input;
357397 }
358398
399 // The hands ride the pattern whatever the head is doing, so a bot that stops
400 // fighting — or stops firing — gives the pull back rather than walking off
401 // aiming at the floor. Not on the scripted brain: that one is the baseline
402 // the policy is measured against, and a baseline that moves measures nothing.
403 if (brain.useLearned) ride(bot, brain, flashed(bot, brain).grip, dt);
359404 listen(world, bot, brain);
360405 const target = pickTarget(world, bot, brain);
361406 if (target !== null) {
src/client/observe.ts +7 −2
@@ -1,5 +1,5 @@
11 /**
2 * What a bot sees, as a flat vector of 32 numbers in roughly [-1, 1].
2 * What a bot sees, as a flat vector of 33 numbers in roughly [-1, 1].
33 *
44 * Everything here is egocentric — distances and directions are in the bot's
55 * own frame, never the map's — so a policy trained in one corner of de_forge
@@ -19,7 +19,7 @@ import { inaccuracy, WEAPONS } from "./weapons.ts";
1919 import type { Player, World } from "./world.ts";
2020 import { aimAt, bodyCentre, currentWeapon, eyePos } from "./world.ts";
2121
22 export const OBS_SIZE = 32;
22 export const OBS_SIZE = 33;
2323
2424 /** What the bot remembers between ticks that the world does not store for it. */
2525 export type Memory = {
@@ -120,6 +120,11 @@ export function observe(
120120 out[at + 29] = theirSpeed;
121121 out[at + 30] = ahead;
122122 out[at + 31] = right - left;
123 // How much of its own spray pattern these hands hold (`src/client/spray.ts`).
124 // A policy that did not know this would aim for a recoil nobody was about to
125 // make: the same crosshair is right for a bot that rides the pattern and
126 // wrong for one whose gun climbs out of the fight.
127 out[at + 32] = bot.brain?.grip ?? 0;
123128 return out;
124129 }
125130
src/client/replay.test.ts +18 −9
@@ -35,6 +35,15 @@ const TICKS = SECONDS * TICKS_PER_SECOND;
3535
3636 /**
3737 * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for
38 * #190, which gave the bots spray control: their hands now ride the recoil
39 * pattern (`src/client/spray.ts`) and the policy they fight with was retrained
40 * in an arena whose learner has those hands and whose shaping is paid on where
41 * the round would actually land. The first nine seconds — the freeze and the
42 * walk out of spawn — are the same numbers to the character, and every second
43 * from the tenth on, which is the first at which a bot has an enemy to decide
44 * about, hashes differently. Nobody's footing moved; the shooting did.
45 *
46 * Before that it was re-pinned for
3847 * #177, which retrained the policy the ten bots in it fight with: every second
3948 * from the tenth on — the first at which a bot has an enemy to decide about —
4049 * hashes differently, and the nine before it are the numbers #164 left to the
@@ -54,18 +63,18 @@ const TICKS = SECONDS * TICKS_PER_SECOND;
5463 * anything: the bots buy no grenades and the scripted person never presses
5564 * either key, so no flash goes off and no `blind` is ever non-zero here.
5665 */
57 const ROUND = "3dd837d703034e7c0ba08c2633300e1b28f8e0b4e160193077dba84438538c3e";
66 const ROUND = "dc3121bc9214db5c1928c61e155c022b827dc361bb72e128a84a4c28bc08f4b9";
5867 const ROUND_SECONDS = [
5968 "5978706f2efd", "aa6aa9a5a3c9", "f822e864db93", "6f1cf4de1926",
6069 "25923b9cb132", "83b06bc2430e", "b97f8d621af5", "c1b94afedda0",
61 "700c15a0f6c1", "c38f50dab1ca", "7a99f7ac430d", "704874f88a78",
62 "362cdfb7dc07", "dc096b96ac6f", "c7f5998a8d2a", "0d20ab5e1686",
63 "8ca62062fe8a", "87d4d63b8ff1", "37390d1539de", "eaeef81da1d6",
64 "a33856d0ce01", "6dde9a94751a", "4c929dfc9607", "ee774c17027d",
65 "9135e1bb46df", "643a85bf9c93", "3b62f2485ccd", "f6cee0354c77",
66 "94578e2ff523", "7efd91c9c90d", "769e3d6d5c1f", "861c0e487740",
67 "471399956ceb", "4391147a2f38", "12a4eed48969", "fcde6163b6d4",
68 "ec71e47d09bb", "2b5f5f764b2f", "ed40a891d704", "8d43dd52fa3e",
70 "700c15a0f6c1", "32921cefb2ff", "8241df979380", "c815eb110a37",
71 "95d7115059f4", "e131dfcfdb54", "a56027278fc7", "3e66ed95e89c",
72 "df1d01273592", "392385270f63", "c41cf61d1f0f", "e5a9d02a9e68",
73 "88d708808f5f", "b956000db9df", "c04cbb5e0444", "94c30ef49e7d",
74 "d5771420a724", "f3ed6f1d7224", "a797590b29d2", "41c4e0317add",
75 "554dbc193f79", "bbf96042b6c7", "64a02bf5f0a8", "ae9d223ce2b2",
76 "0599d003b07e", "75b6b121770e", "558606b5b303", "c94ba5961c21",
77 "3da066135df7", "51037e8ff1cb", "c6cc8d93ed31", "a001ff0f1424",
6978 ];
7079
7180 /**
src/client/spray.test.ts added +129
@@ -0,0 +1,129 @@
1 import { expect, test } from "bun:test";
2 import { BEST_GRIP, ease, FAST_RATE, grip, gripRate, SLOW_RATE, sprayPull, WORST_GRIP } from "./spray.ts";
3 import { recoilOffset, RECOIL_SHARE } from "./weapons.ts";
4 import { DE_FORGE } from "./map.ts";
5 import { worldOf } from "./level.ts";
6 import { COMPETITIVE } from "./match.ts";
7 import { aimAt, bodyCentre, canSee, createWorld, shoot, type Player } from "./world.ts";
8
9 const TICK = 1 / 64;
10
11 test("the pull is the exact mirror of what recoil does to the bullet", () => {
12 // `shoot` sends the round at yaw - right·RECOIL_SHARE and pitch - up·RECOIL_SHARE.
13 // Full grip holds the view the same distance the other way, so the two cancel
14 // to nothing and the bullet leaves along the crosshair the burst started on.
15 for (const shot of [0, 1, 3, 7, 15, 29, 99]) {
16 const [right, up] = recoilOffset("ak47", shot);
17 const [yaw, pitch] = sprayPull("ak47", shot, 1);
18 expect(yaw).toBeCloseTo(right * RECOIL_SHARE, 12);
19 expect(pitch).toBeCloseTo(up * RECOIL_SHARE, 12);
20 // Half the grip is half the pull: the share is a straight multiplier.
21 expect(sprayPull("ak47", shot, 0.5)[1]).toBeCloseTo(pitch / 2, 12);
22 // And no grip at all is no pull at all, which is what the game did before.
23 expect(sprayPull("ak47", shot, 0)).toEqual([0, 0]);
24 }
25 // The first round of a burst is not recoiling yet, so there is nothing to hold.
26 expect(sprayPull("ak47", 0, 1)).toEqual([0, 0]);
27 // Nothing thrown has a pattern, so nothing thrown has a pull.
28 expect(sprayPull("flash", 5, 1)).toEqual([0, 0]);
29 });
30
31 test("a pair of hands is a share and a speed, and skill is the ladder between", () => {
32 expect(grip(0)).toBeCloseTo(WORST_GRIP, 12);
33 expect(grip(1)).toBeCloseTo(BEST_GRIP, 12);
34 expect(grip(0.5)).toBeGreaterThan(grip(0.25));
35 expect(gripRate(0)).toBeCloseTo(SLOW_RATE, 12);
36 expect(gripRate(1)).toBeCloseTo(FAST_RATE, 12);
37 // Off the end of the ladder is the end of the ladder, never past it: the
38 // best hands in the game still leave a tenth of the pattern uncontrolled.
39 expect(grip(3)).toBeCloseTo(BEST_GRIP, 12);
40 expect(grip(-1)).toBeCloseTo(WORST_GRIP, 12);
41 expect(BEST_GRIP).toBeLessThan(1);
42 });
43
44 test("the pull eases towards the climb, and how long a tick is cannot change it", () => {
45 const to: [number, number] = [2, 6];
46 const one = ease([0, 0], to, 20, TICK);
47 expect(one[1]).toBeGreaterThan(0);
48 expect(one[1]).toBeLessThan(to[1]); // it lags: a reflex chasing a gun
49 const half = ease(ease([0, 0], to, 20, TICK / 2), to, 20, TICK / 2);
50 expect(half[0]).toBeCloseTo(one[0], 12);
51 expect(half[1]).toBeCloseTo(one[1], 12);
52 // Fast hands close more of the gap in the same tick than slow ones.
53 expect(ease([0, 0], to, FAST_RATE, TICK)[1]).toBeGreaterThan(ease([0, 0], to, SLOW_RATE, TICK)[1]);
54 // Nowhere to go is nowhere: hands that are not spraying add exactly zero.
55 expect(ease([0, 0], [0, 0], 20, TICK)).toEqual([0, 0]);
56 // And a pull left on the view unwinds back to nothing once the burst stops.
57 let pull: [number, number] = [2, 6];
58 for (let i = 0; i < 64; i++) pull = ease(pull, [0, 0], FAST_RATE, TICK);
59 expect(Math.hypot(pull[0], pull[1])).toBeLessThan(0.01);
60 });
61
62 /**
63 * The claim the whole file rests on, measured on the real bullets: hold an AK
64 * down for a full magazine at a standing body six, eight and ten cells down
65 * long A, and add up the damage. The view is pulled by the same arithmetic
66 * `bots.ts` rides with, and the only difference between two runs is the pair
67 * of hands — the same world from the same seed, the same ticks, so the same
68 * accuracy rolls come out of `world.random` in the same order.
69 */
70 function sprayDamage(share: number, rate: number, cells: number): number {
71 const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, 4242);
72 world.match.phase = "live";
73 const shooter = world.players[1] as Player;
74 const dummy = world.me;
75 // Down long A, which is four cells wide and twenty long: a clear line.
76 shooter.pos = worldOf(DE_FORGE, 36, 12, 2);
77 dummy.pos = worldOf(DE_FORGE, 36, 12 + cells, 2);
78 expect(canSee(world, shooter, dummy)).toBe(true);
79 shooter.weapon = "ak47";
80 shooter.primary = "ak47";
81 shooter.ammo.ak47 = { mag: 30, reserve: 90 };
82 const aim = aimAt(shooter, bodyCentre(dummy));
83 shooter.yaw = aim.yaw;
84 shooter.pitch = aim.pitch;
85
86 let pull: [number, number] = [0, 0];
87 let fired = 0;
88 for (let tick = 0; tick < 64 * 4 && fired < 30; tick++) {
89 world.time += TICK;
90 const next = ease(pull, sprayPull("ak47", shooter.shotIndex, share), rate, TICK);
91 shooter.yaw += next[0] - pull[0];
92 shooter.pitch += next[1] - pull[1];
93 pull = next;
94 if (shoot(world, shooter)) fired += 1;
95 // A dummy that keeps standing there, so thirty rounds are thirty chances.
96 dummy.health = 100;
97 dummy.alive = true;
98 }
99 expect(fired).toBe(30);
100 return shooter.damage;
101 }
102
103 /** Ninety rounds: one magazine at each of the three ranges. */
104 const magazines = (skill: number | null): number =>
105 [6, 8, 10].reduce(
106 (sum, cells) =>
107 sum + (skill === null
108 ? sprayDamage(0, FAST_RATE, cells)
109 : sprayDamage(grip(skill), gripRate(skill), cells)),
110 0,
111 );
112
113 test("a controlled spray stays on the body an uncontrolled one climbs off", () => {
114 const loose = magazines(null); // the bots before this change: no pull at all
115 const fodder = magazines(0);
116 const middling = magazines(0.5);
117 const best = magazines(1);
118 // Ninety AK rounds at a standing body. Uncontrolled the pattern walks off it
119 // after the first handful and 339 damage is the lot; the best hands in the
120 // game keep 3,639 of it on the body, which is a magazine that kills whoever
121 // it started on. The ladder in between is the whole point of a skill number:
122 // 374 for fodder, 743 for the middle of the pack.
123 expect(best).toBeGreaterThan(loose * 3);
124 expect(middling).toBeGreaterThan(loose);
125 expect(best).toBeGreaterThan(middling);
126 expect(middling).toBeGreaterThan(fodder);
127 // Even the worst hands in the game hold a little of it.
128 expect(fodder).toBeGreaterThanOrEqual(loose);
129 });
src/client/spray.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * Spray control: what a pair of hands does about the recoil it just made.
3 *
4 * A rifle held down climbs a fixed pattern (`makePattern` in weapons.ts) and
5 * `RECOIL_SHARE` of that pattern is where the round actually goes, so the
6 * twentieth bullet of an AK burst leaves the barrel some six degrees above the
7 * crosshair. A person answers that by pulling the mouse down and a little
8 * against the drift — the crosshair ends up below the enemy and the bullets
9 * end up on it — and that pull is a *hand* skill, learned by the muscle rather
10 * than decided by the head. It belongs here for the same reason a bot's turn
11 * rate and its wobble do: the policy asks for a crosshair, and how much of the
12 * pattern the hands can hold is what the ladder is made of.
13 *
14 * Two numbers describe a pair of hands:
15 *
16 * grip — the share of the pattern they cancel, 0 (none of it) to 1 (all of
17 * it). Never quite 1, because a bot that erases its own recoil to the
18 * bit is not a person and is not fun to play against.
19 * rate — how fast the pull catches up with the climb, in e-foldings a
20 * second. A pull is a reflex chasing a gun, not a rail: it lags, and
21 * the first shots of a burst are the ones it lags on.
22 *
23 * Nothing here knows what a bot is. It is the recoil pattern, a shot number
24 * and a pair of hands, in and out — the world's bullets are not touched and a
25 * person gets no help from this file.
26 */
27
28 import { clamp } from "../engine/index.ts";
29 import { recoilOffset, RECOIL_SHARE, type WeaponId } from "./weapons.ts";
30
31 /** What the worst hands in the game hold: a quarter of the pattern, no more. */
32 export const WORST_GRIP = 0.22;
33
34 /** And the best. Deliberately short of 1: a perfect spray is nobody's. */
35 export const BEST_GRIP = 0.9;
36
37 /** How fast the worst and the best hands catch up, in e-foldings a second. */
38 export const SLOW_RATE = 7;
39 export const FAST_RATE = 26;
40
41 /** The share of the pattern hands of this skill pull back. */
42 export const grip = (skill: number): number =>
43 WORST_GRIP + (BEST_GRIP - WORST_GRIP) * clamp(skill, 0, 1);
44
45 /** How fast hands of this skill chase the climb. */
46 export const gripRate = (skill: number): number =>
47 SLOW_RATE + (FAST_RATE - SLOW_RATE) * clamp(skill, 0, 1);
48
49 /**
50 * Where hands with this `grip` want to hold the view for shot number `shot` of
51 * a `id` spray: [yaw, pitch] degrees, added to the aim.
52 *
53 * The sign is the mirror of `shoot` in world.ts, which sends the round at
54 * `yaw - right · RECOIL_SHARE` and `pitch - up · RECOIL_SHARE`. Cancelling that
55 * means holding the crosshair the same distance the other way — down the
56 * screen (pitch counts downwards) and into the drift — so at full grip the
57 * bullet goes exactly where the crosshair was before the trigger went down.
58 */
59 export function sprayPull(id: WeaponId, shot: number, grip: number): [number, number] {
60 const [right, up] = recoilOffset(id, shot);
61 return [right * RECOIL_SHARE * grip, up * RECOIL_SHARE * grip];
62 }
63
64 /**
65 * One tick of a pull chasing where it wants to be: an exponential ease, so the
66 * gap closes by `1 - e^(-rate·dt)` of itself however long the tick was. Framerate
67 * cannot change it, which is the property a fixed-tick simulation wants anyway,
68 * and a gap of nothing stays exactly nothing — hands that are not spraying add
69 * exactly zero to the view.
70 */
71 export function ease(
72 from: readonly [number, number],
73 to: readonly [number, number],
74 rate: number,
75 dt: number,
76 ): [number, number] {
77 const k = 1 - Math.exp(-Math.max(0, rate) * dt);
78 return [from[0] + (to[0] - from[0]) * k, from[1] + (to[1] - from[1]) * k];
79 }
src/client/weapons.ts +10
@@ -147,6 +147,16 @@ const PATTERNS = new Map<WeaponId, [number, number][]>(
147147 WEAPON_LIST.map((w, i) => [w.id, makePattern(w, 0x5eed + i * 7919)]),
148148 );
149149
150 /**
151 * How much of the pattern reaches the bullet. The muzzle walks the whole of
152 * `makePattern`, and six tenths of that walk is where the round actually goes:
153 * the rest is the gun moving in the hands without the barrel following it all
154 * the way. It is a constant rather than a literal in `shoot` because spray
155 * control is the mirror of it — `src/client/spray.ts` cancels exactly this
156 * share — and the two must never be able to disagree.
157 */
158 export const RECOIL_SHARE = 0.6;
159
150160 /** Where shot number `n` of a burst lands relative to the crosshair. */
151161 export function recoilOffset(id: WeaponId, shot: number): [number, number] {
152162 const pattern = PATTERNS.get(id);
src/client/weights.test.ts +2 −2
@@ -18,13 +18,13 @@ import { OBS_SIZE } from "./observe.ts";
1818 import { PACKED } from "./weights.ts";
1919
2020 /** What `unpack(PACKED)` has dequantised to since the weights were trained. */
21 const DIGEST = "5582db35be828690f086d23bb6ee92c7807696188634bac6c4c8b09730068aa5";
21 const DIGEST = "755d03516ba0122e6b30a04ac575c311e652ef12f38276fa0e5eb6289ed34103";
2222
2323 test("the shipped weights unpack to the shape the game asks the policy for", () => {
2424 const p = unpack(PACKED);
2525 expect(p.shape.heads).toEqual(HEADS);
2626 expect(p.shape.obs).toBe(OBS_SIZE);
27 expect(p.shape.obs).toBe(32);
27 expect(p.shape.obs).toBe(33);
2828 expect(p.w1.length).toBe(p.shape.hidden * p.shape.obs);
2929 for (const v of p.w1) expect(Number.isFinite(v)).toBe(true);
3030 });
src/client/weights.ts +10 −10

Diff not shown — a line 10,883 characters wide (limit 2,000). +10 −10

View file
src/client/world.ts +3 −3
@@ -30,7 +30,7 @@ import {
3030 import * as rules from "./match.ts";
3131 import { CS_HULL, CS_MOVE } from "./movement.ts";
3232 import {
33 WEAPONS, inaccuracy, recoilOffset,
33 WEAPONS, inaccuracy, recoilOffset, RECOIL_SHARE,
3434 type ThrownId, type WeaponId, type Weapon,
3535 } from "./weapons.ts";
3636 import { botBuy, botThink, type BotBrain } from "./bots.ts";
@@ -382,8 +382,8 @@ export function shoot(world: World, p: Player): boolean {
382382 const [rx, ry] = recoilOffset(w.id, p.shotIndex);
383383 p.shotIndex += 1;
384384 const accuracyRoll = (): number => (world.random() + world.random() - 1);
385 const yaw = p.yaw - rx * 0.6 + accuracyRoll() * spread;
386 const pitch = p.pitch - ry * 0.6 + accuracyRoll() * spread;
385 const yaw = p.yaw - rx * RECOIL_SHARE + accuracyRoll() * spread;
386 const pitch = p.pitch - ry * RECOIL_SHARE + accuracyRoll() * spread;
387387 p.punch = [p.punch[0] + (rx - p.punch[0]) * 0.5, p.punch[1] + (ry - p.punch[1]) * 0.5];
388388
389389 const from = eyePos(p);
src/rl/arena.test.ts +54 −2
@@ -4,9 +4,10 @@ import { OBS_SIZE } from "../client/observe.ts";
44 import { colOf, openCells, rowOf, walkable, type Level } from "../client/level.ts";
55 import { DE_FORGE } from "../client/map.ts";
66 import { aimAt, bodyCentre, canSee } from "../client/world.ts";
7 import { recoilOffset, RECOIL_SHARE } from "../client/weapons.ts";
78 import {
8 createArena, EPISODE_TICKS, observeFoe, observeLearner, relevel, reset, SKIP, step, stepInput,
9 stillFoe,
9 createArena, EPISODE_TICKS, muzzleError, observeFoe, observeLearner, relevel, reset, SKIP, step,
10 stepInput, stillFoe,
1011 } from "./arena.ts";
1112 import { holdoutLevels } from "./maps.ts";
1213
@@ -156,3 +157,54 @@ test("the arena leaves no render effects behind to grow without bound", () => {
156157 expect(arena.world.messages.length).toBe(0);
157158 expect(arena.world.match.phase).toBe("live");
158159 });
160
161 test("the shaping is paid on where the round would go, not on where the crosshair is", () => {
162 const arena = createArena(11, stillFoe);
163 const { learner, foe } = arena;
164 learner.pos = [0, 0, 2];
165 foe.pos = [700, 0, 2];
166 learner.weapon = "m4a4";
167 const aim = aimAt(learner, bodyCentre(foe));
168 learner.yaw = aim.yaw;
169 learner.pitch = aim.pitch;
170 // The first round of a burst goes where the crosshair is, so a crosshair on
171 // the chest is an error of nothing.
172 learner.shotIndex = 0;
173 expect(muzzleError(learner, foe)).toBeCloseTo(0, 9);
174 // A dozen rounds in, the same crosshair sends the round over the shoulder —
175 // which is exactly the bot the old yaw-only shaping was happy with.
176 learner.shotIndex = 12;
177 expect(muzzleError(learner, foe)).toBeGreaterThan(2);
178 // Holding the pattern down puts the muzzle back on the body.
179 const [right, up] = recoilOffset("m4a4", 12);
180 learner.yaw = aim.yaw + right * RECOIL_SHARE;
181 learner.pitch = aim.pitch + up * RECOIL_SHARE;
182 expect(muzzleError(learner, foe)).toBeCloseTo(0, 9);
183 });
184
185 test("the learner is handed a pair of hands, and they are not the duel's dice", () => {
186 const arena = createArena(4242, stillFoe);
187 expect(arena.learner.brain).toBe(arena.hands);
188 expect(arena.hands.grip).toBeGreaterThan(0);
189 // The grip the policy is shown is the grip its hands have.
190 const obs = new Float32Array(OBS_SIZE);
191 observeLearner(arena, obs);
192 expect(obs[OBS_SIZE - 1]).toBeCloseTo(arena.hands.grip, 6);
193 // Fresh hands every duel, off a stream of their own: the spawns, the ranges
194 // and the opponent a seed draws are the same whatever the hands came out as.
195 const grips = new Set<number>();
196 const spots: string[] = [];
197 for (let i = 0; i < 8; i++) {
198 reset(arena);
199 grips.add(arena.hands.grip);
200 spots.push(`${arena.learner.pos[0]},${arena.foe.pos[0]}`);
201 }
202 expect(grips.size).toBeGreaterThan(1);
203 const again = createArena(4242, stillFoe);
204 const repeat: string[] = [];
205 for (let i = 0; i < 8; i++) {
206 reset(again);
207 repeat.push(`${again.learner.pos[0]},${again.foe.pos[0]}`);
208 }
209 expect(repeat).toEqual(spots);
210 });
src/rl/arena.ts +48 −3
@@ -13,9 +13,11 @@
1313 */
1414
1515 import { decode, toInput, type Intent } from "../client/action.ts";
16 import { makeBrain, botThink } from "../client/bots.ts";
16 import { makeBrain, botThink, ride, type BotBrain } from "../client/bots.ts";
1717 import { openCells, type Level } from "../client/level.ts";
1818 import { DE_FORGE } from "../client/map.ts";
19 import { grip as gripOf, gripRate } from "../client/spray.ts";
20 import { recoilOffset, RECOIL_SHARE } from "../client/weapons.ts";
1921 import { angleDelta, clamp, dist, resetEffects, rng, type Vec3 } from "../engine/index.ts";
2022 import { observe, OBS_SIZE, type Memory } from "../client/observe.ts";
2123 import type { Input, Player, World } from "../client/world.ts";
@@ -60,6 +62,17 @@ export type Arena = {
6062 random: () => number;
6163 /** What the opponent does this tick. Swapped out for self-play. */
6264 opponent: (arena: Arena, dt: number) => Input;
65 /**
66 * The learner's hands: how much of its spray pattern it holds and how fast
67 * it catches up, drawn afresh each duel from its own stream so the policy
68 * meets every rung of the game's skill ladder. Its grip is one of the
69 * numbers it observes, so it can aim for the recoil it is actually going to
70 * make rather than for an average one.
71 */
72 hands: BotBrain;
73 /** Where those hands are drawn from, kept apart from `random` on purpose:
74 * a duel's spawns, ranges and opponent must not move when the hands do. */
75 handRandom: () => number;
6376 /** How far apart a fresh duel starts. The trainer widens it as it goes. */
6477 minRange: number;
6578 maxRange: number;
@@ -95,8 +108,11 @@ export function createArena(seed: number, opponent = scriptedFoe, level: Level =
95108 const world = createWorld(level, SHORT, "learner", "ct", 1, seed);
96109 const learner = world.me;
97110 const foe = world.players[1] as Player;
111 const hands = makeBrain(0.65, seed ^ 0x2545f491);
112 learner.brain = hands;
98113 const arena: Arena = {
99114 world, open: openOf(level), learner, foe,
115 hands, handRandom: rng(seed ^ 0x2545f491),
100116 memory: { sinceSeen: 9 },
101117 foeMemory: { sinceSeen: 9 },
102118 ticks: 0,
@@ -131,6 +147,7 @@ const equip = (p: Player, weapon: "ak47" | "m4a4"): void => {
131147 p.lastShotAt = -99;
132148 p.punch = [0, 0];
133149 p.damage = 0;
150 if (p.brain !== undefined) p.brain.pull = [0, 0];
134151 };
135152
136153 /**
@@ -147,17 +164,37 @@ function potentialOf(arena: Arena, visible: boolean): number {
147164 const { learner, foe } = arena;
148165 if (!learner.alive || !foe.alive) return 0;
149166 const range = dist(learner.pos, foe.pos);
150 const aim = Math.abs(angleDelta(learner.yaw, aimAt(learner, bodyCentre(foe)).yaw));
167 const aim = muzzleError(learner, foe);
151168 // Three terms: a gentle slope that points the bot the right way from
152169 // anywhere, and two peaks — one a dozen degrees wide, one three — because
153170 // "nearly aimed" and "aimed" are the whole difference between a miss and a
154171 // kill, and a single slope flattens out long before that difference matters.
155172 const aimed = visible
156 ? 0.4 * (1 - aim / 180) + 0.5 * Math.exp(-aim / 12) + 0.5 * Math.exp(-aim / 3)
173 ? 0.4 * clamp(1 - aim / 180, 0, 1) + 0.5 * Math.exp(-aim / 12) + 0.5 * Math.exp(-aim / 3)
157174 : 0;
158175 return aimed + 0.3 * (1 - clamp(range / 2500, 0, 1));
159176 }
160177
178 /**
179 * How far off the body the round this bot is about to fire would land, in
180 * degrees: the crosshair walked by whatever is left of the spray pattern after
181 * the hands have pulled against it, against the line to the chest.
182 *
183 * This is what the shaping above is paid on, and both halves of it matter. It
184 * is the *muzzle* and not the crosshair, so a bot a dozen rounds into a spray
185 * is graded on where the twelfth round goes; and it is an angle and not a yaw,
186 * so pitch is taught at all — shaping on yaw alone left the pitch to the damage
187 * term, and what that taught was to point at the floor and let the climb walk
188 * the spray up a body, which is a 22%-accuracy way to win a fight.
189 */
190 export function muzzleError(bot: Player, target: Player): number {
191 const aim = aimAt(bot, bodyCentre(target));
192 const [right, up] = recoilOffset(bot.weapon, bot.shotIndex);
193 const yaw = angleDelta(bot.yaw - right * RECOIL_SHARE, aim.yaw);
194 const pitch = aim.pitch - (bot.pitch - up * RECOIL_SHARE);
195 return Math.hypot(yaw, pitch);
196 }
197
161198 /**
162199 * A fresh duel. Most of the time the two can see each other from the start,
163200 * because that is the situation worth being good at; the rest of the time they
@@ -202,6 +239,11 @@ export function reset(arena: Arena): void {
202239 learner.pitch = 0;
203240 foe.yaw = aimAt(foe, bodyCentre(learner)).yaw + (random() * 2 - 1) * 90;
204241 foe.pitch = 0;
242 // Fresh hands for the learner, off its own stream.
243 const skill = arena.handRandom();
244 arena.hands.grip = gripOf(skill);
245 arena.hands.gripRate = gripRate(skill);
246 arena.hands.pull = [0, 0];
205247 foe.brain = makeBrain(
206248 0.15 + random() * Math.max(0.05, arena.maxSkill - 0.15),
207249 (random() * 0xffffff) | 0,
@@ -259,6 +301,9 @@ export function step(arena: Arena, intent: Intent, skip = SKIP): StepResult {
259301 for (let k = 0; k < skip; k++) {
260302 learner.yaw += intent.yawRate * TICK;
261303 learner.pitch = clamp(learner.pitch + intent.pitchRate * TICK, -89, 89);
304 // The hands ride the pattern under the decision, exactly as they do in the
305 // game: this is the environment the shipped policy has to be good in.
306 ride(learner, arena.hands, arena.hands.grip, TICK);
262307 const ammo = learner.ammo[learner.weapon];
263308 const tick = stepInput(arena, toInput(intent, ammo !== undefined && ammo.mag === 0), TICK);
264309 out.reward += tick.reward;
src/rl/learned.test.ts +6 −6
@@ -51,7 +51,7 @@ test("the trained policy wins more of the same duels than the bots it replaced",
5151 const scripted = duels("scripted");
5252 expect(scripted.won).toBeGreaterThan(0); // the baseline is not a straw man
5353 expect(learned.won).toBeGreaterThan(scripted.won);
54 // The measured margin is 72% against 38% over 240 duels; this asks for far
54 // The measured margin is 71% against 42% over 240 duels; this asks for far
5555 // less, so it fails on a policy that regressed and not on ordinary variance.
5656 expect(learned.won / DUELS).toBeGreaterThan(0.5);
5757 });
@@ -64,11 +64,11 @@ test("the trained policy wins more of the same duels than the bots it replaced",
6464 *
6565 * What a confused bot does here is not lose — it draws. Six seconds of a duel
6666 * go by, nobody dies, and from the other end of a corridor that is a bot that
67 * has forgotten what it was doing. On exactly these duels the weights before
68 * this pull won 68% and drew 14%, against 71% and 8% on de_forge; the ones
69 * this pull ships win 72% and draw 7%, which is the same fight on a map nobody
70 * wrote. The threshold below sits between the two, so a policy trained on one
71 * map again fails here.
67 * has forgotten what it was doing. On exactly these duels the weights #190
68 * ships — trained with hands that ride the recoil and shaping paid on where
69 * the round lands — win 73% and draw 10%, against 71% on de_forge, while the
70 * scripted baseline wins 49% and draws 19%. The threshold below sits between
71 * the two, so a policy trained on one map again fails here.
7272 */
7373 const UNSEEN_DUELS = 192;
7474
static/app.be118e74ac.js added +58

Diff not shown — a line 72,602 characters wide (limit 2,000). +58

View file
static/app.c6a7c68916.js deleted −58

Diff not shown — a line 71,983 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs