Split The code is public; the app answers only to the crew.
A second place to play: one more layout, and bots that plant on it at a rate you can read #147
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/132-a-second-place-to-play-one-more-layout-a → main 3 commits fork
6 files
changed +352
−93
README.md +96 −23
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | # cs |
| 2 | 2 | |
| 3 | 3 | A Counter-Strike-shaped defusal shooter that runs in the browser, and the |
| 4 | leaderboard of the matches people played. Five against five on one map: | |
| 5 | bomb rounds, a buy menu and an economy, bots that pick a site as a side, get | |
| 6 | the bomb down and shoot back. | |
| 4 | leaderboard of the matches people played. Five against five on either of two | |
| 5 | maps: bomb rounds, a buy menu and an economy, bots that pick a site as a side, | |
| 6 | get the bomb down and shoot back. | |
| 7 | 7 | |
| 8 | 8 | ## Run it locally |
| 9 | 9 | |
| @@ -50,7 +50,7 @@ wants one, but the running server never reads it. | ||
| 50 | 50 | loss bonuses. Cover is not all equal: a bullet goes **through a crate** if |
| 51 | 51 | the round is strong enough for the thickness it meets. Crates are the only |
| 52 | 52 | penetrable material — walls, floors, the site plates and metal stop |
| 53 | everything — and every crate on `de_forge` is 128 units across, so the AWP | |
| 53 | everything — and every crate on either map is 128 units across, so the AWP | |
| 54 | 54 | (230 units), AK-47 (175), M4A4 (165) and Desert Eagle (150) punch one |
| 55 | 55 | face-on while the Glock (48), USP (55) and MP9 (60) manage nothing but a |
| 56 | 56 | clipped corner, and a knife nothing at all. The thickness is measured along |
| @@ -142,7 +142,7 @@ wants one, but the running server never reads it. | ||
| 142 | 142 | abandoned match still counts as a match, with its kills and its damage, the |
| 143 | 143 | same as any other — it just never counts as a win, which already required |
| 144 | 144 | `finished`. |
| 145 | - **Generated, not licensed.** The map (`de_forge`), the textures, the player and | |
| 145 | - **Generated, not licensed.** The maps (`de_forge` and `de_cinder`), the textures, the player and | |
| 146 | 146 | weapon models and every sound are produced in code at start-up. There are no |
| 147 | 147 | asset files and nothing from the real game is used — the numbers follow CS's |
| 148 | 148 | published shape so the habits transfer, and the rest is original. |
| @@ -219,6 +219,20 @@ radio clear. Being dead does not take the radio away: you are already watching | ||
| 219 | 219 | the round through a teammate's eyes, and the four still alive are the ones who |
| 220 | 220 | can do something about what you can see. |
| 221 | 221 | |
| 222 | **There are two maps**, and the menu's **Map** select beside Difficulty picks | |
| 223 | which one a match is played on. `de_forge` is the default and the dust-shaped | |
| 224 | one: both sites along the top of the grid, a mid between them, a long corridor | |
| 225 | down the right and a tunnel down the left. `de_cinder` puts the sites on a | |
| 226 | diagonal instead — A in the north-west, B halfway down the east wall — around a | |
| 227 | courtyard the rotation between them has to cross: swapping sites on `de_forge` | |
| 228 | is one corridor behind the defenders' own spawn, and on `de_cinder` it is a | |
| 229 | walk through the middle where the other side can be. Each site on either map has | |
| 230 | the long way in and another way in; on `de_cinder` those are the west long and | |
| 231 | the vents for A, the ramp and the kiln door for B. The choice is remembered for | |
| 232 | next time like the other settings, and it applies at the start of a match, not | |
| 233 | to one already running. What the bots make of the second map is measured in | |
| 234 | *Measuring the round*, below — they were trained on the first. | |
| 235 | ||
| 222 | 236 | The menu's **Difficulty** select picks Easy, Normal or Hard before a match |
| 223 | 237 | starts — each one a band of the bots' skill rather than one number, so a |
| 224 | 238 | side still runs from fodder to uncomfortable at every setting, just a |
| @@ -389,11 +403,15 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured. | ||
| 389 | 403 | |
| 390 | 404 | - `src/client/` — the game, in modules named for one thing each: `math`, `map` |
| 391 | 405 | (a `MapDef` is a value — grid, boxes, spawns, sites — that `buildMap` makes |
| 392 | from a layout's areas and crates; `DE_FORGE` is the one the game plays, and | |
| 393 | everything that reads a grid takes the map rather than reaching for one; the | |
| 394 | catalogue — `MAPS`, `MAP_NAMES`, `DEFAULT_MAP`, `mapFor`, built once each — | |
| 395 | is the seam a player chooses through, offered as a Map select beside | |
| 396 | Difficulty and validated the same way settings.ts validates difficulty), | |
| 406 | from a layout's areas and crates; `DE_FORGE` and `DE_CINDER` are two tables | |
| 407 | of numbers through one builder, and everything that reads a grid takes the | |
| 408 | map rather than reaching for one; the catalogue — `MAPS`, `MAP_NAMES`, | |
| 409 | `DEFAULT_MAP`, `mapFor`, built once each — is the seam a player chooses | |
| 410 | through, offered as a Map select beside Difficulty and validated the same way | |
| 411 | settings.ts validates difficulty. A third layout is a third table, a third | |
| 412 | key and nothing else: the structural tests in `map.test.ts` run over every | |
| 413 | map in the catalogue, so a site inside a crate or a room nothing can walk to | |
| 414 | fails the moment it is added), | |
| 397 | 415 | `physics`, `weapons`, `combat`, `grenades` (a thrown thing's flight: gravity, |
| 398 | 416 | bounce, fuse and how far a blast reaches), `match` (the rules), `world` (the simulation), |
| 399 | 417 | `buy` (the buy panel's rows — what a side can afford, what it already owns |
| @@ -495,6 +513,20 @@ decides it, A* over the map grid walks it, and the rest of it is ordinary code: | ||
| 495 | 513 | buying, planting, defusing, picking a dropped bomb back up, and listening to |
| 496 | 514 | the room. The fight is a reflex, and that is a policy learned with PPO. |
| 497 | 515 | |
| 516 | **Both halves are map-agnostic, and only one of them has seen both maps.** The | |
| 517 | plan takes a `MapDef` and asks it for the sites, the spawns and a path; there | |
| 518 | is no per-map branch anywhere in `plan.ts`, `bots.ts` or `findPath`, so | |
| 519 | `de_cinder` is played by exactly the code that plays `de_forge`. The policy is | |
| 520 | the half that was **trained on `de_forge`** — `src/rl/arena.ts` duels on that | |
| 521 | grid and nowhere else. What it learned is not a map, though: its 32 inputs are | |
| 522 | distances, angles and clocks relative to the bot itself (see *What it sees*), | |
| 523 | so nothing it holds is in map coordinates and it transfers rather than breaks. | |
| 524 | What does change is the *shape* of the fights those inputs describe — a | |
| 525 | courtyard with four mouths hands the policy more duels at once than a corridor | |
| 526 | does, and a site the terrorists reach first is a site they hold rather than | |
| 527 | take. That shows up in the numbers rather than in the code, and the two tables | |
| 528 | in *Measuring the round* are what it looks like. | |
| 529 | ||
| 498 | 530 | The plan is one site a side a round, rolled from the match's seed and the round |
| 499 | 531 | number rather than by each bot for itself, so the five terrorists arrive |
| 500 | 532 | together and the carrier is not walking in alone. The counter-terrorists stack |
| @@ -624,27 +656,68 @@ bun run scripts/measure.ts # 12 seeded SHORT matches | ||
| 624 | 656 | bun run scripts/measure.ts --matches 40 --seed 7 # more matches, a different seed |
| 625 | 657 | bun run scripts/measure.ts --rules long # COMPETITIVE rules instead |
| 626 | 658 | bun run scripts/measure.ts --difficulty hard # harder bots instead of normal |
| 659 | bun run scripts/measure.ts --map de_cinder # the other map, same seed | |
| 627 | 660 | |
| 628 | 661 | |
| 662 | `--map` takes a name from the catalogue and nothing else; a name `MAP_NAMES` | |
| 663 | does not hold falls back to the default rather than measuring silently. | |
| 664 | ||
| 629 | 665 | A round's length is measured from the freeze that starts it to the tick it is |
| 630 | 666 | decided, so it is the freeze time plus whatever of the round clock and the |
| 631 | 667 | bomb's fuse actually ran — not the 115-second round clock alone, which is why |
| 632 | 668 | the longest round below is well past it. |
| 633 | 669 | |
| 634 | Baseline, `--matches 30 --seed 41`, `normal` bots, 223 rounds: a plant in | |
| 635 | **62%** of rounds, ends split `detonated 105 · time 21 · elimination 72 · | |
| 636 | defused 25`, **18%** of planted bombs defused, median round **123 s**, longest | |
| 637 | **144 s**, and about 6 seconds of wall clock a match — which is why this is a | |
| 638 | script and not a test. Before the two sides read the clock (#105) the same run | |
| 639 | was `detonated 103 · time 55 · elimination 39 · defused 19` over 216 rounds: | |
| 640 | a quarter of rounds decided by nothing at all. | |
| 670 | Baseline, `--matches 30 --seed 41`, `normal` bots, both maps on the same seed | |
| 671 | and the same shipped policy: | |
| 641 | 672 | |
| 642 | Two things to hold on to when reading that table. The person is idle the whole | |
| 643 | match, so the count is effectively 5-on-4 in the terrorists' favour — and once | |
| 644 | the sides swap at half time, the idle person is a terrorist standing in spawn, | |
| 645 | sometimes holding the bomb. Those rounds cannot be planted and cannot end in a | |
| 646 | terrorist win; 43 of the 55 rounds that ended on the clock in the run above | |
| 647 | were that. What ends them now is a counter-terrorist that has run out of clock | |
| 673 | | | `de_forge` | `de_cinder` | | |
| 674 | | --- | --- | --- | | |
| 675 | | rounds played | 208 | 204 | | |
| 676 | | plant rate | **63%** | **52%** | | |
| 677 | | detonated | 99 | 83 | | |
| 678 | | elimination | 64 | 88 | | |
| 679 | | defused | 23 | 19 | | |
| 680 | | ends on the clock | 22 (**11%**) | 14 (**7%**) | | |
| 681 | | defuse rate | 18% of planted | 18% of planted | | |
| 682 | | median round | **123 s** | **93 s** | | |
| 683 | | longest round | 144 s | 145 s | | |
| 684 | ||
| 685 | About 6 seconds of wall clock a match on `de_forge` and 4 on `de_cinder` — | |
| 686 | which is why this is a script and not a test. Before the two sides read the | |
| 687 | clock (#105) the `de_forge` run was `detonated 103 · time 55 · elimination 39 · | |
| 688 | defused 19` over 216 rounds: a quarter of rounds decided by nothing at all. | |
| 689 | ||
| 690 | **`de_cinder` is eleven points off `de_forge`'s plant rate, and that number is | |
| 691 | accepted rather than tuned away.** What it is measuring is the map's geometry | |
| 692 | and not a weakness in the bots. `de_forge` puts both sites along the top with | |
| 693 | CT spawn between them: a counter-terrorist walks 12–22 cells to a site while a | |
| 694 | terrorist walks 31–44, so the defence is set before the attack arrives and the | |
| 695 | plant is what a five-stack arriving late against two or three defenders | |
| 696 | produces. `de_cinder`'s sites are diagonally opposite, so no spawn is near | |
| 697 | both: the counter-terrorists walk 17–28 and the terrorists 22–38, which is | |
| 698 | close enough that both sides are still moving when they meet. The fight happens | |
| 699 | on the way in rather than on the site, and a side wiped in the open ends the | |
| 700 | round before anyone has planted anything. Hence 88 eliminations against 64, | |
| 701 | and a median round 30 seconds | |
| 702 | shorter. The rounds that go nowhere at all — the failure #105 was about — are | |
| 703 | *fewer*, not more: 7% against 11%. | |
| 704 | ||
| 705 | Two things say this is the layout and not a broken site. The plants split | |
| 706 | across A and B the same way on both maps (16/32 and 15/26 over twelve seeded | |
| 707 | matches), so neither site is one nobody can take; and every structural claim | |
| 708 | `map.test.ts` makes — both sites reachable from every spawn cell, two doorways | |
| 709 | into each site, one connected walkable region — holds for `de_cinder` as it | |
| 710 | does for `de_forge`. Tuning `plan.ts` or the policy until the second map | |
| 711 | matched the first would be fitting the bots to a table. The map is faster and | |
| 712 | more lethal than the one the policy grew up on; that is what the table says, | |
| 713 | and it is what it feels like to play. | |
| 714 | ||
| 715 | Two more things to hold on to when reading either column. The person is idle | |
| 716 | the whole match, so the count is effectively 5-on-4 in the terrorists' favour — | |
| 717 | and once the sides swap at half time, the idle person is a terrorist standing | |
| 718 | in spawn, sometimes holding the bomb. Those rounds cannot be planted and cannot | |
| 719 | end in a terrorist win; 43 of the 55 rounds that ended on the clock in the | |
| 720 | pre-#105 run above were that. What ends them now is a counter-terrorist that has run out of clock | |
| 648 | 721 | walking over to find out why nobody has come, which is most of why |
| 649 | 722 | `elimination` nearly doubled. The rest of the baseline drifts whenever the |
| 650 | 723 | bots or the blast do — it was 13% defused before #66 and #64 landed — so |
scripts/measure.test.ts +16 −4
| @@ -3,15 +3,27 @@ import { parseArgs, rulesFor } from "./measure.ts"; | ||
| 3 | 3 | import { COMPETITIVE, SHORT } from "../src/client/match.ts"; |
| 4 | 4 | |
| 5 | 5 | test("no flags fall back to a short, quick, seeded default", () => { |
| 6 | expect(parseArgs([])).toEqual({ matches: 12, seed: 20260917, rulesName: "short", difficulty: "normal" }); | |
| 6 | expect(parseArgs([])).toEqual({ | |
| 7 | matches: 12, seed: 20260917, rulesName: "short", difficulty: "normal", map: "de_forge", | |
| 8 | }); | |
| 7 | 9 | }); |
| 8 | 10 | |
| 9 | test("matches, seed, rules and difficulty are read off the command line", () => { | |
| 10 | expect(parseArgs(["--matches", "40", "--seed", "7", "--rules", "long", "--difficulty", "hard"])).toEqual({ | |
| 11 | matches: 40, seed: 7, rulesName: "long", difficulty: "hard", | |
| 11 | test("matches, seed, rules, difficulty and map are read off the command line", () => { | |
| 12 | expect(parseArgs( | |
| 13 | ["--matches", "40", "--seed", "7", "--rules", "long", "--difficulty", "hard", "--map", "de_cinder"], | |
| 14 | )).toEqual({ | |
| 15 | matches: 40, seed: 7, rulesName: "long", difficulty: "hard", map: "de_cinder", | |
| 12 | 16 | }); |
| 13 | 17 | }); |
| 14 | 18 | |
| 19 | // The whole point of the flag is comparing two maps on one seed; a typo that | |
| 20 | // silently measured the default map again would read as the new map's numbers. | |
| 21 | test("a map that is not in the catalogue falls back to the default rather than a name nothing holds", () => { | |
| 22 | expect(parseArgs(["--map", "de_nothing"]).map).toBe("de_forge"); | |
| 23 | expect(parseArgs(["--map"]).map).toBe("de_forge"); | |
| 24 | expect(parseArgs(["--map", "DE_CINDER"]).map).toBe("de_forge"); | |
| 25 | }); | |
| 26 | ||
| 15 | 27 | test("a malformed or missing value falls back to the default rather than NaN", () => { |
| 16 | 28 | expect(parseArgs(["--matches", "nope"]).matches).toBe(12); |
| 17 | 29 | expect(parseArgs(["--matches", "-3"]).matches).toBe(12); |
scripts/measure.ts +21 −7
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * bun run scripts/measure.ts --matches 40 --seed 7 # more matches, a different seed |
| 10 | 10 | * bun run scripts/measure.ts --rules long # COMPETITIVE rules instead |
| 11 | 11 | * bun run scripts/measure.ts --difficulty hard # harder bots instead of normal |
| 12 | * bun run scripts/measure.ts --map de_cinder # a map from the catalogue, not the default one | |
| 12 | 13 | * |
| 13 | 14 | * Nothing here is imported by the game or the server. A round is recorded as |
| 14 | 15 | * plain numbers as it finishes — never a whole `World` held onto for later — |
| @@ -19,14 +20,22 @@ import { COMPETITIVE, SHORT, type Rules } from "../src/client/match.ts"; | ||
| 19 | 20 | import { |
| 20 | 21 | DEFAULT_DIFFICULTY, DIFFICULTIES, NO_INPUT, createWorld, difficultyLabel, update, type Difficulty, |
| 21 | 22 | } from "../src/client/world.ts"; |
| 23 | import { DEFAULT_MAP, MAP_NAMES, mapFor, type MapName } from "../src/client/map.ts"; | |
| 22 | 24 | import { reportLines, summarize, type RoundRecord } from "../src/client/rounds.ts"; |
| 23 | 25 | |
| 24 | 26 | export type RulesName = "short" | "long"; |
| 25 | export type Options = { matches: number; seed: number; rulesName: RulesName; difficulty: Difficulty }; | |
| 27 | export type Options = { | |
| 28 | matches: number; seed: number; rulesName: RulesName; difficulty: Difficulty; map: MapName; | |
| 29 | }; | |
| 26 | 30 | |
| 27 | const DEFAULTS: Options = { matches: 12, seed: 20260917, rulesName: "short", difficulty: DEFAULT_DIFFICULTY }; | |
| 31 | const DEFAULTS: Options = { | |
| 32 | matches: 12, seed: 20260917, rulesName: "short", difficulty: DEFAULT_DIFFICULTY, map: DEFAULT_MAP, | |
| 33 | }; | |
| 28 | 34 | |
| 29 | /** `--matches 40 --seed 7 --rules long --difficulty hard`, missing or malformed flags keep the default. */ | |
| 35 | /** | |
| 36 | * `--matches 40 --seed 7 --rules long --difficulty hard --map de_cinder`; a | |
| 37 | * missing or malformed value keeps the default rather than guessing at it. | |
| 38 | */ | |
| 30 | 39 | export function parseArgs(argv: string[]): Options { |
| 31 | 40 | const value = (name: string): string | null => { |
| 32 | 41 | const at = argv.indexOf(`--${name}`); |
| @@ -36,11 +45,13 @@ export function parseArgs(argv: string[]): Options { | ||
| 36 | 45 | const seed = value("seed") === null ? NaN : Number(value("seed")); |
| 37 | 46 | const rulesArg = value("rules"); |
| 38 | 47 | const difficultyArg = value("difficulty"); |
| 48 | const mapArg = value("map"); | |
| 39 | 49 | return { |
| 40 | 50 | matches: Number.isFinite(matches) && matches > 0 ? Math.round(matches) : DEFAULTS.matches, |
| 41 | 51 | seed: Number.isInteger(seed) && seed >= 0 ? seed : DEFAULTS.seed, |
| 42 | 52 | rulesName: rulesArg === "long" ? "long" : rulesArg === "short" ? "short" : DEFAULTS.rulesName, |
| 43 | 53 | difficulty: isDifficulty(difficultyArg) ? difficultyArg : DEFAULTS.difficulty, |
| 54 | map: isMapName(mapArg) ? mapArg : DEFAULTS.map, | |
| 44 | 55 | }; |
| 45 | 56 | } |
| 46 | 57 | |
| @@ -49,6 +60,9 @@ export const rulesFor = (name: RulesName): Rules => (name === "long" ? COMPETITI | ||
| 49 | 60 | const isDifficulty = (value: string | null): value is Difficulty => |
| 50 | 61 | DIFFICULTIES.includes(value as Difficulty); |
| 51 | 62 | |
| 63 | const isMapName = (value: string | null): value is MapName => | |
| 64 | (MAP_NAMES as readonly string[]).includes(value as string); | |
| 65 | ||
| 52 | 66 | const TICK = 1 / 64; |
| 53 | 67 | |
| 54 | 68 | /** |
| @@ -65,8 +79,8 @@ function maxTicksFor(rules: Rules): number { | ||
| 65 | 79 | } |
| 66 | 80 | |
| 67 | 81 | /** Play one seeded match to its end, the person idle, and return its rounds. */ |
| 68 | function playMatch(rules: Rules, seed: number, difficulty: Difficulty): RoundRecord[] { | |
| 69 | const world = createWorld(rules, "measure", "ct", 5, seed, difficulty); | |
| 82 | function playMatch(rules: Rules, seed: number, difficulty: Difficulty, map: MapName): RoundRecord[] { | |
| 83 | const world = createWorld(rules, "measure", "ct", 5, seed, difficulty, mapFor(map)); | |
| 70 | 84 | const rounds: RoundRecord[] = []; |
| 71 | 85 | let roundStart = 0; // world.time at the freeze that started the round in play |
| 72 | 86 | let prevPhase = world.match.phase; |
| @@ -92,7 +106,7 @@ function main(): void { | ||
| 92 | 106 | const opts = parseArgs(process.argv.slice(2)); |
| 93 | 107 | const rules = rulesFor(opts.rulesName); |
| 94 | 108 | console.log( |
| 95 | `${opts.matches} seeded ${opts.rulesName.toUpperCase()} matches, seed ${opts.seed}, ` + | |
| 109 | `${opts.matches} seeded ${opts.rulesName.toUpperCase()} matches on ${opts.map}, seed ${opts.seed}, ` + | |
| 96 | 110 | `${difficultyLabel(opts.difficulty)} bots, the person idle`, |
| 97 | 111 | ); |
| 98 | 112 | |
| @@ -102,7 +116,7 @@ function main(): void { | ||
| 102 | 116 | const before = Bun.nanoseconds(); |
| 103 | 117 | // Spread out the same way scripts/train.ts spreads its duels: a prime |
| 104 | 118 | // stride so nearby matches do not share a seed's low bits. |
| 105 | const matchRounds = playMatch(rules, opts.seed + i * 7919, opts.difficulty); | |
| 119 | const matchRounds = playMatch(rules, opts.seed + i * 7919, opts.difficulty, opts.map); | |
| 106 | 120 | rounds.push(...matchRounds); |
| 107 | 121 | const seconds = (Bun.nanoseconds() - before) / 1e9; |
| 108 | 122 | console.log(` match ${String(i + 1).padStart(3)}/${opts.matches} ${matchRounds.length} rounds ${seconds.toFixed(1)}s`); |
src/client/map.test.ts +133 −52
| @@ -1,40 +1,78 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { |
| 3 | buildMap, cellAt, colOf, DE_FORGE, DEFAULT_MAP, findPath, MAP_NAMES, mapFor, MAPS, rowOf, siteAt, walkable, | |
| 4 | worldOf, type Layout, | |
| 3 | buildMap, CELL, cellAt, colOf, DE_CINDER, DE_FORGE, DEFAULT_MAP, findPath, MAP_NAMES, mapFor, MAPS, | |
| 4 | rowOf, siteAt, walkable, worldOf, type Layout, type MapDef, | |
| 5 | 5 | } from "./map.ts"; |
| 6 | 6 | |
| 7 | test("the map has two sites and two spawns", () => { | |
| 8 | expect(DE_FORGE.spawns.t.length).toBeGreaterThan(4); | |
| 9 | expect(DE_FORGE.spawns.ct.length).toBeGreaterThan(4); | |
| 10 | expect(siteAt(DE_FORGE, DE_FORGE.sites.A)).toBe("A"); | |
| 11 | expect(siteAt(DE_FORGE, DE_FORGE.sites.B)).toBe("B"); | |
| 7 | /** | |
| 8 | * Every structural claim below holds for every map in the catalogue, not just | |
| 9 | * the one the game opens on. A layout is a table of numbers typed by hand; the | |
| 10 | * way it goes wrong is a site inside a crate or a room nothing can walk to, | |
| 11 | * and neither of those is visible in the table. | |
| 12 | */ | |
| 13 | const CATALOGUE: [string, MapDef][] = MAP_NAMES.map((name) => [name, MAPS[name]]); | |
| 14 | ||
| 15 | /** Every cell the flood fill from `start` can reach, four ways, walking. */ | |
| 16 | function reachable(map: MapDef, start: [number, number]): Set<number> { | |
| 17 | const seen = new Set<number>([start[1] * map.gridW + start[0]]); | |
| 18 | const queue = [start[1] * map.gridW + start[0]]; | |
| 19 | while (queue.length > 0) { | |
| 20 | const i = queue.pop() as number; | |
| 21 | const c = i % map.gridW, r = Math.floor(i / map.gridW); | |
| 22 | for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as [number, number][]) { | |
| 23 | if (!walkable(map, c + dc, r + dr)) continue; | |
| 24 | const j = (r + dr) * map.gridW + c + dc; | |
| 25 | if (seen.has(j)) continue; | |
| 26 | seen.add(j); | |
| 27 | queue.push(j); | |
| 28 | } | |
| 29 | } | |
| 30 | return seen; | |
| 31 | } | |
| 32 | ||
| 33 | test.each(CATALOGUE)("%s has two sites and two spawns", (_name, map) => { | |
| 34 | expect(map.spawns.t.length).toBeGreaterThan(4); | |
| 35 | expect(map.spawns.ct.length).toBeGreaterThan(4); | |
| 36 | expect(siteAt(map, map.sites.A)).toBe("A"); | |
| 37 | expect(siteAt(map, map.sites.B)).toBe("B"); | |
| 12 | 38 | }); |
| 13 | 39 | |
| 14 | test("each side musters in its own spawn, on a cell you can walk to", () => { | |
| 40 | // A site's `sites` point is where a bot walks to plant. A crate dropped on the | |
| 41 | // middle of a site leaves the name in the grid and the point inside the box: | |
| 42 | // the plant still happens, against a wall, and only a person standing there | |
| 43 | // ever finds out. | |
| 44 | test.each(CATALOGUE)("%s plants on floor, not inside a crate", (_name, map) => { | |
| 45 | for (const site of ["A", "B"] as const) { | |
| 46 | const at = map.sites[site]; | |
| 47 | expect(walkable(map, colOf(map, at[0]), rowOf(map, at[1]))).toBe(true); | |
| 48 | expect(cellAt(map, colOf(map, at[0]), rowOf(map, at[1]))).toBe(site); | |
| 49 | } | |
| 50 | }); | |
| 51 | ||
| 52 | test.each(CATALOGUE)("%s musters each side in its own spawn, on a cell you can walk to", (_name, map) => { | |
| 15 | 53 | for (const team of ["t", "ct"] as const) { |
| 16 | const at = DE_FORGE.muster[team]; | |
| 17 | expect(cellAt(DE_FORGE, colOf(DE_FORGE, at[0]), rowOf(DE_FORGE, at[1]))).toBe(team === "t" ? "t" : "c"); | |
| 18 | expect(walkable(DE_FORGE, colOf(DE_FORGE, at[0]), rowOf(DE_FORGE, at[1]))).toBe(true); | |
| 54 | const at = map.muster[team]; | |
| 55 | expect(cellAt(map, colOf(map, at[0]), rowOf(map, at[1]))).toBe(team === "t" ? "t" : "c"); | |
| 56 | expect(walkable(map, colOf(map, at[0]), rowOf(map, at[1]))).toBe(true); | |
| 19 | 57 | // And it is somewhere a bot can reach from either site, which is what |
| 20 | 58 | // a counter-terrorist out of clock asks the pathfinder for. |
| 21 | for (const site of [DE_FORGE.sites.A, DE_FORGE.sites.B]) { | |
| 22 | expect(findPath(DE_FORGE, site, at).length).toBeGreaterThan(0); | |
| 59 | for (const site of [map.sites.A, map.sites.B]) { | |
| 60 | expect(findPath(map, site, at).length).toBeGreaterThan(0); | |
| 23 | 61 | } |
| 24 | 62 | } |
| 25 | 63 | // The two halves of the map really are two halves: T at the bottom of the |
| 26 | 64 | // grid, CT at the top, the sites between them. |
| 27 | expect(DE_FORGE.muster.t[1]).toBeLessThan(DE_FORGE.muster.ct[1]); | |
| 65 | expect(map.muster.t[1]).toBeLessThan(map.muster.ct[1]); | |
| 28 | 66 | }); |
| 29 | 67 | |
| 30 | test("the border is solid all the way round", () => { | |
| 31 | for (let c = 0; c < DE_FORGE.gridW; c++) { | |
| 32 | expect(cellAt(DE_FORGE, c, 0)).toBe("#"); | |
| 33 | expect(cellAt(DE_FORGE, c, DE_FORGE.gridH - 1)).toBe("#"); | |
| 68 | test.each(CATALOGUE)("%s is walled all the way round", (_name, map) => { | |
| 69 | for (let c = 0; c < map.gridW; c++) { | |
| 70 | expect(cellAt(map, c, 0)).toBe("#"); | |
| 71 | expect(cellAt(map, c, map.gridH - 1)).toBe("#"); | |
| 34 | 72 | } |
| 35 | for (let r = 0; r < DE_FORGE.gridH; r++) { | |
| 36 | expect(cellAt(DE_FORGE, 0, r)).toBe("#"); | |
| 37 | expect(cellAt(DE_FORGE, DE_FORGE.gridW - 1, r)).toBe("#"); | |
| 73 | for (let r = 0; r < map.gridH; r++) { | |
| 74 | expect(cellAt(map, 0, r)).toBe("#"); | |
| 75 | expect(cellAt(map, map.gridW - 1, r)).toBe("#"); | |
| 38 | 76 | } |
| 39 | 77 | }); |
| 40 | 78 | |
| @@ -46,46 +84,85 @@ test("grid and world coordinates round-trip", () => { | ||
| 46 | 84 | } |
| 47 | 85 | }); |
| 48 | 86 | |
| 49 | test("every walkable cell is reachable from T spawn", () => { | |
| 87 | test.each(CATALOGUE)("every walkable cell of %s is reachable from T spawn", (_name, map) => { | |
| 50 | 88 | // A room nobody can reach is a bug you only find by walking into it. |
| 51 | const start = DE_FORGE.spawns.t[0]; | |
| 52 | const seen = new Set<number>(); | |
| 53 | const queue = [rowOf(DE_FORGE, start[1]) * DE_FORGE.gridW + colOf(DE_FORGE, start[0])]; | |
| 54 | seen.add(queue[0]); | |
| 55 | while (queue.length > 0) { | |
| 56 | const i = queue.pop() as number; | |
| 57 | const c = i % DE_FORGE.gridW, r = Math.floor(i / DE_FORGE.gridW); | |
| 58 | for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { | |
| 59 | if (!walkable(DE_FORGE, c + dc, r + dr)) continue; | |
| 60 | const j = (r + dr) * DE_FORGE.gridW + c + dc; | |
| 61 | if (seen.has(j)) continue; | |
| 62 | seen.add(j); | |
| 63 | queue.push(j); | |
| 64 | } | |
| 65 | } | |
| 89 | const start = map.spawns.t[0]; | |
| 90 | const seen = reachable(map, [colOf(map, start[0]), rowOf(map, start[1])]); | |
| 66 | 91 | let walkables = 0; |
| 67 | for (let r = 0; r < DE_FORGE.gridH; r++) | |
| 68 | for (let c = 0; c < DE_FORGE.gridW; c++) if (walkable(DE_FORGE, c, r)) walkables++; | |
| 92 | for (let r = 0; r < map.gridH; r++) | |
| 93 | for (let c = 0; c < map.gridW; c++) if (walkable(map, c, r)) walkables++; | |
| 69 | 94 | expect(seen.size).toBe(walkables); |
| 70 | 95 | }); |
| 71 | 96 | |
| 72 | test("greedy meshing collapses the walls into few boxes", () => { | |
| 73 | expect(DE_FORGE.boxes.length).toBeGreaterThan(10); | |
| 74 | expect(DE_FORGE.boxes.length).toBeLessThan(300); | |
| 97 | test.each(CATALOGUE)("greedy meshing collapses %s's walls into few boxes", (_name, map) => { | |
| 98 | expect(map.boxes.length).toBeGreaterThan(10); | |
| 99 | expect(map.boxes.length).toBeLessThan(300); | |
| 75 | 100 | }); |
| 76 | 101 | |
| 77 | test("a path from T spawn reaches both sites", () => { | |
| 78 | for (const site of [DE_FORGE.sites.A, DE_FORGE.sites.B]) { | |
| 79 | const path = findPath(DE_FORGE, DE_FORGE.spawns.t[0], site); | |
| 80 | expect(path.length).toBeGreaterThan(4); | |
| 81 | // Consecutive waypoints are neighbouring cells, never a jump across a wall. | |
| 82 | for (let i = 1; i < path.length - 1; i++) { | |
| 83 | const step = Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); | |
| 84 | expect(step).toBeLessThan(200); | |
| 102 | // Not "a path exists" but "a path exists from every cell a round can start on | |
| 103 | // to both places it can be decided". One spawn cell walled off behind a crate | |
| 104 | // is one bot in five standing still for a whole match. | |
| 105 | test.each(CATALOGUE)("on %s every spawn cell reaches both sites", (_name, map) => { | |
| 106 | for (const team of ["t", "ct"] as const) { | |
| 107 | for (const spawn of map.spawns[team]) { | |
| 108 | for (const site of [map.sites.A, map.sites.B]) { | |
| 109 | const path = findPath(map, spawn, site); | |
| 110 | expect(path.length).toBeGreaterThan(4); | |
| 111 | // Consecutive waypoints are neighbouring cells, never a jump across a wall. | |
| 112 | for (let i = 1; i < path.length - 1; i++) { | |
| 113 | const step = Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); | |
| 114 | expect(step).toBeLessThan(200); | |
| 115 | } | |
| 116 | } | |
| 85 | 117 | } |
| 86 | 118 | } |
| 87 | 119 | }); |
| 88 | 120 | |
| 121 | // Both sites reachable is not the same as both sites *approachable*: a site | |
| 122 | // with one door is a site the defence holds by standing in the door. Each one | |
| 123 | // here has two ways in that do not share their last step. | |
| 124 | test.each(CATALOGUE)("%s lets each site be entered two ways", (_name, map) => { | |
| 125 | for (const site of ["A", "B"] as const) { | |
| 126 | // Every walkable cell that is not the site but touches it, grouped by | |
| 127 | // whether the cells themselves touch: one group is one doorway. | |
| 128 | const mouth = new Set<number>(); | |
| 129 | for (let r = 0; r < map.gridH; r++) | |
| 130 | for (let c = 0; c < map.gridW; c++) { | |
| 131 | if (cellAt(map, c, r) !== site) continue; | |
| 132 | for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as [number, number][]) | |
| 133 | if (walkable(map, c + dc, r + dr) && cellAt(map, c + dc, r + dr) !== site) | |
| 134 | mouth.add((r + dr) * map.gridW + c + dc); | |
| 135 | } | |
| 136 | const seen = new Set<number>(); | |
| 137 | let doors = 0; | |
| 138 | for (const start of mouth) { | |
| 139 | if (seen.has(start)) continue; | |
| 140 | doors++; | |
| 141 | const queue = [start]; | |
| 142 | seen.add(start); | |
| 143 | while (queue.length > 0) { | |
| 144 | const i = queue.pop() as number; | |
| 145 | const c = i % map.gridW, r = Math.floor(i / map.gridW); | |
| 146 | for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as [number, number][]) { | |
| 147 | const j = (r + dr) * map.gridW + c + dc; | |
| 148 | if (!mouth.has(j) || seen.has(j)) continue; | |
| 149 | seen.add(j); | |
| 150 | queue.push(j); | |
| 151 | } | |
| 152 | } | |
| 153 | } | |
| 154 | expect(doors).toBeGreaterThan(1); | |
| 155 | } | |
| 156 | }); | |
| 157 | ||
| 158 | // One scale, one set of crates, one CELL: the shipped policy reads distances | |
| 159 | // and the radar reads the grid, and neither is told which map it is on. | |
| 160 | test("both maps are the same size, in the same cells", () => { | |
| 161 | expect(DE_CINDER.gridW).toBe(DE_FORGE.gridW); | |
| 162 | expect(DE_CINDER.gridH).toBe(DE_FORGE.gridH); | |
| 163 | expect(CELL).toBe(128); | |
| 164 | }); | |
| 165 | ||
| 89 | 166 | test("a path into solid rock comes back empty rather than lying", () => { |
| 90 | 167 | expect(findPath(DE_FORGE, DE_FORGE.spawns.t[0], worldOf(DE_FORGE, 0, 0))).toEqual([]); |
| 91 | 168 | }); |
| @@ -121,10 +198,13 @@ test("buildMap turns a layout into the cells, spawns and sites it describes", () | ||
| 121 | 198 | expect(siteAt(toy, toy.sites.B)).toBe("B"); |
| 122 | 199 | }); |
| 123 | 200 | |
| 124 | test("the catalogue holds de_forge, and nothing builds it twice", () => { | |
| 125 | expect(MAP_NAMES).toEqual(["de_forge"]); | |
| 201 | test("the catalogue holds both maps, and nothing builds either twice", () => { | |
| 202 | expect(MAP_NAMES).toEqual(["de_forge", "de_cinder"]); | |
| 126 | 203 | expect(DEFAULT_MAP).toBe("de_forge"); |
| 127 | 204 | expect(MAPS.de_forge).toBe(DE_FORGE); |
| 205 | expect(MAPS.de_cinder).toBe(DE_CINDER); | |
| 206 | expect(DE_CINDER).not.toBe(DE_FORGE); | |
| 207 | expect(DE_CINDER.grid).not.toEqual(DE_FORGE.grid); | |
| 128 | 208 | }); |
| 129 | 209 | |
| 130 | 210 | // A key and its map's own `name` diverging is a silent 422 on every post — a |
| @@ -137,6 +217,7 @@ test("every catalogue key names the map it holds", () => { | ||
| 137 | 217 | |
| 138 | 218 | test("mapFor returns the map a valid name asks for", () => { |
| 139 | 219 | expect(mapFor("de_forge")).toBe(DE_FORGE); |
| 220 | expect(mapFor("de_cinder")).toBe(DE_CINDER); | |
| 140 | 221 | }); |
| 141 | 222 | |
| 142 | 223 | test("mapFor falls back to the default map for anything unrecognised", () => { |
src/client/map.ts +69 −7
| @@ -5,11 +5,16 @@ | ||
| 5 | 5 | * the bomb can be planted — so the map can never disagree with itself. |
| 6 | 6 | * |
| 7 | 7 | * A map is a value, `MapDef`, built once by `buildMap` from a layout's areas |
| 8 | * and crates. `DE_FORGE` is the one the game plays; nothing below reaches for | |
| 9 | * a module-level grid, so a second layout is just a second `MapDef`. | |
| 8 | * and crates. Two layouts are in the catalogue below; nothing here reaches for | |
| 9 | * a module-level grid, so a third is a third table and a third key. | |
| 10 | 10 | * |
| 11 | * Layout is dust-shaped and original: two sites, a mid, a long right-hand | |
| 12 | * corridor and a left-hand tunnel, T at the bottom of the grid, CT at the top. | |
| 11 | * Both are original, both are 44 cells square, and both put T at the bottom of | |
| 12 | * the grid and CT at the top with the sites between them — that convention is | |
| 13 | * what the radar, the spawns and a counter-terrorist walking back to its own | |
| 14 | * half all read. `de_forge` is dust-shaped: two sites along the top, a mid, a | |
| 15 | * long right-hand corridor and a left-hand tunnel. `de_cinder` is not: the | |
| 16 | * sites are diagonally opposite, and the shortest way from one to the other | |
| 17 | * runs through the courtyard in the middle rather than behind it. | |
| 13 | 18 | */ |
| 14 | 19 | |
| 15 | 20 | import type { Vec3 } from "./math.ts"; |
| @@ -90,6 +95,60 @@ const DE_FORGE_LAYOUT: Layout = { | ||
| 90 | 95 | ], |
| 91 | 96 | }; |
| 92 | 97 | |
| 98 | /** | |
| 99 | * The second place to play. Where de_forge hangs both sites off the top of the | |
| 100 | * grid and runs two long lanes down to the terrorists, de_cinder puts them on | |
| 101 | * a diagonal — A in the north-west, B halfway down the east wall — around a | |
| 102 | * courtyard the rotation between them has to cross. de_forge's counter- | |
| 103 | * terrorists swap sites along one corridor behind their own spawn; de_cinder's | |
| 104 | * walk through the middle, which is the whole difference in how it plays. | |
| 105 | * | |
| 106 | * Each site has the long way and the other way. A is the west long, a twenty- | |
| 107 | * cell climb up the left wall from the terrorist end; or the vents, three | |
| 108 | * cells tall, out of the courtyard's west side into the middle of that climb. | |
| 109 | * B is the ramp up from t long on the east; or the kiln door, straight out of | |
| 110 | * the courtyard into B's north-west shoulder. The counter-terrorists come down | |
| 111 | * the kiln walk to A and the balcony to B, and both of those cross a route the | |
| 112 | * terrorists can take, so no approach is anybody's private corridor. | |
| 113 | * | |
| 114 | * Areas are painted in order and a later one wins, which is how the doorways | |
| 115 | * are cut: `west long` takes a bite out of A's south edge, `kiln walk` takes | |
| 116 | * one out of its east edge. Crates in a spawn are placed in pairs either side | |
| 117 | * of its middle so `muster` stays where it looks like it is. | |
| 118 | */ | |
| 119 | const DE_CINDER_LAYOUT: Layout = { | |
| 120 | name: "de_cinder", | |
| 121 | gridW: 44, | |
| 122 | gridH: 44, | |
| 123 | areas: [ | |
| 124 | { name: "ct spawn", rect: [24, 2, 34, 5], fill: "c" }, | |
| 125 | { name: "ct mid", rect: [20, 6, 25, 13], fill: "." }, | |
| 126 | { name: "b link", rect: [30, 6, 40, 9], fill: "." }, | |
| 127 | { name: "balcony", rect: [36, 9, 40, 16], fill: "." }, | |
| 128 | { name: "b site", rect: [32, 17, 40, 25], fill: "B" }, | |
| 129 | { name: "kiln door", rect: [26, 18, 32, 21], fill: "." }, | |
| 130 | { name: "ramp", rect: [27, 24, 31, 33], fill: "." }, | |
| 131 | { name: "t long", rect: [22, 33, 31, 37], fill: "." }, | |
| 132 | { name: "a site", rect: [3, 4, 11, 11], fill: "A" }, | |
| 133 | { name: "kiln walk", rect: [11, 6, 20, 9], fill: "." }, | |
| 134 | { name: "west long", rect: [3, 11, 7, 30], fill: "." }, | |
| 135 | { name: "vents", rect: [8, 22, 16, 24], fill: "." }, | |
| 136 | { name: "lower west", rect: [3, 30, 12, 35], fill: "." }, | |
| 137 | { name: "t west", rect: [9, 34, 13, 36], fill: "." }, | |
| 138 | { name: "mid", rect: [17, 13, 25, 26], fill: "." }, | |
| 139 | { name: "t mid", rect: [17, 26, 22, 37], fill: "." }, | |
| 140 | { name: "t spawn", rect: [9, 37, 20, 41], fill: "t" }, | |
| 141 | ], | |
| 142 | crates: [ | |
| 143 | [5, 6, "X"], [6, 6, "x"], [8, 5, "x"], [9, 9, "x"], [4, 10, "X"], | |
| 144 | [34, 19, "X"], [35, 19, "x"], [39, 18, "X"], [38, 23, "x"], [33, 24, "x"], | |
| 145 | [19, 17, "x"], [23, 17, "x"], [21, 23, "X"], | |
| 146 | [5, 16, "x"], [5, 27, "X"], [38, 12, "x"], [29, 29, "x"], [14, 23, "x"], | |
| 147 | [24, 35, "x"], [30, 7, "x"], [18, 7, "x"], [11, 39, "x"], [18, 39, "x"], | |
| 148 | [26, 3, "x"], [32, 3, "x"], | |
| 149 | ], | |
| 150 | }; | |
| 151 | ||
| 93 | 152 | function buildGrid(layout: Layout): Cell[] { |
| 94 | 153 | const g: Cell[] = new Array(layout.gridW * layout.gridH).fill("#"); |
| 95 | 154 | for (const area of layout.areas) { |
| @@ -223,13 +282,16 @@ export function buildMap(layout: Layout): MapDef { | ||
| 223 | 282 | /** The map de_forge plays on, built once from today's tables. */ |
| 224 | 283 | export const DE_FORGE: MapDef = buildMap(DE_FORGE_LAYOUT); |
| 225 | 284 | |
| 285 | /** The second map, built the same way from its own tables. */ | |
| 286 | export const DE_CINDER: MapDef = buildMap(DE_CINDER_LAYOUT); | |
| 287 | ||
| 226 | 288 | // --- the catalogue ---------------------------------------------------------- |
| 227 | 289 | |
| 228 | /** Every map the game can be played on. One entry today; a second layout is a second key. */ | |
| 229 | export type MapName = "de_forge"; | |
| 290 | /** Every map the game can be played on. A further layout is a further key. */ | |
| 291 | export type MapName = "de_forge" | "de_cinder"; | |
| 230 | 292 | |
| 231 | 293 | /** Name to built map, each one built exactly once, above. */ |
| 232 | export const MAPS: Record<MapName, MapDef> = { de_forge: DE_FORGE }; | |
| 294 | export const MAPS: Record<MapName, MapDef> = { de_forge: DE_FORGE, de_cinder: DE_CINDER }; | |
| 233 | 295 | |
| 234 | 296 | export const MAP_NAMES: readonly MapName[] = Object.keys(MAPS) as MapName[]; |
| 235 | 297 |
src/readme.test.ts +17
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | 9 | import { readFileSync } from "node:fs"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { DEFAULT_MAP, MAP_NAMES } from "./client/map.ts"; | |
| 11 | 12 | |
| 12 | 13 | const root = join(import.meta.dir, ".."); |
| 13 | 14 | const read = (path: string): string => readFileSync(join(root, path), "utf8"); |
| @@ -88,3 +89,19 @@ test("the readme says where the crosshair setting is, and that dynamic is the de | ||
| 88 | 89 | expect(names.length).toBeGreaterThan(3); |
| 89 | 90 | for (const name of names) expect(playing).toContain(name); |
| 90 | 91 | }); |
| 92 | ||
| 93 | test("the readme names every map in the catalogue, and no map the catalogue does not hold", () => { | |
| 94 | const playing = section("Playing"); | |
| 95 | expect(playing).toContain("**There are two maps**"); | |
| 96 | for (const name of MAP_NAMES) expect(playing).toContain(`\`${name}\``); | |
| 97 | expect(playing).toContain(`\`${DEFAULT_MAP}\` is the default`); | |
| 98 | }); | |
| 99 | ||
| 100 | // The one thing a player cannot see for themselves: the shipped weights met | |
| 101 | // one of these two maps and not the other, which is why the numbers differ. | |
| 102 | test("the readme says which map the policy was trained on and what that costs", () => { | |
| 103 | const bots = section("The bots"); | |
| 104 | expect(bots).toContain("trained on `de_forge`"); | |
| 105 | const round = section("Measuring the round"); | |
| 106 | for (const name of MAP_NAMES) expect(round).toContain(name); | |
| 107 | }); |
new above
offline