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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/72-the-bots-have-ears-footsteps-a-plant-and → main 2 commits fork
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. | ||
| 267 | 267 | this round, where they fell), `radar` (the blips the |
| 268 | 268 | HUD's minimap draws, and nothing it draws them with), `summary` (the end of |
| 269 | 269 | 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), | |
| 270 | 272 | `settings` (mute, volume, sensitivity and difficulty: read, validated, and |
| 271 | 273 | never NaN or an unknown name), |
| 272 | 274 | `renderer`, `hud`, `input`, `audio`, `main`. |
| @@ -325,8 +327,8 @@ signed for, so they become the guests they always were. | ||
| 325 | 327 | |
| 326 | 328 | They are split in two. Getting to the site is a plan — `src/client/plan.ts` |
| 327 | 329 | 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. | |
| 330 | 332 | |
| 331 | 333 | The plan is one site a side a round, rolled from the match's seed and the round |
| 332 | 334 | 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 | ||
| 340 | 342 | planted in 65% of rounds rather than 38%, and 10% of rounds end on the clock |
| 341 | 343 | rather than 37%. |
| 342 | 344 | |
| 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. | |
| 343 | 363 | - **What it sees** — 32 numbers, all egocentric (`src/client/observe.ts`): the |
| 344 | 364 | sine and cosine of its aim error, the range, the enemy's health and whether it |
| 345 | 365 | 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", () => { | ||
| 85 | 85 | expect(immediate.fire).toBe(false); |
| 86 | 86 | }); |
| 87 | 87 | |
| 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 | ||
| 88 | 144 | // --- difficulty ----------------------------------------------------------- |
| 89 | 145 | |
| 90 | 146 | 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", () | ||
| 187 | 243 | }); |
| 188 | 244 | |
| 189 | 245 | 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]; | |
| 191 | 247 | const planted = seeds.filter((seed) => playRound(seed).planted); |
| 192 | 248 | // 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); | |
| 197 | 256 | }); |
| 198 | 257 | |
| 199 | 258 | test("a bomb on the floor is still picked up by a living terrorist", () => { |
src/client/bots.ts +59 −7
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | import { decode, HEADS, STILL, type Intent } from "./action.ts"; |
| 15 | import { fromOther, loudness, reported, type HeardName, type Maker } from "./hearing.ts"; | |
| 15 | 16 | import { angleDelta, clamp, dist, dist2d, rng, type Vec3 } from "./math.ts"; |
| 16 | 17 | import { findPath, siteAt, walkable, colOf, rowOf } from "./map.ts"; |
| 17 | 18 | import { observe, OBS_SIZE, type Memory } from "./observe.ts"; |
| @@ -79,6 +80,8 @@ export type BotBrain = { | ||
| 79 | 80 | strafe: number; |
| 80 | 81 | strafeUntil: number; |
| 81 | 82 | suspect: Vec3 | null; |
| 83 | /** The world clock the last time this bot listened: what came after it is news. */ | |
| 84 | heardAt: number; | |
| 82 | 85 | plannedFor: string; |
| 83 | 86 | site: Site; // the round's plan, read once when the round starts |
| 84 | 87 | reset: boolean; |
| @@ -105,7 +108,7 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr | ||
| 105 | 108 | aimError: 4.5 - skill * 3.6, |
| 106 | 109 | goal: null, path: [], step: 0, repathAt: 0, |
| 107 | 110 | 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: "", | |
| 109 | 112 | site: "A", reset: true, |
| 110 | 113 | random: rng(seed), |
| 111 | 114 | obs: new Float32Array(OBS_SIZE), |
| @@ -269,14 +272,60 @@ function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null { | ||
| 269 | 272 | return best; |
| 270 | 273 | } |
| 271 | 274 | |
| 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 | */ | |
| 273 | 297 | 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 | } | |
| 279 | 327 | } |
| 328 | if (from !== null) brain.suspect = reported(bot.pos, from, world.map, brain.random); | |
| 280 | 329 | } |
| 281 | 330 | |
| 282 | 331 | /** |
| @@ -417,6 +466,7 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 417 | 466 | brain.path = []; |
| 418 | 467 | brain.targetId = null; |
| 419 | 468 | brain.suspect = null; |
| 469 | brain.heardAt = world.time; | |
| 420 | 470 | brain.plannedFor = ""; |
| 421 | 471 | brain.memory.sinceSeen = 9; |
| 422 | 472 | brain.hold = 0; |
| @@ -424,6 +474,8 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 424 | 474 | input.switchTo = bot.primary ?? bot.secondary; |
| 425 | 475 | } |
| 426 | 476 | if (world.match.phase === "freeze") { |
| 477 | // Nothing that happens behind the barrier is worth turning round for. | |
| 478 | brain.heardAt = world.time; | |
| 427 | 479 | return input; |
| 428 | 480 | } |
| 429 | 481 |
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 = | ||
| 89 | 89 | // person's own, and who pulled it. `shooter` is what lets the renderer put a |
| 90 | 90 | // muzzle flash on that player's gun and skip the one whose eyes the camera |
| 91 | 91 | // 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. | |
| 93 | 94 | | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean; shooter: number } |
| 94 | 95 | | { kind: "hitmark"; at: number; headshot: boolean } |
| 95 | 96 | // Damage the person took: who took it, where it came from in the world, and |
| @@ -97,7 +98,9 @@ export type Effect = | ||
| 97 | 98 | | { kind: "hurt"; at: number; victim: number; from: Vec3; amount: number } |
| 98 | 99 | // `pitch` is only ever set on a "beep": it is the one sound whose tone is |
| 99 | 100 | // 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 }; | |
| 101 | 104 | |
| 102 | 105 | // The ids are here as well as the names because the HUD asks the feed who |
| 103 | 106 | // killed the person, and two players may answer to the same name. |
| @@ -518,7 +521,7 @@ export function shoot(world: World, p: Player): boolean { | ||
| 518 | 521 | if (w.magazine > 0 && (ammo === undefined || ammo.mag <= 0)) { |
| 519 | 522 | if (!p.emptyThisPress) { |
| 520 | 523 | 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 }); | |
| 522 | 525 | } |
| 523 | 526 | reload(world, p); |
| 524 | 527 | return false; |
| @@ -728,7 +731,7 @@ function throwGrenade(world: World, p: Player): boolean { | ||
| 728 | 731 | * bullet, though the thrower is very much in range of their own grenade. |
| 729 | 732 | */ |
| 730 | 733 | 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 }); | |
| 732 | 735 | for (const n of BURST) { |
| 733 | 736 | world.effects.push({ |
| 734 | 737 | kind: "impact", |
| @@ -771,7 +774,7 @@ function updateGrenades(world: World, dt: number): void { | ||
| 771 | 774 | for (let i = world.grenades.length - 1; i >= 0; i--) { |
| 772 | 775 | const g = world.grenades[i] as Grenade; |
| 773 | 776 | 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 }); | |
| 775 | 778 | // Off the list before it goes off: a fuse fires once, and a kill that ends |
| 776 | 779 | // the round must not find the grenade that caused it still in the world. |
| 777 | 780 | if (detonated) { |
| @@ -809,7 +812,7 @@ function updateBomb(world: World, p: Player, input: Input, dt: number): void { | ||
| 809 | 812 | p.money = rules.capMoney(p.money + rules.PLANT_REWARD, world.match.rules); |
| 810 | 813 | rules.plantBomb(world.match); |
| 811 | 814 | 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 }); | |
| 813 | 816 | say(world, `The bomb has been planted at ${site}.`); |
| 814 | 817 | } |
| 815 | 818 | } else { |
| @@ -829,7 +832,7 @@ function updateBomb(world: World, p: Player, input: Input, dt: number): void { | ||
| 829 | 832 | if (bomb.defuseProgress >= need) { |
| 830 | 833 | bomb.state = "defused"; |
| 831 | 834 | 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 }); | |
| 833 | 836 | finishRound(world, "ct", "defused"); |
| 834 | 837 | } |
| 835 | 838 | } else if (bomb.defuser === p) { |
| @@ -886,7 +889,7 @@ function footsteps(world: World, p: Player, wish: Wish, before: Vec3): void { | ||
| 886 | 889 | p.stepDistance += dist2d(before, p.pos); |
| 887 | 890 | if (p.stepDistance < STEP_STRIDE) return; |
| 888 | 891 | 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 }); | |
| 890 | 893 | } |
| 891 | 894 | |
| 892 | 895 | // --- the tick ------------------------------------------------------------- |
src/rl/arena.test.ts +62 −3
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 4 | import { OBS_SIZE } from "../client/observe.ts"; |
| 5 | import { forward, greedy, newActivations } from "../client/policy.ts"; | |
| 4 | 6 | import { aimAt, bodyCentre, canSee } from "../client/world.ts"; |
| 5 | 7 | 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, | |
| 8 | 10 | } from "./arena.ts"; |
| 9 | 11 | |
| 10 | 12 | 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", () => { | ||
| 144 | 146 | expect(arena.world.time).toBe(0); |
| 145 | 147 | }); |
| 146 | 148 | |
| 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 | ||
| 147 | 206 | test("the arena leaves no render effects behind to grow without bound", () => { |
| 148 | 207 | const arena = createArena(2468, stillFoe); |
| 149 | 208 | for (let i = 0; i < 50; i++) step(arena, { ...STILL, fire: true }); |