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

Merged opened this

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

Merged

vesper/cs:vesper/132-a-second-place-to-play-one-more-layout-a → main 3 commits fork

  • Merged. vesper/132-a-second-place-to-play-one-more-layout-a was merged into main as 7dd5512.

Review

Sign in to review this change.
6 files changed +352 −93
README.md +96 −23
@@ -1,9 +1,9 @@
11 # cs
22
33 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.
77
88 ## Run it locally
99
@@ -50,7 +50,7 @@ wants one, but the running server never reads it.
5050 loss bonuses. Cover is not all equal: a bullet goes **through a crate** if
5151 the round is strong enough for the thickness it meets. Crates are the only
5252 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
5454 (230 units), AK-47 (175), M4A4 (165) and Desert Eagle (150) punch one
5555 face-on while the Glock (48), USP (55) and MP9 (60) manage nothing but a
5656 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.
142142 abandoned match still counts as a match, with its kills and its damage, the
143143 same as any other — it just never counts as a win, which already required
144144 `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
146146 weapon models and every sound are produced in code at start-up. There are no
147147 asset files and nothing from the real game is used — the numbers follow CS's
148148 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
219219 the round through a teammate's eyes, and the four still alive are the ones who
220220 can do something about what you can see.
221221
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
222236 The menu's **Difficulty** select picks Easy, Normal or Hard before a match
223237 starts — each one a band of the bots' skill rather than one number, so a
224238 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.
389403
390404 - `src/client/` — the game, in modules named for one thing each: `math`, `map`
391405 (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),
397415 `physics`, `weapons`, `combat`, `grenades` (a thrown thing's flight: gravity,
398416 bounce, fuse and how far a blast reaches), `match` (the rules), `world` (the simulation),
399417 `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:
495513 buying, planting, defusing, picking a dropped bomb back up, and listening to
496514 the room. The fight is a reflex, and that is a policy learned with PPO.
497515
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
498530 The plan is one site a side a round, rolled from the match's seed and the round
499531 number rather than by each bot for itself, so the five terrorists arrive
500532 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
624656 bun run scripts/measure.ts --matches 40 --seed 7 # more matches, a different seed
625657 bun run scripts/measure.ts --rules long # COMPETITIVE rules instead
626658 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
627660 ```
628661
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
629665 A round's length is measured from the freeze that starts it to the tick it is
630666 decided, so it is the freeze time plus whatever of the round clock and the
631667 bomb's fuse actually ran — not the 115-second round clock alone, which is why
632668 the longest round below is well past it.
633669
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:
641672
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
648721 walking over to find out why nobody has come, which is most of why
649722 `elimination` nearly doubled. The rest of the baseline drifts whenever the
650723 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";
33 import { COMPETITIVE, SHORT } from "../src/client/match.ts";
44
55 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 });
79 });
810
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",
1216 });
1317 });
1418
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
1527 test("a malformed or missing value falls back to the default rather than NaN", () => {
1628 expect(parseArgs(["--matches", "nope"]).matches).toBe(12);
1729 expect(parseArgs(["--matches", "-3"]).matches).toBe(12);
scripts/measure.ts +21 −7
@@ -9,6 +9,7 @@
99 * bun run scripts/measure.ts --matches 40 --seed 7 # more matches, a different seed
1010 * bun run scripts/measure.ts --rules long # COMPETITIVE rules instead
1111 * 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
1213 *
1314 * Nothing here is imported by the game or the server. A round is recorded as
1415 * 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";
1920 import {
2021 DEFAULT_DIFFICULTY, DIFFICULTIES, NO_INPUT, createWorld, difficultyLabel, update, type Difficulty,
2122 } from "../src/client/world.ts";
23 import { DEFAULT_MAP, MAP_NAMES, mapFor, type MapName } from "../src/client/map.ts";
2224 import { reportLines, summarize, type RoundRecord } from "../src/client/rounds.ts";
2325
2426 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 };
2630
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 };
2834
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 */
3039 export function parseArgs(argv: string[]): Options {
3140 const value = (name: string): string | null => {
3241 const at = argv.indexOf(`--${name}`);
@@ -36,11 +45,13 @@ export function parseArgs(argv: string[]): Options {
3645 const seed = value("seed") === null ? NaN : Number(value("seed"));
3746 const rulesArg = value("rules");
3847 const difficultyArg = value("difficulty");
48 const mapArg = value("map");
3949 return {
4050 matches: Number.isFinite(matches) && matches > 0 ? Math.round(matches) : DEFAULTS.matches,
4151 seed: Number.isInteger(seed) && seed >= 0 ? seed : DEFAULTS.seed,
4252 rulesName: rulesArg === "long" ? "long" : rulesArg === "short" ? "short" : DEFAULTS.rulesName,
4353 difficulty: isDifficulty(difficultyArg) ? difficultyArg : DEFAULTS.difficulty,
54 map: isMapName(mapArg) ? mapArg : DEFAULTS.map,
4455 };
4556 }
4657
@@ -49,6 +60,9 @@ export const rulesFor = (name: RulesName): Rules => (name === "long" ? COMPETITI
4960 const isDifficulty = (value: string | null): value is Difficulty =>
5061 DIFFICULTIES.includes(value as Difficulty);
5162
63 const isMapName = (value: string | null): value is MapName =>
64 (MAP_NAMES as readonly string[]).includes(value as string);
65
5266 const TICK = 1 / 64;
5367
5468 /**
@@ -65,8 +79,8 @@ function maxTicksFor(rules: Rules): number {
6579 }
6680
6781 /** 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));
7084 const rounds: RoundRecord[] = [];
7185 let roundStart = 0; // world.time at the freeze that started the round in play
7286 let prevPhase = world.match.phase;
@@ -92,7 +106,7 @@ function main(): void {
92106 const opts = parseArgs(process.argv.slice(2));
93107 const rules = rulesFor(opts.rulesName);
94108 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}, ` +
96110 `${difficultyLabel(opts.difficulty)} bots, the person idle`,
97111 );
98112
@@ -102,7 +116,7 @@ function main(): void {
102116 const before = Bun.nanoseconds();
103117 // Spread out the same way scripts/train.ts spreads its duels: a prime
104118 // 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);
106120 rounds.push(...matchRounds);
107121 const seconds = (Bun.nanoseconds() - before) / 1e9;
108122 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 @@
11 import { expect, test } from "bun:test";
22 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,
55 } from "./map.ts";
66
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");
1238 });
1339
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) => {
1553 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);
1957 // And it is somewhere a bot can reach from either site, which is what
2058 // 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);
2361 }
2462 }
2563 // The two halves of the map really are two halves: T at the bottom of the
2664 // 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]);
2866 });
2967
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("#");
3472 }
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("#");
3876 }
3977 });
4078
@@ -46,46 +84,85 @@ test("grid and world coordinates round-trip", () => {
4684 }
4785 });
4886
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) => {
5088 // 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])]);
6691 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++;
6994 expect(seen.size).toBe(walkables);
7095 });
7196
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);
75100 });
76101
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 }
85117 }
86118 }
87119 });
88120
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
89166 test("a path into solid rock comes back empty rather than lying", () => {
90167 expect(findPath(DE_FORGE, DE_FORGE.spawns.t[0], worldOf(DE_FORGE, 0, 0))).toEqual([]);
91168 });
@@ -121,10 +198,13 @@ test("buildMap turns a layout into the cells, spawns and sites it describes", ()
121198 expect(siteAt(toy, toy.sites.B)).toBe("B");
122199 });
123200
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"]);
126203 expect(DEFAULT_MAP).toBe("de_forge");
127204 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);
128208 });
129209
130210 // 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", () => {
137217
138218 test("mapFor returns the map a valid name asks for", () => {
139219 expect(mapFor("de_forge")).toBe(DE_FORGE);
220 expect(mapFor("de_cinder")).toBe(DE_CINDER);
140221 });
141222
142223 test("mapFor falls back to the default map for anything unrecognised", () => {
src/client/map.ts +69 −7
@@ -5,11 +5,16 @@
55 * the bomb can be planted — so the map can never disagree with itself.
66 *
77 * 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.
1010 *
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.
1318 */
1419
1520 import type { Vec3 } from "./math.ts";
@@ -90,6 +95,60 @@ const DE_FORGE_LAYOUT: Layout = {
9095 ],
9196 };
9297
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
93152 function buildGrid(layout: Layout): Cell[] {
94153 const g: Cell[] = new Array(layout.gridW * layout.gridH).fill("#");
95154 for (const area of layout.areas) {
@@ -223,13 +282,16 @@ export function buildMap(layout: Layout): MapDef {
223282 /** The map de_forge plays on, built once from today's tables. */
224283 export const DE_FORGE: MapDef = buildMap(DE_FORGE_LAYOUT);
225284
285 /** The second map, built the same way from its own tables. */
286 export const DE_CINDER: MapDef = buildMap(DE_CINDER_LAYOUT);
287
226288 // --- the catalogue ----------------------------------------------------------
227289
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";
230292
231293 /** 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 };
233295
234296 export const MAP_NAMES: readonly MapName[] = Object.keys(MAPS) as MapName[];
235297
src/readme.test.ts +17
@@ -8,6 +8,7 @@
88 import { expect, test } from "bun:test";
99 import { readFileSync } from "node:fs";
1010 import { join } from "node:path";
11 import { DEFAULT_MAP, MAP_NAMES } from "./client/map.ts";
1112
1213 const root = join(import.meta.dir, "..");
1314 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
8889 expect(names.length).toBeGreaterThan(3);
8990 for (const name of names) expect(playing).toContain(name);
9091 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs