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

The bots have ears: footsteps, a plant and a grenade at their feet pull them round #107

Merged opened this

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

Merged

vesper/cs:vesper/72-the-bots-have-ears-footsteps-a-plant-and → main 2 commits fork

  • Merged. vesper/72-the-bots-have-ears-footsteps-a-plant-and was merged into main as 42f5c5a.

Review

Sign in to review this change.
7 files changed +458 −25
README.md +22 −2
@@ -267,6 +267,8 @@ with the canvas as you turn; nobody re-walks the grid to draw it.
267267 this round, where they fell), `radar` (the blips the
268268 HUD's minimap draws, and nothing it draws them with), `summary` (the end of
269269 the match as numbers), `ears` (a sound's pan and volume, as plain data),
270 `hearing` (what a sound is worth to someone standing somewhere else: how loud
271 it arrives through however many walls, and where they think it came from),
270272 `settings` (mute, volume, sensitivity and difficulty: read, validated, and
271273 never NaN or an unknown name),
272274 `renderer`, `hud`, `input`, `audio`, `main`.
@@ -325,8 +327,8 @@ signed for, so they become the guests they always were.
325327
326328 They are split in two. Getting to the site is a plan — `src/client/plan.ts`
327329 decides it, A* over the map grid walks it, and the rest of it is ordinary code:
328 buying, planting, defusing, picking a dropped bomb back up, listening for shots
329 through walls. The fight is a reflex, and that is a policy learned with PPO.
330 buying, planting, defusing, picking a dropped bomb back up, and listening to
331 the room. The fight is a reflex, and that is a policy learned with PPO.
330332
331333 The plan is one site a side a round, rolled from the match's seed and the round
332334 number rather than by each bot for itself, so the five terrorists arrive
@@ -340,6 +342,24 @@ headless over twenty seeded `SHORT` matches, before and after: the bomb is
340342 planted in 65% of rounds rather than 38%, and 10% of rounds end on the clock
341343 rather than 37%.
342344
345 - **What it hears** — the sounds the world already makes, read off the effect
346 list once a tick (`src/client/hearing.ts`, and `listen` in `bots.ts`). A
347 gunshot is the loudest thing on the map and an AWP the loudest gun; a plant
348 and a defuse carry past the site; a running footstep carries about a
349 corridor and a half; a dry trigger barely leaves the hands it clicked in. A
350 wall between costs a few hundred units of apparent distance rather than
351 silence, and the point a bot turns towards is off by more the further away
352 the noise was — a footstep across the map is a direction, not a pin. A bot
353 never hears itself or its own side.
354 **What stays silent:** walking (<kbd>Shift</kbd>) and crouching make no
355 footstep at all, so a quiet flank is genuinely quiet — that is the whole
356 point of the key. The bomb's own beep is heard by the person and ignored by
357 the bots, because it repeats twice a second from somewhere both sides
358 already know and would drown out everything else near a planted site. And
359 none of this reaches the policy: what a bot hears moves its feet and its
360 view, never the 32 numbers below. The training arena empties the effect list
361 every step, so the opponent the weights were trained against is deaf, and
362 `src/rl/arena.test.ts` pins that.
343363 - **What it sees** — 32 numbers, all egocentric (`src/client/observe.ts`): the
344364 sine and cosine of its aim error, the range, the enemy's health and whether it
345365 is looking back, its own health, armour, magazine, recoil, current inaccuracy
src/client/bots.test.ts +64 −5
@@ -85,6 +85,62 @@ test("a bot that has only just seen you does not shoot yet", () => {
8585 expect(immediate.fire).toBe(false);
8686 });
8787
88 // --- hearing --------------------------------------------------------------
89
90 /**
91 * A bot standing in ct mid with its back to b link, and the person 512 units
92 * away through the wall between the two. The person moves for `seconds` —
93 * running, walking or ducking — and what comes back is what the bot made of
94 * the noise. Nothing here is pushed by hand: the footsteps are the ones
95 * `world.ts` pushes for a person who is actually moving.
96 */
97 function behindTheWall(seconds: number, how: Partial<typeof NO_INPUT>): Player {
98 const world = createWorld(COMPETITIVE, "you", "ct", 1, 4242);
99 world.match.phase = "live";
100 const bot = world.players[1] as Player;
101 world.me.pos = [-448, 1856, 2]; // b link
102 world.me.yaw = 90; // running up the room, not into the wall
103 bot.pos = [64, 1856, 2]; // ct mid, one wall away
104 bot.yaw = 0; // looking the other way entirely
105 bot.brain = makeBrain(1, 99);
106 bot.brain.reset = false;
107 bot.brain.heardAt = world.time;
108 for (let t = 0; t < 64 * seconds; t++) update(world, 1 / 64, { ...NO_INPUT, forward: 1, ...how });
109 return bot;
110 }
111
112 test("a bot turns towards a running enemy it cannot see", () => {
113 const bot = behindTheWall(1, {});
114 const suspect = bot.brain!.suspect;
115 expect(suspect).not.toBeNull();
116 // It believes the noise is roughly where it was made: west of it, in b link.
117 expect(suspect![0]).toBeLessThan(bot.pos[0]);
118 const towards = aimAt(bot, suspect!).yaw;
119 expect(Math.abs(((towards - bot.yaw + 540) % 360) - 180)).toBeLessThan(45);
120 expect(Math.abs(bot.yaw)).toBeGreaterThan(45); // it started out looking the other way
121 });
122
123 test("the same enemy walking or ducking is not heard at all", () => {
124 expect(behindTheWall(1, { walk: true }).brain!.suspect).toBeNull();
125 expect(behindTheWall(1, { duck: true }).brain!.suspect).toBeNull();
126 });
127
128 test("a bot never hears itself walking about", () => {
129 const world = createWorld(RULES, "you", "ct", 1, 606);
130 world.match.phase = "live";
131 const bot = world.players[1] as Player;
132 // The person stands still and silent at the far end of the map; the only
133 // footsteps in this round are the bot's own.
134 world.me.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2];
135 let steps = 0;
136 for (let t = 0; t < 64 * 3; t++) {
137 update(world, 1 / 64, NO_INPUT);
138 steps += world.effects.filter((e) => e.kind === "sound" && e.name === "step" && e.by === bot.id).length;
139 expect(bot.brain?.suspect ?? null).toBeNull();
140 }
141 expect(steps).toBeGreaterThan(0); // it really was making a noise
142 });
143
88144 // --- difficulty -----------------------------------------------------------
89145
90146 test("easy hands are strictly slower and shakier than hard's", () => {
@@ -187,13 +243,16 @@ test("a side walks in together: every terrorist reads the round's own site", ()
187243 });
188244
189245 test("the carrier gets the bomb down inside the round clock", () => {
190 const seeds = [11, 23, 37, 51, 67];
246 const seeds = [11, 23, 37, 51, 67, 83, 97, 113, 131, 149];
191247 const planted = seeds.filter((seed) => playRound(seed).planted);
192248 // Not every round: a carrier that loses the fight on the way is a round the
193 // terrorists lost, and that is allowed to happen. Four of five, though —
194 // where the code this replaced planted in none of these five, because a bot
195 // that rolled its own site never arrived anywhere with anybody.
196 expect(planted.length).toBeGreaterThanOrEqual(4);
249 // terrorists lost, and that is allowed to happen. Half of them, though —
250 // where the code this replaced planted in none of these, because a bot that
251 // rolled its own site never arrived anywhere with anybody. Ten seeds rather
252 // than five because the rate this is asking about is only about 54% (that is
253 // `scripts/measure.ts` over 177 rounds, before and after the bots could
254 // hear), and five rounds cannot tell 54% from a bad afternoon.
255 expect(planted.length).toBeGreaterThanOrEqual(5);
197256 });
198257
199258 test("a bomb on the floor is still picked up by a living terrorist", () => {
src/client/bots.ts +59 −7
@@ -12,6 +12,7 @@
1212 */
1313
1414 import { decode, HEADS, STILL, type Intent } from "./action.ts";
15 import { fromOther, loudness, reported, type HeardName, type Maker } from "./hearing.ts";
1516 import { angleDelta, clamp, dist, dist2d, rng, type Vec3 } from "./math.ts";
1617 import { findPath, siteAt, walkable, colOf, rowOf } from "./map.ts";
1718 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
@@ -79,6 +80,8 @@ export type BotBrain = {
7980 strafe: number;
8081 strafeUntil: number;
8182 suspect: Vec3 | null;
83 /** The world clock the last time this bot listened: what came after it is news. */
84 heardAt: number;
8285 plannedFor: string;
8386 site: Site; // the round's plan, read once when the round starts
8487 reset: boolean;
@@ -105,7 +108,7 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
105108 aimError: 4.5 - skill * 3.6,
106109 goal: null, path: [], step: 0, repathAt: 0,
107110 targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0,
108 strafe: 0, strafeUntil: 0, suspect: null, plannedFor: "",
111 strafe: 0, strafeUntil: 0, suspect: null, heardAt: 0, plannedFor: "",
109112 site: "A", reset: true,
110113 random: rng(seed),
111114 obs: new Float32Array(OBS_SIZE),
@@ -269,14 +272,60 @@ function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null {
269272 return best;
270273 }
271274
272 /** An enemy shooting nearby is a direction to look, even through a wall. */
275 /** The player an effect's `by` names, if it names one. Ids are the slot they sit in. */
276 function makerOf(world: World, by: number | undefined): Maker {
277 if (by === undefined) return undefined;
278 const at = world.players[by];
279 return at !== undefined && at.id === by ? at : world.players.find((p) => p.id === by);
280 }
281
282 /**
283 * Hearing. Every noise the world made since this bot last listened is looked
284 * at once — no list built and nothing allocated until something is actually
285 * heard — and the loudest of them, if it arrives above `AUDIBLE`, becomes the
286 * place the bot thinks someone is.
287 *
288 * It is the sounds themselves, not the players making them: a gunshot through
289 * two walls is quieter than one down the corridor, a sprint carries and a walk
290 * makes no sound at all, and where the bot ends up looking is off by more the
291 * further away it was. `hearing.ts` owns all of that arithmetic.
292 *
293 * The bomb's own beep is skipped on purpose. It repeats every half-second from
294 * a place both sides already know, so acting on it would drown out every
295 * footstep near a planted site — what a CT should do about it is #46's retake.
296 */
273297 function listen(world: World, bot: Player, brain: BotBrain): void {
274 for (const other of world.players) {
275 if (!other.alive || other.team === bot.team) continue;
276 if (world.time - other.lastShotAt > 0.25) continue;
277 if (dist(bot.pos, other.pos) > 2200) continue;
278 brain.suspect = [other.pos[0], other.pos[1], other.pos[2] + 48];
298 const since = brain.heardAt;
299 brain.heardAt = world.time;
300 let loudest = 0;
301 let from: Vec3 | null = null;
302 // Backwards, and out at the first stale one: `world.effects` is only ever
303 // appended to and only ever filtered, so what is new is the tail of it. The
304 // list is two seconds deep and a firefight fills it with tracers, blood and
305 // impacts; what this bot has not heard yet is the handful on the end.
306 for (let i = world.effects.length - 1; i >= 0; i--) {
307 const effect = world.effects[i];
308 if (effect === undefined || effect.at < since) break;
309 let name: HeardName;
310 let pos: Vec3;
311 let by: number | undefined;
312 if (effect.kind === "shot") {
313 name = effect.weapon;
314 pos = effect.pos;
315 by = effect.shooter;
316 } else if (effect.kind === "sound" && effect.name !== "beep") {
317 name = effect.name;
318 pos = effect.pos;
319 by = effect.by;
320 } else continue;
321 if (!fromOther(bot, makerOf(world, by))) continue;
322 const heard = loudness(bot.pos, pos, name, world.map);
323 if (heard > loudest) {
324 loudest = heard;
325 from = pos;
326 }
279327 }
328 if (from !== null) brain.suspect = reported(bot.pos, from, world.map, brain.random);
280329 }
281330
282331 /**
@@ -417,6 +466,7 @@ export function botThink(world: World, bot: Player, dt: number): Input {
417466 brain.path = [];
418467 brain.targetId = null;
419468 brain.suspect = null;
469 brain.heardAt = world.time;
420470 brain.plannedFor = "";
421471 brain.memory.sinceSeen = 9;
422472 brain.hold = 0;
@@ -424,6 +474,8 @@ export function botThink(world: World, bot: Player, dt: number): Input {
424474 input.switchTo = bot.primary ?? bot.secondary;
425475 }
426476 if (world.match.phase === "freeze") {
477 // Nothing that happens behind the barrier is worth turning round for.
478 brain.heardAt = world.time;
427479 return input;
428480 }
429481
src/client/hearing.test.ts added +110
@@ -0,0 +1,110 @@
1 import { expect, test } from "bun:test";
2 import { AUDIBLE, fromOther, loudness, loudnessOf, reported, wallsBetween, type HeardName } from "./hearing.ts";
3 import { DE_FORGE } from "./map.ts";
4 import { dist2d, type Vec3 } from "./math.ts";
5
6 /**
7 * Two pairs of standing spots on de_forge, 512 units apart each. One pair is
8 * two ends of ct mid with nothing between them; the other is b link and ct
9 * mid, which are the same distance apart with the wall at column 19 in the
10 * way. Everything below is one against the other.
11 */
12 const CLEAR: [Vec3, Vec3] = [[-192, 1856, 2], [320, 1856, 2]];
13 const THROUGH_A_WALL: [Vec3, Vec3] = [[-448, 1856, 2], [64, 1856, 2]];
14
15 /** Ct mid to halfway down mid: 896 units of open corridor, for what a wall is worth. */
16 const DOWN_MID: [Vec3, Vec3] = [[64, 1856, 2], [64, 960, 2]];
17
18 test("the two test pairs are the same distance apart, one of them through a wall", () => {
19 expect(dist2d(CLEAR[0], CLEAR[1])).toBeCloseTo(512, 6);
20 expect(dist2d(THROUGH_A_WALL[0], THROUGH_A_WALL[1])).toBeCloseTo(512, 6);
21 expect(wallsBetween(DE_FORGE, CLEAR[0], CLEAR[1])).toBe(0);
22 expect(wallsBetween(DE_FORGE, THROUGH_A_WALL[0], THROUGH_A_WALL[1])).toBe(1);
23 });
24
25 test("a sound falls away with distance until it is not worth hearing", () => {
26 const here: Vec3 = [-192, 1856, 2];
27 const near = loudness(here, [64, 1856, 2], "ak47", DE_FORGE);
28 const far = loudness(here, [1856, 1856, 2], "ak47", DE_FORGE);
29 expect(near).toBeGreaterThan(far);
30 expect(far).toBeGreaterThan(0);
31 // And far enough away, nothing at all: a rifle across the whole map.
32 expect(loudness(here, [1856, -2600, 2], "ak47", DE_FORGE)).toBe(0);
33 // Right on top of the listener a sound is at its own full loudness, no more.
34 expect(loudness(here, here, "ak47", DE_FORGE)).toBeCloseTo(loudnessOf("ak47"), 6);
35 });
36
37 test("a gunshot is the loudest thing on the map", () => {
38 const [here, there] = CLEAR;
39 const rifle = loudness(here, there, "ak47", DE_FORGE);
40 const quieter: HeardName[] = ["step", "plant", "defuse", "explode", "blast", "bounce", "empty", "beep", "knife"];
41 for (const name of quieter) {
42 expect(loudness(here, there, name, DE_FORGE)).toBeLessThan(rifle);
43 }
44 // The AWP is the one exception, and it is a gun.
45 expect(loudness(here, there, "awp", DE_FORGE)).toBeGreaterThan(rifle);
46 });
47
48 test("a wall costs distance, not silence", () => {
49 const clear = loudness(CLEAR[0], CLEAR[1], "step", DE_FORGE);
50 const muffled = loudness(THROUGH_A_WALL[0], THROUGH_A_WALL[1], "step", DE_FORGE);
51 expect(muffled).toBeGreaterThan(0);
52 expect(muffled).toBeLessThan(clear);
53 // The same footstep 896 units away in the clear — down mid, which is open
54 // that far — is about as quiet: the wall is worth a few hundred units of
55 // walking, which is what it should be.
56 const far = loudness(DOWN_MID[0], DOWN_MID[1], "step", DE_FORGE);
57 expect(wallsBetween(DE_FORGE, DOWN_MID[0], DOWN_MID[1])).toBe(0);
58 expect(Math.abs(muffled - far)).toBeLessThan(0.02);
59 });
60
61 test("far enough away, a wall is the difference between heard and not", () => {
62 // A footstep a thousand units away carries down open mid, and does not from
63 // b link to the far end of ct mid — the same distance, with a wall in it.
64 const clear = loudness([64, 1856, 2], [64, 856, 2], "step", DE_FORGE);
65 const blocked = loudness([-704, 1856, 2], [296, 1856, 2], "step", DE_FORGE);
66 expect(clear).toBeGreaterThanOrEqual(AUDIBLE);
67 expect(blocked).toBe(0);
68 });
69
70 test("where a sound seems to come from is a point near by and a direction far off", () => {
71 // A random source stuck at its extreme, so the error is the whole error.
72 const worst = (): number => 1;
73 const here: Vec3 = [-192, 1856, 2];
74 const close: Vec3 = [here[0] + 200, here[1], 2];
75 const far: Vec3 = [here[0] + 2000, here[1], 2];
76 const closeError = dist2d(reported(here, close, DE_FORGE, worst), close);
77 const farError = dist2d(reported(here, far, DE_FORGE, worst), far);
78 expect(closeError).toBeLessThan(40);
79 expect(farError).toBeGreaterThan(200);
80 // The bearing, though, is about as good either way: a far sound is a
81 // direction to look, not a place to shoot at.
82 const bearing = (from: Vec3, to: Vec3): number => Math.atan2(to[1] - from[1], to[0] - from[0]);
83 const off = (truth: Vec3): number =>
84 Math.abs(bearing(here, reported(here, truth, DE_FORGE, worst)) - bearing(here, truth));
85 expect(off(far)).toBeLessThan(off(close) * 2 + 0.05);
86 expect(off(far)).toBeLessThan(0.3); // under twenty degrees at two thousand units
87 });
88
89 test("a wall makes a sound harder to place as well as quieter", () => {
90 const worst = (): number => 1;
91 const clear = dist2d(reported(CLEAR[0], CLEAR[1], DE_FORGE, worst), CLEAR[1]);
92 const muffled = dist2d(reported(THROUGH_A_WALL[0], THROUGH_A_WALL[1], DE_FORGE, worst), THROUGH_A_WALL[1]);
93 expect(muffled).toBeGreaterThan(clear);
94 });
95
96 test("a heard point is at shoulder height, not on the floor", () => {
97 const still = (): number => 0.5; // no error at all
98 const at = reported([-192, 1856, 2], [320, 1856, 2], DE_FORGE, still);
99 expect(at[0]).toBeCloseTo(320, 6);
100 expect(at[1]).toBeCloseTo(1856, 6);
101 expect(at[2]).toBeGreaterThan(2);
102 });
103
104 test("your own side's noise is not news; anyone else's is", () => {
105 const me = { id: 3, team: "ct" as const };
106 expect(fromOther(me, { id: 3, team: "ct" })).toBe(false); // yourself
107 expect(fromOther(me, { id: 5, team: "ct" })).toBe(false); // a teammate
108 expect(fromOther(me, { id: 5, team: "t" })).toBe(true); // an enemy
109 expect(fromOther(me, undefined)).toBe(true); // the bomb, or a grenade in the air
110 });
src/client/hearing.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * What a sound is worth to whoever is standing somewhere else: how loud it
3 * arrives, and where they think it came from. Plain data in and out — a
4 * position, a name and the map — so it is the same answer for a bot as it
5 * would be for anything else that wanted to listen.
6 *
7 * A sound is named by what made it: one of `world.ts`'s `SoundName`s, or, for
8 * a gunshot, the weapon that fired it. That is the whole of the loudness
9 * table: an AWP carries across the map, a footstep carries across a room, and
10 * a dry trigger barely leaves the hands it clicked in.
11 *
12 * Walls muffle rather than silence. Working out how much would be a trace per
13 * sound per listener, which is five listeners times every effect every tick,
14 * so this counts the solid cells on the straight line instead and charges each
15 * one extra distance: cheap, deterministic, and wrong in the direction that
16 * matters least — a sound around a corner reads as a sound further away.
17 */
18
19 import { cellAt, colOf, rowOf, CELL, type MapDef } from "./map.ts";
20 import { dist2d, type Vec3 } from "./math.ts";
21 import type { SoundName, Team } from "./world.ts";
22 import type { WeaponId } from "./weapons.ts";
23
24 /** Everything that can be heard: the world's own noises, and a shot per gun. */
25 export type HeardName = SoundName | WeaponId;
26
27 /**
28 * How loud each thing is at the source, relative to each other. A rifle is
29 * 1 by construction and nothing else is louder, which is what keeps a gunshot
30 * the thing a bot turns to when a gunshot and a footstep arrive together.
31 */
32 const LOUDNESS: Record<HeardName, number> = {
33 // Guns, loudest first. The AWP is the only thing on the map that beats a rifle.
34 awp: 1.25, deagle: 1.05, ak47: 1, m4a4: 0.95, mp9: 0.8, glock: 0.7, usp: 0.6,
35 // A knife swing is a whisper, and neither of these is ever fired as a shot:
36 // a grenade is thrown and the bomb is never held.
37 knife: 0.08, he: 0.1, bomb: 0,
38 // The world. A plant and a defuse are deliberate and carry past the site; a
39 // footstep is a room away; a dry trigger is the gun in your own hands.
40 explode: 0.95, blast: 0.9, plant: 0.9, defuse: 0.8, beep: 0.5,
41 step: 0.45, bounce: 0.35, empty: 0.25,
42 };
43
44 /**
45 * Below this a sound is not information, it is the room. It is set where it
46 * is because a rifle shot lands on it at about 2500 units — the distance the
47 * bots have always turned round for — and a running footstep at about 1100,
48 * which is roughly a site room and a corridor.
49 */
50 export const AUDIBLE = 0.1;
51
52 /** Inside this everything is at its own full loudness; past it, it falls away. */
53 const NEAR = 250;
54
55 /** What one solid cell between the two of you costs, in units of apparent distance. */
56 const WALL_COST = 3 * CELL;
57
58 /** The error on a heard point as a share of how far away it is, in the clear. */
59 const SPREAD = 0.08;
60
61 /** What each wall in the way adds to that share: muffled is also harder to place. */
62 const SPREAD_PER_WALL = 0.07;
63
64 /** Where a bot looks when it looks at a noise: shoulder height, not the floor. */
65 const SHOULDER = 48;
66
67 /** The loudness this name leaves its source at, 0 for anything that cannot be heard. */
68 export const loudnessOf = (name: HeardName): number => LOUDNESS[name] ?? 0;
69
70 /**
71 * Solid cells on the straight line from `from` to `to`, sampled a cell at a
72 * time. Only full-height walls count: a crate is something you hear over.
73 */
74 export function wallsBetween(map: MapDef, from: Vec3, to: Vec3): number {
75 const span = dist2d(from, to);
76 const steps = Math.max(1, Math.round(span / CELL));
77 let walls = 0;
78 for (let i = 0; i < steps; i++) {
79 const t = (i + 0.5) / steps;
80 const x = from[0] + (to[0] - from[0]) * t;
81 const y = from[1] + (to[1] - from[1]) * t;
82 if (cellAt(map, colOf(map, x), rowOf(map, y)) === "#") walls += 1;
83 }
84 return walls;
85 }
86
87 const falloff = (base: number, distance: number): number => (base * NEAR) / Math.max(NEAR, distance);
88
89 /**
90 * How loud a sound made at `pos` arrives at `listener`, or 0 when it does not
91 * arrive at all. The clear-line answer is worked out first and rejected before
92 * any cell is looked at, so most of what happens on a tick costs one hypot.
93 */
94 export function loudness(listener: Vec3, pos: Vec3, name: HeardName, map: MapDef): number {
95 const base = loudnessOf(name);
96 if (base <= 0) return 0;
97 const span = dist2d(listener, pos);
98 if (falloff(base, span) < AUDIBLE) return 0; // too far even with nothing in the way
99 const heard = falloff(base, span + wallsBetween(map, listener, pos) * WALL_COST);
100 return heard < AUDIBLE ? 0 : heard;
101 }
102
103 /**
104 * Where the listener believes the sound came from: the truth, thrown off by
105 * an error that grows with distance and with every wall it came through. The
106 * angle it is wrong by stays about the same, which is the point — a footstep
107 * across the map is a direction to look, not a place to shoot at.
108 */
109 export function reported(
110 listener: Vec3, pos: Vec3, map: MapDef, random: () => number,
111 ): Vec3 {
112 const span = dist2d(listener, pos);
113 const error = span * (SPREAD + wallsBetween(map, listener, pos) * SPREAD_PER_WALL);
114 return [
115 pos[0] + (random() * 2 - 1) * error,
116 pos[1] + (random() * 2 - 1) * error,
117 pos[2] + SHOULDER,
118 ];
119 }
120
121 /** Who made a sound: a player, or nobody at all — the bomb, or a grenade already in the air. */
122 export type Maker = { id: number; team: Team } | undefined;
123
124 /**
125 * Is this sound news? Your own footsteps are not, and neither are your side's:
126 * five bots walking together would otherwise spend the round turning round to
127 * look at each other.
128 */
129 export const fromOther = (listener: { id: number; team: Team }, maker: Maker): boolean =>
130 maker === undefined || (maker.id !== listener.id && maker.team !== listener.team);
src/client/world.ts +11 −8
@@ -89,7 +89,8 @@ export type Effect =
8989 // person's own, and who pulled it. `shooter` is what lets the renderer put a
9090 // muzzle flash on that player's gun and skip the one whose eyes the camera
9191 // is behind — `mine` cannot answer that, because a dead person watches a
92 // teammate shoot.
92 // teammate shoot — and it is also the ear the bots listen with, which skips
93 // its own side's guns.
9394 | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean; shooter: number }
9495 | { kind: "hitmark"; at: number; headshot: boolean }
9596 // Damage the person took: who took it, where it came from in the world, and
@@ -97,7 +98,9 @@ export type Effect =
9798 | { kind: "hurt"; at: number; victim: number; from: Vec3; amount: number }
9899 // `pitch` is only ever set on a "beep": it is the one sound whose tone is
99100 // decided at the moment it happens (how close the fuse is), not by its name.
100 | { kind: "sound"; at: number; pos: Vec3; name: SoundName; pitch?: number };
101 // `by` is whose noise it is, where it is anyone's: the bomb's own beep and
102 // the blast that ends it belong to nobody, so everyone may hear them.
103 | { kind: "sound"; at: number; pos: Vec3; name: SoundName; pitch?: number; by?: number };
101104
102105 // The ids are here as well as the names because the HUD asks the feed who
103106 // killed the person, and two players may answer to the same name.
@@ -518,7 +521,7 @@ export function shoot(world: World, p: Player): boolean {
518521 if (w.magazine > 0 && (ammo === undefined || ammo.mag <= 0)) {
519522 if (!p.emptyThisPress) {
520523 p.emptyThisPress = true;
521 world.effects.push({ kind: "sound", at: world.time, pos: eyePos(p), name: "empty" });
524 world.effects.push({ kind: "sound", at: world.time, pos: eyePos(p), name: "empty", by: p.id });
522525 }
523526 reload(world, p);
524527 return false;
@@ -728,7 +731,7 @@ function throwGrenade(world: World, p: Player): boolean {
728731 * bullet, though the thrower is very much in range of their own grenade.
729732 */
730733 function detonate(world: World, g: Grenade): void {
731 world.effects.push({ kind: "sound", at: world.time, pos: [...g.pos] as Vec3, name: "blast" });
734 world.effects.push({ kind: "sound", at: world.time, pos: [...g.pos] as Vec3, name: "blast", by: g.owner });
732735 for (const n of BURST) {
733736 world.effects.push({
734737 kind: "impact",
@@ -771,7 +774,7 @@ function updateGrenades(world: World, dt: number): void {
771774 for (let i = world.grenades.length - 1; i >= 0; i--) {
772775 const g = world.grenades[i] as Grenade;
773776 const { bounced, detonated } = stepGrenade(g, dt, world.boxes);
774 if (bounced) world.effects.push({ kind: "sound", at: world.time, pos: [...g.pos] as Vec3, name: "bounce" });
777 if (bounced) world.effects.push({ kind: "sound", at: world.time, pos: [...g.pos] as Vec3, name: "bounce", by: g.owner });
775778 // Off the list before it goes off: a fuse fires once, and a kill that ends
776779 // the round must not find the grenade that caused it still in the world.
777780 if (detonated) {
@@ -809,7 +812,7 @@ function updateBomb(world: World, p: Player, input: Input, dt: number): void {
809812 p.money = rules.capMoney(p.money + rules.PLANT_REWARD, world.match.rules);
810813 rules.plantBomb(world.match);
811814 bomb.beepAt = 0;
812 world.effects.push({ kind: "sound", at: world.time, pos: bomb.pos, name: "plant" });
815 world.effects.push({ kind: "sound", at: world.time, pos: bomb.pos, name: "plant", by: p.id });
813816 say(world, `The bomb has been planted at ${site}.`);
814817 }
815818 } else {
@@ -829,7 +832,7 @@ function updateBomb(world: World, p: Player, input: Input, dt: number): void {
829832 if (bomb.defuseProgress >= need) {
830833 bomb.state = "defused";
831834 p.money = rules.capMoney(p.money + rules.DEFUSE_REWARD, world.match.rules);
832 world.effects.push({ kind: "sound", at: world.time, pos: bomb.pos, name: "defuse" });
835 world.effects.push({ kind: "sound", at: world.time, pos: bomb.pos, name: "defuse", by: p.id });
833836 finishRound(world, "ct", "defused");
834837 }
835838 } else if (bomb.defuser === p) {
@@ -886,7 +889,7 @@ function footsteps(world: World, p: Player, wish: Wish, before: Vec3): void {
886889 p.stepDistance += dist2d(before, p.pos);
887890 if (p.stepDistance < STEP_STRIDE) return;
888891 p.stepDistance -= STEP_STRIDE;
889 world.effects.push({ kind: "sound", at: world.time, pos: [p.pos[0], p.pos[1], p.pos[2]], name: "step" });
892 world.effects.push({ kind: "sound", at: world.time, pos: [p.pos[0], p.pos[1], p.pos[2]], name: "step", by: p.id });
890893 }
891894
892895 // --- the tick -------------------------------------------------------------
src/rl/arena.test.ts +62 −3
@@ -1,10 +1,12 @@
11 import { expect, test } from "bun:test";
2 import { STILL, toInput } from "../client/action.ts";
2 import { decode, HEADS, STILL, toInput } from "../client/action.ts";
3 import { policy } from "../client/bots.ts";
34 import { OBS_SIZE } from "../client/observe.ts";
5 import { forward, greedy, newActivations } from "../client/policy.ts";
46 import { aimAt, bodyCentre, canSee } from "../client/world.ts";
57 import {
6 createArena, EPISODE_TICKS, observeFoe, observeLearner, policyFoe, reset, SKIP, step, stepInput,
7 stillFoe,
8 createArena, EPISODE_TICKS, observeFoe, observeLearner, policyFoe, reset, scriptedFoe, SKIP, step,
9 stepInput, stillFoe,
810 } from "./arena.ts";
911
1012 test("a duel starts with two live players in range of each other", () => {
@@ -144,6 +146,63 @@ test("both sides can be observed, and a reset puts the duel back", () => {
144146 expect(arena.world.time).toBe(0);
145147 });
146148
149 // --- what the arena does not have ----------------------------------------
150
151 /**
152 * The bots in the game hear the world (`src/client/hearing.ts`): they read the
153 * sounds off `world.effects` and turn towards the loudest of them. The arena's
154 * foe is the same `botThink`, so the question is what it hears here — and the
155 * answer is nothing, because the arena empties the effect list at the end of
156 * every step and the foe thinks at the start of the next one. Deaf is what the
157 * shipped weights were trained against, and this pins it: a foe that starts
158 * hearing gunshots in here is a different opponent and a different baseline.
159 */
160 function episode(seeds: number[]): { actions: number[]; heard: number; effects: number } {
161 const net = policy();
162 const obs = new Float32Array(OBS_SIZE);
163 const act = newActivations(net.shape);
164 const actions = new Int32Array(HEADS.length);
165 const out: number[] = [];
166 let heard = 0;
167 let effects = 0;
168 for (const seed of seeds) {
169 const arena = createArena(seed, scriptedFoe);
170 while (arena.ticks < EPISODE_TICKS && arena.learner.alive && arena.foe.alive) {
171 observeLearner(arena, obs);
172 forward(net, obs, act);
173 greedy(net.shape, act.logits, actions);
174 out.push(...actions);
175 step(arena, decode(actions));
176 effects += arena.world.effects.length;
177 if (arena.foe.brain?.suspect != null) heard += 1;
178 }
179 }
180 return { actions: out, heard, effects };
181 }
182
183 const SEEDS = [4242, 7, 99991];
184
185 test("the arena's foe never hears anything: there is nothing left to hear", () => {
186 const run = episode(SEEDS);
187 expect(run.actions.length).toBeGreaterThan(0);
188 expect(run.effects).toBe(0); // nothing survives a step
189 expect(run.heard).toBe(0); // so the foe never forms a suspect
190 });
191
192 /**
193 * And the episodes themselves, action for action. The digest is over every
194 * action the shipped weights chose across the three seeds above; regenerate it
195 * — deliberately, and say so in the pull — with the same loop if a change to
196 * the policy, the physics or the arena is meant to move it.
197 */
198 test("an episode against the shipped weights produces the same actions it did", () => {
199 const run = episode(SEEDS);
200 expect(run.actions.length).toBe(1080);
201 expect(Bun.hash(run.actions.join(",")).toString(16)).toBe("3779f480fec06e8f");
202 // Twice, from the same seeds, is the same episode: nothing here reads a clock.
203 expect(episode(SEEDS).actions).toEqual(run.actions);
204 });
205
147206 test("the arena leaves no render effects behind to grow without bound", () => {
148207 const arena = createArena(2468, stillFoe);
149208 for (let i = 0; i < 50; i++) step(arena, { ...STILL, fire: true });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs