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

forked from trevato/cs · proposes

The map becomes a value: de_forge is one object the game is handed, not module state it imports #104

Merged opened this

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

Merged

vesper/cs-engine:vesper/95-the-map-becomes-a-value-de-forge-is-one → main 3 commits fork

  • Merged. vesper/95-the-map-becomes-a-value-de-forge-is-one was merged into main as fc68c6d.

Review

Sign in to review this change.
27 files changed +563 −204
README.md +14 −4
@@ -191,14 +191,24 @@ subtends — and the panel says which of the two you are looking at.
191191 `example.test.ts` plays it headlessly: a seeded run scores the same twice, a
192192 shot down the target's bearing hits and one twenty degrees off misses, and a
193193 second of elapsed time is sixty ticks however the frames are cut.
194 - `src/client/` — the game, in modules named for one thing each: `map` (de_forge
195 itself: its areas, its crates, its cell alphabet and its spawns and sites, laid
196 over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules),
194 - `src/client/` — the game, in modules named for one thing each: `level` (what a
195 level *is* to this game — a grid of lettered cells, the predicate that says
196 which letters you can stand on, the boxes, the spawns, the sites, the wall
197 height and the material table, plus the questions asked of one: `walkable`,
198 `siteAt`, `findPath`), `map` (de_forge itself: its areas, its crates, its cell
199 alphabet and its spawns and sites, laid over the engine's grid, exported as
200 `DE_FORGE` — one value of `Level` and the only one that ships),
201 `physics`, `weapons`, `combat`, `match` (the rules),
197202 `world` (the simulation),
198203 `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`,
199204 `binding`, `scene`, `hud`, `audio`, `main`. Everything in here is headless and
200205 tested: the WebGL2 is the engine's now, and this game has no renderer of its
201 own. `models`, `materials` and `audio` are this game's recipes for the
206 own. The level is handed in rather than imported: `createWorld(level, …)`
207 keeps it on the world and the bots, the bullets, the bomb and the meshes read
208 it from there, so `main` is the only file that names `DE_FORGE` and
209 `level.test.ts` builds an eleven-by-nine box room of its own, walks a bot
210 across it and stops a bullet in its walls without a line of `src/client/`
211 changing to allow it. `models`, `materials` and `audio` are this game's recipes for the
202212 engine's three asset tools — `main` builds the atlas and all eight meshes at
203213 start-up and hands them to `createRenderer`, which keeps them by name — and
204214 `binding` is its key table and the one
src/client/bots.test.ts +2 −1
@@ -1,10 +1,11 @@
11 import { expect, test } from "bun:test";
22 import { applyIntent, botThink, CARD, makeBrain, policy, think } from "./bots.ts";
3 import { DE_FORGE } from "./map.ts";
34 import { COMPETITIVE } from "./match.ts";
45 import { aimAt, bodyCentre, createWorld, type Player } from "./world.ts";
56
67 const duel = (seed = 2024): { world: ReturnType<typeof createWorld>; me: Player; bot: Player } => {
7 const world = createWorld(COMPETITIVE, "you", "ct", 1, seed);
8 const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, seed);
89 world.match.phase = "live";
910 const me = world.me;
1011 const bot = world.players[1] as Player;
src/client/bots.ts +11 −9
@@ -13,7 +13,7 @@
1313
1414 import { decode, HEADS, STILL, type Intent } from "./action.ts";
1515 import { angleDelta, clamp, dist, rng, type Vec3 } from "../engine/index.ts";
16 import * as map from "./map.ts";
16 import { colOf, findPath, rowOf, siteAt, walkable, type Level } from "./level.ts";
1717 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
1818 import {
1919 forward, newActivations, sample, unpack, type Activations, type Policy,
@@ -118,24 +118,26 @@ function brainOf(world: World, bot: Player): BotBrain {
118118 /** Where this bot is trying to be, given what the round is doing. */
119119 function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 {
120120 const bomb = world.bomb;
121 const level = world.level;
122 const site = level.sites[brain.site];
121123 if (bot.team === "t") {
122124 if (bomb.state === "planted") {
123125 // Hold near the bomb rather than walk onto it.
124 return nearby(brain, bomb.pos, 300);
126 return nearby(level, brain, bomb.pos, 300);
125127 }
126 if (bot.hasBomb) return map.sites[brain.site];
128 if (bot.hasBomb) return site;
127129 if (bomb.state === "dropped") return bomb.pos;
128 return nearby(brain, map.sites[brain.site], 200);
130 return nearby(level, brain, site, 200);
129131 }
130132 if (bomb.state === "planted") return bomb.pos;
131 return nearby(brain, map.sites[brain.site], 250);
133 return nearby(level, brain, site, 250);
132134 }
133135
134 const nearby = (brain: BotBrain, at: Vec3, spread: number): Vec3 => {
136 const nearby = (level: Level, brain: BotBrain, at: Vec3, spread: number): Vec3 => {
135137 for (let tries = 0; tries < 8; tries++) {
136138 const x = at[0] + (brain.random() * 2 - 1) * spread;
137139 const y = at[1] + (brain.random() * 2 - 1) * spread;
138 if (map.walkable(map.colOf(x), map.rowOf(y))) return [x, y, at[2]];
140 if (walkable(level, colOf(level, x), rowOf(level, y))) return [x, y, at[2]];
139141 }
140142 return at;
141143 };
@@ -317,7 +319,7 @@ export function botThink(world: World, bot: Player, dt: number): Input {
317319 world.time > brain.repathAt || objective !== brain.plannedFor;
318320 if (stale) {
319321 brain.goal = chooseGoal(world, bot, brain);
320 brain.path = map.findPath(bot.pos, brain.goal);
322 brain.path = findPath(world.level, bot.pos, brain.goal);
321323 brain.step = 0;
322324 brain.plannedFor = objective;
323325 brain.repathAt = world.time + 3 + brain.random() * 3;
@@ -342,7 +344,7 @@ export function botThink(world: World, bot: Player, dt: number): Input {
342344 /** Planting and defusing: press use when standing where it matters. */
343345 function withBomb(world: World, bot: Player, input: Input): Input {
344346 const bomb = world.bomb;
345 if (bot.team === "t" && bot.hasBomb && map.siteAt(bot.pos) !== null && world.match.phase === "live") {
347 if (bot.team === "t" && bot.hasBomb && siteAt(world.level, bot.pos) !== null && world.match.phase === "live") {
346348 input.use = true;
347349 input.forward = 0;
348350 input.side = 0;
src/client/combat.test.ts +1 −1
@@ -1,6 +1,6 @@
11 import { expect, test } from "bun:test";
22 import { applyHit, hitboxes, traceBullet, type Actor } from "./combat.ts";
3 import type { Box } from "./map.ts";
3 import type { Box } from "./level.ts";
44 import type { Vec3 } from "../engine/index.ts";
55 import { WEAPONS } from "./weapons.ts";
66
src/client/combat.ts +1 −1
@@ -4,7 +4,7 @@
44 * crouching head really is where a crouching head looks.
55 */
66
7 import type { Box } from "./map.ts";
7 import type { Box } from "./level.ts";
88 import { dist, rayBox, traceWorld, type Vec3 } from "../engine/index.ts";
99 import { HULL } from "./physics.ts";
1010 import { computeDamage, type Hitgroup, type Weapon } from "./weapons.ts";
src/client/hud.test.ts +2 −1
@@ -23,6 +23,7 @@
2323
2424 import { expect, test } from "bun:test";
2525 import { drawHud, type Paint } from "./hud.ts";
26 import { DE_FORGE } from "./map.ts";
2627 import { SHORT } from "./match.ts";
2728 import { inaccuracy, WEAPONS } from "./weapons.ts";
2829 import { createWorld, currentWeapon, type Ammo, type Player, type World } from "./world.ts";
@@ -71,7 +72,7 @@ function recorder(): Recorder {
7172 * Every test draws a *copy* of it instead, so a test that spends a magazine or
7273 * kills a bot does not reach the next one.
7374 */
74 const BASE = createWorld(SHORT, "you", "ct", 5, 64);
75 const BASE = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 64);
7576
7677 const copyAmmo = (ammo: Record<string, Ammo>): Record<string, Ammo> =>
7778 Object.fromEntries(Object.entries(ammo).map(([id, a]) => [id, { ...a }]));
src/client/level.test.ts added +189
@@ -0,0 +1,189 @@
1 /**
2 * A level is a value, and this file is the proof: an eleven-by-nine box room,
3 * written out here and nowhere else, is handed to `createWorld` and the game
4 * plays on it. A bot paths from one spawn to the other, walks, and stays inside
5 * walls de_forge has never heard of; a bullet stops in one of them. Nothing
6 * under `src/client/` changed to allow it — the room only has to be a `Level`.
7 *
8 * The invariants every level owes are checked here once, over both levels: a
9 * spawn you cannot stand on, a site off the edge of the grid or a box hanging
10 * outside the floor are bugs in a map, and the test that catches them should
11 * not have to be written again for the second one.
12 */
13
14 import { expect, test } from "bun:test";
15 import { readdirSync, readFileSync } from "node:fs";
16 import { join } from "node:path";
17 import { traceBullet } from "./combat.ts";
18 import { mergeSolid, worldOf as gridWorldOf, type Grid, type Vec3 } from "../engine/index.ts";
19 import {
20 colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Level,
21 } from "./level.ts";
22 import { DE_FORGE } from "./map.ts";
23 import { SHORT } from "./match.ts";
24 import { applyInput, createWorld, type Player } from "./world.ts";
25 import { botThink } from "./bots.ts";
26
27 // --- a second level, built here ------------------------------------------
28
29 const CELL = 128;
30 const WALL = 256;
31
32 /**
33 * One room, drawn as it is played: `#` is wall, `t` and `c` are the two spawns,
34 * `A` and `B` are the two bombsites a defusal level has by definition, and a
35 * dot is floor. Eleven cells east by nine south, which is 1,408 by 1,152 units.
36 */
37 const ROWS = [
38 "###########",
39 "#t.......A#",
40 "#.........#",
41 "#.........#",
42 "#.........#",
43 "#.........#",
44 "#.........#",
45 "#c.......B#",
46 "###########",
47 ];
48
49 const GRID: Grid<string> = {
50 width: ROWS[0].length,
51 height: ROWS.length,
52 cell: CELL,
53 cells: ROWS.flatMap((row) => [...row]),
54 };
55
56 /** The room's whole alphabet: everything that is not rock can be walked on. */
57 const passable = (cell: string): boolean => cell !== "#";
58
59 const halfX = (GRID.width / 2) * CELL;
60 const halfY = (GRID.height / 2) * CELL;
61
62 /**
63 * Where the one cell carrying `letter` sits in the world. The four places are
64 * read back off the grid rather than written twice, so the room cannot disagree
65 * with the picture above — which is the same trick de_forge plays.
66 */
67 function place(letter: string): Vec3 {
68 const at = GRID.cells.indexOf(letter);
69 return gridWorldOf(GRID, at % GRID.width, Math.floor(at / GRID.width));
70 }
71
72 const ROOM: Level = {
73 name: "de_room",
74 grid: GRID,
75 passable,
76 wallHeight: WALL,
77 materials: { FLOOR: 0, WALL: 1 },
78 boxes: [
79 { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: 0 },
80 ...mergeSolid(GRID, (cell) => cell === "#", 0, WALL, 1),
81 ],
82 spawns: { t: [place("t")], ct: [place("c")] },
83 sites: { A: place("A"), B: place("B") },
84 siteCells: { A: "A", B: "B" },
85 };
86
87 // --- what every level owes ------------------------------------------------
88
89 /** Why this level is not one, or nothing at all. Every level answers this. */
90 function problems(level: Level): string[] {
91 const bad: string[] = [];
92 for (const [team, spots] of Object.entries(level.spawns))
93 for (const spot of spots)
94 if (!walkable(level, colOf(level, spot[0]), rowOf(level, spot[1])))
95 bad.push(`${level.name}: a ${team} spawn at ${spot} is not walkable`);
96 for (const [name, site] of Object.entries(level.sites))
97 if (siteAt(level, site) !== name) bad.push(`${level.name}: site ${name} at ${site} is not on ${name}`);
98 const { width, height, cell } = level.grid;
99 const edge = [(width / 2) * cell, (height / 2) * cell];
100 for (const box of level.boxes)
101 for (const axis of [0, 1])
102 if (box.min[axis] < -edge[axis] || box.max[axis] > edge[axis])
103 bad.push(`${level.name}: a box runs from ${box.min} to ${box.max}, outside the grid`);
104 return bad;
105 }
106
107 test("both levels keep the promises the type makes: spawns, sites, boxes", () => {
108 expect(problems(DE_FORGE)).toEqual([]);
109 expect(problems(ROOM)).toEqual([]);
110 // And the check bites: a spawn in the wall and a box off the edge are named.
111 const broken: Level = {
112 ...ROOM,
113 spawns: { t: [worldOf(ROOM, 0, 0)], ct: ROOM.spawns.ct },
114 boxes: [{ min: [-9999, 0, 0], max: [0, 0, 0], mat: 0 }],
115 };
116 expect(problems(broken).length).toBe(2);
117 });
118
119 test("the room is a grid, a predicate and eleven boxes of geometry", () => {
120 expect(ROOM.grid.cells.length).toBe(11 * 9);
121 expect(walkable(ROOM, 1, 1)).toBe(true);
122 expect(walkable(ROOM, 0, 0)).toBe(false);
123 expect(walkable(ROOM, -1, 4)).toBe(false); // off the edge is not a cell
124 expect(siteAt(ROOM, ROOM.sites.A)).toBe("A");
125 expect(siteAt(ROOM, ROOM.sites.B)).toBe("B");
126 expect(openCells(ROOM).length).toBe(9 * 7);
127 });
128
129 test("only the map and the entry name de_forge: everything else is handed a level", () => {
130 // The whole point of the type. A module that reaches for `DE_FORGE` is a
131 // module that has decided which map the game is on, and only the file that
132 // builds it and the file that starts the game get to decide that.
133 const mine = import.meta.dir;
134 const allowed = new Set(["map.ts", "main.ts"]);
135 const offenders = readdirSync(mine)
136 .filter((file) => file.endsWith(".ts") && !file.includes(".test"))
137 .filter((file) => !allowed.has(file) && readFileSync(join(mine, file), "utf8").includes("DE_FORGE"));
138 expect(offenders).toEqual([]);
139 });
140
141 // --- and the game played on it --------------------------------------------
142
143 test("a path crosses the room, cell by cell, without leaving it", () => {
144 const path = findPath(ROOM, ROOM.spawns.t[0], ROOM.spawns.ct[0]);
145 expect(path.length).toBeGreaterThan(4);
146 for (let i = 1; i < path.length; i++)
147 expect(Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1])).toBeLessThan(CELL * 1.5);
148 for (const step of path) expect(walkable(ROOM, colOf(ROOM, step[0]), rowOf(ROOM, step[1]))).toBe(true);
149 });
150
151 test("a world on the room is a world on the room, and its bot walks it", () => {
152 const world = createWorld(ROOM, SHORT, "me", "ct", 1, 5);
153 expect(world.level).toBe(ROOM);
154 // The bot spawns on the room's T spawn, not on de_forge's.
155 const bot = world.players.find((p) => p.bot) as Player;
156 expect([bot.pos[0], bot.pos[1]]).toEqual([ROOM.spawns.t[0][0], ROOM.spawns.t[0][1]]);
157 expect(siteAt(ROOM, bot.pos)).toBe(null);
158
159 // Three seconds of it thinking and moving, with nobody to shoot at: the round
160 // rules are left out on purpose, because a lone bot ends the round at once.
161 world.match.phase = "live";
162 world.me.alive = false;
163 const from: [number, number] = [bot.pos[0], bot.pos[1]];
164 for (let tick = 0; tick < 64 * 3; tick++) {
165 world.time += 1 / 64;
166 applyInput(world, bot, botThink(world, bot, 1 / 64), 1 / 64);
167 }
168 // It walked, on a path made of this room's cells, and this room's walls —
169 // not de_forge's — are what kept it inside.
170 expect(Math.hypot(bot.pos[0] - from[0], bot.pos[1] - from[1])).toBeGreaterThan(200);
171 const path = (bot.brain as { path: Vec3[] }).path;
172 expect(path.length).toBeGreaterThan(1);
173 for (const step of path)
174 expect(walkable(ROOM, colOf(ROOM, step[0]), rowOf(ROOM, step[1]))).toBe(true);
175 expect(Math.abs(bot.pos[0])).toBeLessThan(halfX);
176 expect(Math.abs(bot.pos[1])).toBeLessThan(halfY);
177 });
178
179 test("a bullet fired across the room stops in the room's own wall", () => {
180 const world = createWorld(ROOM, SHORT, "me", "ct", 1, 9);
181 const from: [number, number, number] = [0, 0, 64];
182 // Due east: the east wall's inner face is the west side of column 10, at
183 // (10 - 5.5 + 0.5) * 128 - 64 = 576 units.
184 const hit = traceBullet(from, [8192, 0, 64], world.level.boxes, [], -1);
185 expect(hit.kind).toBe("world");
186 if (hit.kind !== "world") return;
187 expect(hit.point[0]).toBeCloseTo(576, 6);
188 expect(hit.normal).toEqual([-1, 0, 0]);
189 });
src/client/level.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * What a level is, as one value: a grid of lettered cells, what a letter means,
3 * the boxes that grid merges into, where each side spawns, where the two sites
4 * are, and the numbers it was built at. `map.ts` builds de_forge as one of
5 * these and `createWorld` is handed it, so a second map, a second mode or a
6 * four-cell test room is a second value rather than a second module.
7 *
8 * Nothing here knows de_forge's alphabet. A cell is a letter; which letters
9 * exist and which of them a player may stand on is the level's own `passable`,
10 * and the arithmetic and the algorithms under it — cell to world, meshing, A* —
11 * are the engine's `src/engine/grid.ts`, which knows less still.
12 *
13 * Everything below takes the level first and reads it once. These are the
14 * questions the simulation asks every tick, so they stay flat: a lookup here is
15 * a bounds check and a multiply, never a search.
16 */
17
18 import * as g from "../engine/index.ts";
19 import type { Box, Grid, Vec3 } from "../engine/index.ts";
20
21 /** The tracer owns the box; a level hands it out, because that is what a level is made of. */
22 export type { Box } from "../engine/index.ts";
23
24 /** A cell is a letter. What the letters are, and what each one means, is a level's own. */
25 export type Cell = string;
26
27 export type Level = {
28 /** What to call it in a log or a test failure. */
29 readonly name: string;
30 /** The cells, row-major, with the world arithmetic that goes with them. */
31 readonly grid: Grid<Cell>;
32 /** May a player stand on this cell? The whole of what the search knows about the alphabet. */
33 readonly passable: (cell: Cell) => boolean;
34 /** How tall a wall stands, in world units. */
35 readonly wallHeight: number;
36 /** The material ids this level's boxes are tagged with, by name. */
37 readonly materials: Readonly<Record<string, number>>;
38 /** Everything solid, in one list: the renderer draws it and bullets stop in it. */
39 readonly boxes: Box[];
40 /** Where each side starts a round, one point per spawn cell. */
41 readonly spawns: { readonly t: Vec3[]; readonly ct: Vec3[] };
42 /** The middle of each bombsite. */
43 readonly sites: { readonly A: Vec3; readonly B: Vec3 };
44 /** Which letter each site is paved with, so a point can be asked which site it stands in. */
45 readonly siteCells: { readonly A: Cell; readonly B: Cell };
46 };
47
48 /** The cell at (col, row), or `undefined` off the edge — what is out there is the level's business. */
49 export const cellAt = (level: Level, col: number, row: number): Cell | undefined =>
50 g.cellAt(level.grid, col, row);
51
52 /**
53 * Can a player stand in this cell? Off the edge of a level is not a cell and so
54 * is never walkable, whatever the level's border is made of.
55 */
56 export function walkable(level: Level, col: number, row: number): boolean {
57 const { width, height, cells } = level.grid;
58 if (col < 0 || row < 0 || col >= width || row >= height) return false;
59 return level.passable(cells[row * width + col] as Cell);
60 }
61
62 /** Grid coordinates are north-down; the world is Y-north, centred on zero. */
63 export const worldOf = (level: Level, col: number, row: number, z = 0): Vec3 =>
64 g.worldOf(level.grid, col, row, z);
65
66 export const colOf = (level: Level, x: number): number => g.colOf(level.grid, x);
67 export const rowOf = (level: Level, y: number): number => g.rowOf(level.grid, y);
68
69 /** Which bombsite a point stands in, if any. */
70 export function siteAt(level: Level, pos: Vec3): "A" | "B" | null {
71 const cell = cellAt(level, colOf(level, pos[0]), rowOf(level, pos[1]));
72 if (cell === undefined) return null;
73 if (cell === level.siteCells.A) return "A";
74 if (cell === level.siteCells.B) return "B";
75 return null;
76 }
77
78 /**
79 * A* over the cells this level calls walkable, eight ways, no corner-cutting:
80 * the search is the engine's and what a bot may stand on is the level's answer.
81 * de_forge is 1936 cells, and 100 searches between random walkable cells average
82 * 0.10 ms each, so a bot changing its goal mid-tick costs a tenth of what it has.
83 */
84 export const findPath = (level: Level, from: Vec3, to: Vec3): Vec3[] =>
85 g.findPath(level.grid, level.passable, from, to);
86
87 /** Every point a player can stand in, in reading order: what a spawn picker chooses from. */
88 export function openCells(level: Level, z = 0): Vec3[] {
89 const out: Vec3[] = [];
90 for (let row = 0; row < level.grid.height; row++)
91 for (let col = 0; col < level.grid.width; col++)
92 if (walkable(level, col, row)) out.push(worldOf(level, col, row, z));
93 return out;
94 }
src/client/main.ts +7 −4
@@ -22,6 +22,8 @@ import {
2222 attachButtons, clamp, createControls, createLoop, createRenderer, type Button, type Camera,
2323 type Listens, type Mesh,
2424 } from "../engine/index.ts";
25 import { DE_FORGE } from "./map.ts";
26 import type { Level } from "./level.ts";
2527 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
2628 import { LAYERS, TEX_SIZE, materialTextures } from "./materials.ts";
2729 import { meshFor } from "./models.ts";
@@ -251,7 +253,7 @@ export function boot(host: Host): Frame {
251253 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
252254 const team = sideField.value === "t" ? "t" : "ct";
253255 const name = nameField.value.trim().slice(0, 24) || "you";
254 world = createWorld(rules, name, team, 5);
256 world = createWorld(DE_FORGE, rules, name, team, 5);
255257 matchId = host.randomUUID();
256258 posted = -1;
257259 loop.reset();
@@ -367,12 +369,13 @@ export function boot(host: Host): Frame {
367369 *
368370 * Exported because a name this misses is a crash mid-match — the first frame a
369371 * player picks that gun up — and `main.test.ts` holds it against every name
370 * `scene.ts` can place.
372 * `scene.ts` can place. The level is a value, so which map `level` is made of
373 * is an argument; this game plays de_forge, which is the default.
371374 */
372 export function gameMeshes(): Record<string, Mesh> {
375 export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> {
373376 const kinds = new Set(Object.values(WEAPONS).map((weapon) => weapon.kind));
374377 const names = ["level", "player", "bomb", ...kinds];
375 return Object.fromEntries(names.map((name) => [name, meshFor(name)]));
378 return Object.fromEntries(names.map((name) => [name, meshFor(name, level)]));
376379 }
377380
378381 /**
src/client/map.test.ts +41 −18
@@ -1,31 +1,40 @@
1 /**
2 * de_forge, pinned. Everything this file asserted when the map was a module of
3 * singletons it asserts now about `DE_FORGE`, the one value `map.ts` exports:
4 * the same cells, the same fifty boxes, the same thirty-three waypoints from T
5 * spawn to A. What a *level* is, and what is true of every one of them, is
6 * `level.test.ts`; this is about this map.
7 */
8
19 import { expect, test } from "bun:test";
2 import {
3 boxes, cellAt, colOf, findPath, GRID_H, GRID_W, rowOf, siteAt, sites, spawns, walkable, worldOf,
4 } from "./map.ts";
10 import { cellAt, colOf, findPath, rowOf, siteAt, walkable, worldOf } from "./level.ts";
11 import { DE_FORGE, GRID_H, GRID_W } from "./map.ts";
12
13 const { boxes, sites, spawns } = DE_FORGE;
514
615 test("the map has two sites and two spawns", () => {
716 expect(spawns.t.length).toBeGreaterThan(4);
817 expect(spawns.ct.length).toBeGreaterThan(4);
9 expect(siteAt(sites.A)).toBe("A");
10 expect(siteAt(sites.B)).toBe("B");
18 expect(siteAt(DE_FORGE, sites.A)).toBe("A");
19 expect(siteAt(DE_FORGE, sites.B)).toBe("B");
1120 });
1221
1322 test("the border is solid all the way round", () => {
1423 for (let c = 0; c < GRID_W; c++) {
15 expect(cellAt(c, 0)).toBe("#");
16 expect(cellAt(c, GRID_H - 1)).toBe("#");
24 expect(cellAt(DE_FORGE, c, 0)).toBe("#");
25 expect(cellAt(DE_FORGE, c, GRID_H - 1)).toBe("#");
1726 }
1827 for (let r = 0; r < GRID_H; r++) {
19 expect(cellAt(0, r)).toBe("#");
20 expect(cellAt(GRID_W - 1, r)).toBe("#");
28 expect(cellAt(DE_FORGE, 0, r)).toBe("#");
29 expect(cellAt(DE_FORGE, GRID_W - 1, r)).toBe("#");
2130 }
2231 });
2332
2433 test("grid and world coordinates round-trip", () => {
2534 for (const [c, r] of [[3, 7], [21, 21], [40, 38]] as [number, number][]) {
26 const p = worldOf(c, r);
27 expect(colOf(p[0])).toBe(c);
28 expect(rowOf(p[1])).toBe(r);
35 const p = worldOf(DE_FORGE, c, r);
36 expect(colOf(DE_FORGE, p[0])).toBe(c);
37 expect(rowOf(DE_FORGE, p[1])).toBe(r);
2938 }
3039 });
3140
@@ -33,13 +42,13 @@ test("every walkable cell is reachable from T spawn", () => {
3342 // A room nobody can reach is a bug you only find by walking into it.
3443 const start = spawns.t[0];
3544 const seen = new Set<number>();
36 const queue = [rowOf(start[1]) * GRID_W + colOf(start[0])];
45 const queue = [rowOf(DE_FORGE, start[1]) * GRID_W + colOf(DE_FORGE, start[0])];
3746 seen.add(queue[0]);
3847 while (queue.length > 0) {
3948 const i = queue.pop() as number;
4049 const c = i % GRID_W, r = Math.floor(i / GRID_W);
4150 for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
42 if (!walkable(c + dc, r + dr)) continue;
51 if (!walkable(DE_FORGE, c + dc, r + dr)) continue;
4352 const j = (r + dr) * GRID_W + c + dc;
4453 if (seen.has(j)) continue;
4554 seen.add(j);
@@ -47,7 +56,8 @@ test("every walkable cell is reachable from T spawn", () => {
4756 }
4857 }
4958 let walkables = 0;
50 for (let r = 0; r < GRID_H; r++) for (let c = 0; c < GRID_W; c++) if (walkable(c, r)) walkables++;
59 for (let r = 0; r < GRID_H; r++)
60 for (let c = 0; c < GRID_W; c++) if (walkable(DE_FORGE, c, r)) walkables++;
5161 expect(seen.size).toBe(walkables);
5262 });
5363
@@ -74,7 +84,7 @@ test("de_forge is exactly where it was, to the unit", () => {
7484 test("the route from T spawn to A is the same route it was", () => {
7585 // A number a change to the search moves: 33 waypoints from the first T spawn
7686 // cell to the centre of A, 4506 units walked against 4142 as the crow flies.
77 const path = findPath(spawns.t[0], sites.A);
87 const path = findPath(DE_FORGE, spawns.t[0], sites.A);
7888 expect(spawns.t[0]).toEqual([576, -1984, 0]);
7989 expect(path.length).toBe(33);
8090 expect(path[0]).toEqual([704, -1984, 0]);
@@ -89,7 +99,7 @@ test("the route from T spawn to A is the same route it was", () => {
8999
90100 test("a path from T spawn reaches both sites", () => {
91101 for (const site of [sites.A, sites.B]) {
92 const path = findPath(spawns.t[0], site);
102 const path = findPath(DE_FORGE, spawns.t[0], site);
93103 expect(path.length).toBeGreaterThan(4);
94104 // Consecutive waypoints are neighbouring cells, never a jump across a wall.
95105 for (let i = 1; i < path.length - 1; i++) {
@@ -100,5 +110,18 @@ test("a path from T spawn reaches both sites", () => {
100110 });
101111
102112 test("a path into solid rock comes back empty rather than lying", () => {
103 expect(findPath(spawns.t[0], worldOf(0, 0))).toEqual([]);
113 expect(findPath(DE_FORGE, spawns.t[0], worldOf(DE_FORGE, 0, 0))).toEqual([]);
114 });
115
116 test("de_forge carries its own alphabet, its numbers and its material table", () => {
117 // The predicate is the level's: nobody outside this file knows what a crate is.
118 expect(DE_FORGE.name).toBe("de_forge");
119 expect(DE_FORGE.passable(".")).toBe(true);
120 for (const solid of ["#", "x", "X"]) expect(DE_FORGE.passable(solid)).toBe(false);
121 for (const open of ["A", "B", "t", "c"]) expect(DE_FORGE.passable(open)).toBe(true);
122 expect(DE_FORGE.grid.cell).toBe(128);
123 expect(DE_FORGE.grid.cells.length).toBe(GRID_W * GRID_H);
124 expect(DE_FORGE.wallHeight).toBe(384);
125 expect(DE_FORGE.materials).toEqual({ FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 });
126 expect(DE_FORGE.siteCells).toEqual({ A: "A", B: "B" });
104127 });
src/client/map.ts +27 −38
@@ -7,6 +7,12 @@
77 * Layout is dust-shaped and original: two sites, a mid, a long right-hand
88 * corridor and a left-hand tunnel, T at the bottom of the grid, CT at the top.
99 *
10 * This file is one map and nothing else. What a level *is* — the grid, the
11 * boxes, the spawns, the sites and the questions asked of them — is
12 * `src/client/level.ts`, and `DE_FORGE` below is one value of that type, built
13 * once here and handed to `createWorld`. Nobody imports this map to find out
14 * where they are standing; they read the level off the world they are in.
15 *
1016 * Everything in here is Counter-Strike. The grid itself — the cell arithmetic,
1117 * the greedy meshing and the A* — is `src/engine/grid.ts`, which knows nothing
1218 * about this alphabet: what counts as solid or walkable goes in as a predicate.
@@ -14,6 +20,7 @@
1420
1521 import * as g from "../engine/index.ts";
1622 import type { Box, Vec3 } from "../engine/index.ts";
23 import type { Level } from "./level.ts";
1724
1825 export const CELL = 128;
1926 export const GRID_W = 44;
@@ -66,33 +73,24 @@ function build(): g.Grid<Cell> {
6673 return { width: GRID_W, height: GRID_H, cell: CELL, cells };
6774 }
6875
69 export const grid: g.Grid<Cell> = build();
76 const grid = build();
7077
7178 /** Off the edge of de_forge is rock, which is what the border is made of anyway. */
72 export const cellAt = (col: number, row: number): Cell => g.cellAt(grid, col, row) ?? "#";
79 const cellAt = (col: number, row: number): Cell => g.cellAt(grid, col, row) ?? "#";
7380
7481 /**
7582 * Solid to a walking bot: walls and both kinds of crate. This predicate is the
76 * whole of what the engine's search knows about de_forge.
83 * whole of what the engine's search knows about de_forge, and it is the one
84 * thing the level carries that is a function rather than a number.
7785 */
78 export const passable = (c: Cell): boolean => c !== "#" && c !== "x" && c !== "X";
86 const passable = (c: string): boolean => c !== "#" && c !== "x" && c !== "X";
7987
80 /** The same question in grid coordinates, which is how bots and the policy ask it. */
81 export const walkable = (col: number, row: number): boolean => passable(cellAt(col, row));
82
83 /** Grid coordinates are north-down; the world is Y-north, centred on zero. */
84 export const worldOf = (col: number, row: number, z = 0): Vec3 => g.worldOf(grid, col, row, z);
85
86 export const colOf = (x: number): number => g.colOf(grid, x);
87 export const rowOf = (y: number): number => g.rowOf(grid, y);
88 const worldOf = (col: number, row: number, z = 0): Vec3 => g.worldOf(grid, col, row, z);
8889
8990 // --- geometry -------------------------------------------------------------
9091
9192 export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const;
9293
93 /** The tracer owns the box; the map hands it out, because that is where it is written. */
94 export type { Box } from "../engine/index.ts";
95
9694 const mergeSolid = (match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] =>
9795 g.mergeSolid(grid, match, z0, z1, mat);
9896
@@ -116,7 +114,7 @@ const half = (GRID_W / 2) * CELL;
116114 const floor: Box = { min: [-half, -half, -32], max: [half, half, 0], mat: MAT.FLOOR };
117115
118116 /** Everything solid, in one list: the renderer draws it and bullets stop in it. */
119 export const boxes: Box[] = [
117 const boxes: Box[] = [
120118 floor,
121119 siteSlab("A"),
122120 siteSlab("B"),
@@ -134,31 +132,22 @@ function cellsOf(fill: Cell): Vec3[] {
134132 return out;
135133 }
136134
137 export const spawns: { t: Vec3[]; ct: Vec3[] } = { t: cellsOf("t"), ct: cellsOf("c") };
138
139 const siteCells = { A: cellsOf("A"), B: cellsOf("B") };
140 export const sites: { A: Vec3; B: Vec3 } = {
141 A: centre(siteCells.A),
142 B: centre(siteCells.B),
143 };
144
145135 function centre(list: Vec3[]): Vec3 {
146136 const s = list.reduce((a, p) => [a[0] + p[0], a[1] + p[1], 0] as Vec3, [0, 0, 0] as Vec3);
147137 return [s[0] / list.length, s[1] / list.length, 0];
148138 }
149139
150 /** Which bombsite a point stands in, if any. */
151 export function siteAt(pos: Vec3): "A" | "B" | null {
152 const c = cellAt(colOf(pos[0]), rowOf(pos[1]));
153 return c === "A" || c === "B" ? c : null;
154 }
140 const siteCells = { A: cellsOf("A"), B: cellsOf("B") };
155141
156 // --- pathfinding ----------------------------------------------------------
157
158 /**
159 * A* over the walkable cells, eight ways, no corner-cutting; the search is the
160 * engine's and what a bot may stand on is this game's answer. The grid is 1936
161 * cells, and 100 searches between random walkable cells average 0.10 ms each,
162 * so a bot changing its goal mid-tick costs a tenth of what it has.
163 */
164 export const findPath = (from: Vec3, to: Vec3): Vec3[] => g.findPath(grid, passable, from, to);
142 /** de_forge, whole: the one map this game ships, as one value of `Level`. */
143 export const DE_FORGE: Level = {
144 name: "de_forge",
145 grid,
146 passable,
147 wallHeight: WALL_HEIGHT,
148 materials: MAT,
149 boxes,
150 spawns: { t: cellsOf("t"), ct: cellsOf("c") },
151 sites: { A: centre(siteCells.A), B: centre(siteCells.B) },
152 siteCells: { A: "A", B: "B" },
153 };
src/client/models.test.ts +8 −7
@@ -17,7 +17,8 @@ import { expect, test } from "bun:test";
1717 import { readFileSync } from "node:fs";
1818 import { join } from "node:path";
1919 import type { Mesh } from "../engine/index.ts";
20 import { boxes, type Box } from "./map.ts";
20 import type { Box } from "./level.ts";
21 import { DE_FORGE } from "./map.ts";
2122 import { LAYERS } from "./materials.ts";
2223 import { bombMesh, levelMesh, meshFor, playerMesh, viewModelMesh } from "./models.ts";
2324 import { HULL } from "./physics.ts";
@@ -94,8 +95,8 @@ test("the level of no boxes is an empty mesh, not a throw", () => {
9495 test("the level boxes grow the mesh and leave it well formed", () => {
9596 const two = levelMesh([oneBox, { min: [0, 0, 0], max: [128, 128, 128], mat: 2 }]);
9697 expect(wellFormed(two)).toEqual({ vertices: 48, triangles: 24 });
97 const map = levelMesh(boxes);
98 expect(wellFormed(map)).toEqual({ vertices: boxes.length * 24, triangles: boxes.length * 12 });
98 const map = levelMesh(DE_FORGE.boxes);
99 expect(wellFormed(map)).toEqual({ vertices: DE_FORGE.boxes.length * 24, triangles: DE_FORGE.boxes.length * 12 });
99100 });
100101
101102 test("the player stands on the floor at exactly the standing hull height", () => {
@@ -153,11 +154,11 @@ test("a kind nobody carries falls back to a gun rather than throwing", () => {
153154 });
154155
155156 test("meshFor names the four recipes and nothing else", () => {
156 expect(data(meshFor("level"))).toBe(data(levelMesh(boxes)));
157 expect(data(meshFor("player"))).toBe(data(playerMesh()));
158 expect(data(meshFor("bomb"))).toBe(data(bombMesh()));
157 expect(data(meshFor("level", DE_FORGE))).toBe(data(levelMesh(DE_FORGE.boxes)));
158 expect(data(meshFor("player", DE_FORGE))).toBe(data(playerMesh()));
159 expect(data(meshFor("bomb", DE_FORGE))).toBe(data(bombMesh()));
159160 for (const kind of new Set(Object.values(WEAPONS).map((w) => w.kind))) {
160 expect(data(meshFor(kind))).toBe(data(viewModelMesh(kind)));
161 expect(data(meshFor(kind, DE_FORGE))).toBe(data(viewModelMesh(kind)));
161162 }
162163 });
163164
src/client/models.ts +6 −3
@@ -5,7 +5,7 @@
55 */
66
77 import { addBox, builder, finish, type Builder, type Mesh } from "../engine/index.ts";
8 import { boxes, type Box } from "./map.ts";
8 import type { Box, Level } from "./level.ts";
99 import { HULL } from "./physics.ts";
1010
1111 /** The level, as one buffer. Fifty-odd boxes, three hundred quads, one draw. */
@@ -78,9 +78,12 @@ export function viewModelMesh(kind: string): Mesh {
7878 * hands them to the engine's renderer, which keeps them by name and never
7979 * learns what a name is made of — that is this file, and this is the one line a
8080 * second game's model table would replace.
81 *
82 * Which level is drawn is the caller's: the map is a value now, so the geometry
83 * of `level` is whatever level the match is played on.
8184 */
82 export function meshFor(name: string): Mesh {
83 if (name === "level") return levelMesh(boxes);
85 export function meshFor(name: string, level: Level): Mesh {
86 if (name === "level") return levelMesh(level.boxes);
8487 if (name === "player") return playerMesh();
8588 if (name === "bomb") return bombMesh();
8689 return viewModelMesh(name);
src/client/observe.test.ts +2 −1
@@ -1,10 +1,11 @@
11 import { expect, test } from "bun:test";
2 import { DE_FORGE } from "./map.ts";
23 import { COMPETITIVE } from "./match.ts";
34 import { observe, OBS_SIZE } from "./observe.ts";
45 import { aimAt, bodyCentre, canSee, createWorld, type Player } from "./world.ts";
56
67 const duel = (): { world: ReturnType<typeof createWorld>; me: Player; foe: Player } => {
7 const world = createWorld(COMPETITIVE, "you", "ct", 1, 1234);
8 const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, 1234);
89 const me = world.me;
910 const foe = world.players[1] as Player;
1011 world.match.phase = "live";
src/client/observe.ts +12 −9
@@ -4,15 +4,15 @@
44 * Everything here is egocentric — distances and directions are in the bot's
55 * own frame, never the map's — so a policy trained in one corner of de_forge
66 * works in every other corner, and so the same 32 numbers describe a duel on a
7 * site and a duel in a tunnel. Free space is read off the map grid rather than
8 * traced, because a grid lookup is a multiply and a trace is three hundred box
9 * tests, and this runs once per bot per tick.
7 * site and a duel in a tunnel. Free space is read off the grid of whatever level
8 * the world is on rather than traced, because a grid lookup is a multiply and a
9 * trace is three hundred box tests, and this runs once per bot per tick.
1010 *
1111 * The layout is fixed: change it and the shipped weights mean something else,
1212 * so OBS_SIZE and the order below are part of the policy's contract.
1313 */
1414
15 import * as map from "./map.ts";
15 import { colOf, rowOf, walkable, type Level } from "./level.ts";
1616 import { angleDelta, clamp, dist, type Vec3 } from "../engine/index.ts";
1717 import { HULL } from "./physics.ts";
1818 import { inaccuracy, WEAPONS } from "./weapons.ts";
@@ -29,11 +29,11 @@ export type Memory = {
2929 const rad = Math.PI / 180;
3030
3131 /** Is the cell `d` units along (yaw + offset) somewhere a bot could stand? */
32 const clearAt = (from: Vec3, yaw: number, offset: number, d: number): number => {
32 const clearAt = (level: Level, from: Vec3, yaw: number, offset: number, d: number): number => {
3333 const a = (yaw + offset) * rad;
3434 const x = from[0] + Math.cos(a) * d;
3535 const y = from[1] + Math.sin(a) * d;
36 return map.walkable(map.colOf(x), map.rowOf(y)) ? 1 : 0;
36 return walkable(level, colOf(level, x), rowOf(level, y)) ? 1 : 0;
3737 };
3838
3939 /**
@@ -81,9 +81,12 @@ export function observe(
8181 });
8282 const magazine = Math.max(1, weapon.magazine);
8383 const eye = eyePos(bot);
84 const ahead = (clearAt(eye, bot.yaw, 0, 64) + clearAt(eye, bot.yaw, 0, 128) + clearAt(eye, bot.yaw, 0, 224)) / 3;
85 const right = (clearAt(eye, bot.yaw, -90, 96) + clearAt(eye, bot.yaw, -50, 128)) / 2;
86 const left = (clearAt(eye, bot.yaw, 90, 96) + clearAt(eye, bot.yaw, 50, 128)) / 2;
84 const level = world.level;
85 const ahead =
86 (clearAt(level, eye, bot.yaw, 0, 64) + clearAt(level, eye, bot.yaw, 0, 128) +
87 clearAt(level, eye, bot.yaw, 0, 224)) / 3;
88 const right = (clearAt(level, eye, bot.yaw, -90, 96) + clearAt(level, eye, bot.yaw, -50, 128)) / 2;
89 const left = (clearAt(level, eye, bot.yaw, 90, 96) + clearAt(level, eye, bot.yaw, 50, 128)) / 2;
8790
8891 out[at + 0] = 1; // a bias, so a dead layer still has an offset to lean on
8992 out[at + 1] = target === null ? 0 : 1;
src/client/physics.test.ts +1 −1
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2 import type { Box } from "./map.ts";
2 import type { Box } from "./level.ts";
33 import type { Vec3 } from "../engine/index.ts";
44 import { accelerate, airAccelerate, fallDamage, HULL, MOVE, move, type Body, type Wish } from "./physics.ts";
55
src/client/physics.ts +1 −1
@@ -8,7 +8,7 @@
88 * a 32×32×72 hull, an 18-unit step.
99 */
1010
11 import type { Box } from "./map.ts";
11 import type { Box } from "./level.ts";
1212 import { clamp, dot, normalize, type Vec3 } from "../engine/index.ts";
1313
1414 export const HULL = { radius: 16, stand: 72, duck: 54, eyeStand: 64, eyeDuck: 46 };
src/client/replay.test.ts +7 −6
@@ -20,6 +20,7 @@
2020 */
2121
2222 import { expect, test } from "bun:test";
23 import { DE_FORGE } from "./map.ts";
2324 import { SHORT } from "./match.ts";
2425 import {
2526 CHECKPOINT_CHARS, compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND,
@@ -73,7 +74,7 @@ const ROUND_PIN: Pin = { label: "ten-bot round", constant: "ROUND", digest: ROUN
7374 const DUEL_PIN: Pin = { label: "arena duel", constant: "DUEL", digest: DUEL, seconds: DUEL_SECONDS };
7475
7576 /** The round, exactly as the pin was taken. */
76 const roundRun = (): Run => withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), TICKS));
77 const roundRun = (): Run => withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), TICKS));
7778
7879 /**
7980 * The duel, stepped the arena's own way: the learner takes the script, the
@@ -111,9 +112,9 @@ test("a seeded duel in the training arena plays exactly as it played", () => {
111112
112113 test("the same seed is the same run, and another seed is another run", () => {
113114 const four = 4 * TICKS_PER_SECOND;
114 const a = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), four));
115 const b = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 7), four));
116 const other = withoutEntropy(() => digest(createWorld(SHORT, "me", "ct", 5, 8), four));
115 const a = withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), four));
116 const b = withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), four));
117 const other = withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 8), four));
117118 expect(a.digest).toBe(b.digest);
118119 expect(a.digest).not.toBe(other.digest);
119120 // The first four seconds of the long run are the first four seconds, too.
@@ -121,7 +122,7 @@ test("the same seed is the same run, and another seed is another run", () => {
121122 });
122123
123124 test("a move the game could not feel leaves the digest alone; one it could moves it", () => {
124 const world = createWorld(SHORT, "me", "ct", 5, 7);
125 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7);
125126 const before = snapshot(world);
126127 world.me.pos[0] += 2e-6; // a fiftieth of the quantum: libm noise, not behaviour
127128 expect(snapshot(world)).toBe(before);
@@ -132,7 +133,7 @@ test("a move the game could not feel leaves the digest alone; one it could moves
132133 test("a nudged simulation is a different run from the first second on", () => {
133134 const four = 4 * TICKS_PER_SECOND;
134135 const nudged = withoutEntropy(() => {
135 const world = createWorld(SHORT, "me", "ct", 5, 7);
136 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7);
136137 (world.players[3] as Player).pos[1] += 1; // one unit, on one bot, before the first tick
137138 return digest(world, four);
138139 });
src/client/scene.test.ts +2 −1
@@ -16,6 +16,7 @@ import { join } from "node:path";
1616 import type { Camera } from "../engine/index.ts";
1717 import { HULL } from "./physics.ts";
1818 import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts";
19 import { DE_FORGE } from "./map.ts";
1920 import { SHORT } from "./match.ts";
2021 import { createWorld, type Effect, type World } from "./world.ts";
2122
@@ -23,7 +24,7 @@ const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90
2324
2425 /** A match nobody is playing: ten players, the bomb carried, no effects. */
2526 function world(): World {
26 const w = createWorld(SHORT, "you", "ct", 5, 7);
27 const w = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 7);
2728 w.time = 100;
2829 w.effects.length = 0;
2930 return w;
src/client/world.test.ts +16 −15
@@ -1,5 +1,6 @@
11 import { expect, test } from "bun:test";
2 import { siteAt, sites } from "./map.ts";
2 import { siteAt } from "./level.ts";
3 import { DE_FORGE } from "./map.ts";
34 import { SHORT } from "./match.ts";
45 import { WEAPONS } from "./weapons.ts";
56 import { buy, createWorld, currentWeapon, NO_INPUT, shoot, startRound, update, type Input, type World } from "./world.ts";
@@ -10,7 +11,7 @@ const run = (world: World, seconds: number, input: Input = NO_INPUT): void => {
1011 };
1112
1213 test("a world starts ten players, one bomb and a freeze time", () => {
13 const world = createWorld(SHORT, "me", "ct", 5, 1);
14 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 1);
1415 expect(world.players.length).toBe(10);
1516 expect(world.players.filter((p) => p.team === "t").length).toBe(5);
1617 expect(world.players.filter((p) => p.bot).length).toBe(9);
@@ -20,7 +21,7 @@ test("a world starts ten players, one bomb and a freeze time", () => {
2021 });
2122
2223 test("nobody moves or shoots during the freeze", () => {
23 const world = createWorld(SHORT, "me", "ct", 5, 2);
24 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 2);
2425 const start = world.players.map((p) => [...p.pos]);
2526 run(world, SHORT.freezeTime - 1, { ...NO_INPUT, forward: 1, fire: true });
2627 world.players.forEach((p, i) => {
@@ -30,7 +31,7 @@ test("nobody moves or shoots during the freeze", () => {
3031 });
3132
3233 test("buying takes the money and hands over the gun, and only in the freeze", () => {
33 const world = createWorld(SHORT, "me", "ct", 5, 3);
34 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 3);
3435 world.me.money = 5000;
3536 expect(buy(world, world.me, "m4a4")).toBe(true);
3637 expect(world.me.money).toBe(5000 - WEAPONS.m4a4.price);
@@ -44,7 +45,7 @@ test("buying takes the money and hands over the gun, and only in the freeze", ()
4445 });
4546
4647 test("a shot costs a bullet and a reload puts it back", () => {
47 const world = createWorld(SHORT, "me", "ct", 5, 4);
48 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 4);
4849 world.me.money = 5000;
4950 buy(world, world.me, "m4a4");
5051 run(world, SHORT.freezeTime + 0.5);
@@ -57,7 +58,7 @@ test("a shot costs a bullet and a reload puts it back", () => {
5758 });
5859
5960 test("bots leave their spawn once the round goes live", () => {
60 const world = createWorld(SHORT, "me", "ct", 5, 5);
61 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 5);
6162 const bot = world.players.find((p) => p.bot && p.team === "t");
6263 const start = [...bot.pos];
6364 run(world, SHORT.freezeTime + 6);
@@ -65,7 +66,7 @@ test("bots leave their spawn once the round goes live", () => {
6566 });
6667
6768 test("a round is played out: bots meet, shoot, and one side wins it", () => {
68 const world = createWorld(SHORT, "me", "ct", 5, 7);
69 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7);
6970 world.me.alive = false; // stand back and let the bots have it
7071 run(world, SHORT.freezeTime + SHORT.roundTime + 1);
7172 expect(world.match.round >= 1).toBe(true);
@@ -74,13 +75,13 @@ test("a round is played out: bots meet, shoot, and one side wins it", () => {
7475 });
7576
7677 test("the terrorists plant, and a planted bomb keeps the round alive past the clock", () => {
77 const world = createWorld(SHORT, "me", "t", 5, 11);
78 const world = createWorld(DE_FORGE, SHORT, "me", "t", 5, 11);
7879 run(world, SHORT.freezeTime + 0.2);
7980 // Put the person on the site holding the bomb and press use.
8081 for (const p of world.players) p.hasBomb = false;
8182 world.me.hasBomb = true;
82 world.me.pos = [sites.A[0], sites.A[1], 2];
83 expect(siteAt(world.me.pos)).toBe("A");
83 world.me.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2];
84 expect(siteAt(world.level, world.me.pos)).toBe("A");
8485 run(world, SHORT.plantTime + 0.3, { ...NO_INPUT, use: true });
8586 expect(world.bomb.state).toBe("planted");
8687 expect(world.match.phase).toBe("planted");
@@ -88,13 +89,13 @@ test("the terrorists plant, and a planted bomb keeps the round alive past the cl
8889 });
8990
9091 test("a defusing CT wins the round, and the money follows the rules", () => {
91 const world = createWorld(SHORT, "me", "ct", 5, 13);
92 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 13);
9293 run(world, SHORT.freezeTime + 0.2);
93 world.bomb = { state: "planted", pos: [sites.B[0], sites.B[1], 2], site: "B", plantProgress: 0, defuseProgress: 0, defuser: null };
94 world.bomb = { state: "planted", pos: [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2], site: "B", plantProgress: 0, defuseProgress: 0, defuser: null };
9495 world.bombPlanted = true;
9596 world.match.phase = "planted";
9697 world.match.timer = 30;
97 world.me.pos = [sites.B[0], sites.B[1], 2];
98 world.me.pos = [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2];
9899 world.me.defuseKit = true;
99100 const before = world.me.money;
100101 run(world, SHORT.defuseTimeWithKit + 0.3, { ...NO_INPUT, use: true });
@@ -105,7 +106,7 @@ test("a defusing CT wins the round, and the money follows the rules", () => {
105106 });
106107
107108 test("a new round puts everyone back with full health and a full magazine", () => {
108 const world = createWorld(SHORT, "me", "ct", 5, 17);
109 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 17);
109110 world.me.money = 5000;
110111 buy(world, world.me, "m4a4");
111112 world.me.ammo.m4a4.mag = 3;
@@ -119,7 +120,7 @@ test("a new round puts everyone back with full health and a full magazine", () =
119120 });
120121
121122 test("a match plays itself to the end inside its own time", () => {
122 const world = createWorld({ ...SHORT, winScore: 2, freezeTime: 1, roundTime: 40, endDelay: 1 }, "me", "ct", 5, 23);
123 const world = createWorld(DE_FORGE, { ...SHORT, winScore: 2, freezeTime: 1, roundTime: 40, endDelay: 1 }, "me", "ct", 5, 23);
123124 world.me.alive = false;
124125 for (let t = 0; t < 60 * 8 && world.match.phase !== "match_over"; t += TICK) update(world, TICK, NO_INPUT);
125126 expect(world.match.phase).toBe("match_over");
src/client/world.ts +22 −9
@@ -8,7 +8,7 @@
88 */
99
1010 import { applyHit, traceBullet, type Actor } from "./combat.ts";
11 import * as map from "./map.ts";
11 import { siteAt, type Level } from "./level.ts";
1212 import { angleVectors, angleDelta, clamp, dist, normalize, rng, sub, visible, type Vec3 } from "../engine/index.ts";
1313 import * as rules from "./match.ts";
1414 import { eyeHeight, hullHeight, move, type Body, type Wish, MOVE } from "./physics.ts";
@@ -95,7 +95,13 @@ export type World = {
9595 feed: FeedEntry[];
9696 messages: { at: number; text: string }[];
9797 random: () => number;
98 boxes: typeof map.boxes;
98 /**
99 * The map this round is played on, whole. Everything that needs to know where
100 * it is standing — the bots, the bomb, the bullets, the frame — reads it from
101 * here rather than importing one, which is what lets a test play a four-cell
102 * room and what a second map will need.
103 */
104 level: Level;
99105 bombPlanted: boolean; // in this round, for the economy
100106 pendingBuy: boolean;
101107 };
@@ -124,7 +130,13 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
124130 };
125131 }
126132
133 /**
134 * A world on a level. The level comes first because it is the ground everything
135 * else stands on: hand it de_forge and you get the game, hand it a box room and
136 * you get the same game in a box room.
137 */
127138 export function createWorld(
139 level: Level,
128140 ruleSet: rules.Rules,
129141 playerName: string,
130142 playerTeam: Team = "ct",
@@ -150,7 +162,7 @@ export function createWorld(
150162 match, players, me, time: 0,
151163 bomb: { state: "carried", pos: [0, 0, 0], site: null, plantProgress: 0, defuseProgress: 0, defuser: null },
152164 effects: [], feed: [], messages: [],
153 random, boxes: map.boxes, bombPlanted: false, pendingBuy: false,
165 random, level, bombPlanted: false, pendingBuy: false,
154166 };
155167 startRound(world);
156168 return world;
@@ -170,10 +182,11 @@ function say(world: World, text: string): void {
170182 // --- rounds ---------------------------------------------------------------
171183
172184 export function startRound(world: World): void {
173 const spots = { t: [...map.spawns.t], ct: [...map.spawns.ct] };
185 const spawns = world.level.spawns;
186 const spots = { t: [...spawns.t], ct: [...spawns.ct] };
174187 for (const p of world.players) {
175188 const list = spots[p.team];
176 const at = list.splice(Math.floor(world.random() * list.length), 1)[0] ?? map.spawns[p.team][0];
189 const at = list.splice(Math.floor(world.random() * list.length), 1)[0] ?? spawns[p.team][0];
177190 p.pos = [at[0], at[1], 2];
178191 p.vel = [0, 0, 0];
179192 p.health = 100;
@@ -338,7 +351,7 @@ export function shoot(world: World, p: Player): boolean {
338351 const range = w.kind === "knife" ? 64 : RANGE;
339352 const dir = angleVectors(yaw, pitch).forward;
340353 const to: Vec3 = [from[0] + dir[0] * range, from[1] + dir[1] * range, from[2] + dir[2] * range];
341 const hit = traceBullet(from, to, world.boxes, world.players, p.id);
354 const hit = traceBullet(from, to, world.level.boxes, world.players, p.id);
342355 world.effects.push({ kind: "shot", at: world.time, pos: from, weapon: w.id, mine: p === world.me });
343356 if (w.kind !== "knife") world.effects.push({ kind: "tracer", at: world.time, from, to: hit.kind === "none" ? to : hit.point });
344357
@@ -381,7 +394,7 @@ function updateBomb(world: World, p: Player, input: Input, dt: number): void {
381394 const bomb = world.bomb;
382395 if (bomb.state === "carried" && p.hasBomb && world.match.phase === "live") {
383396 bomb.pos = [p.pos[0], p.pos[1], p.pos[2]];
384 const site = map.siteAt(p.pos);
397 const site = siteAt(world.level, p.pos);
385398 if (input.use && site !== null && p.onGround) {
386399 bomb.plantProgress += dt;
387400 if (bomb.plantProgress >= world.match.rules.plantTime) {
@@ -455,7 +468,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
455468 walk: input.walk || p.scoped,
456469 maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed),
457470 };
458 move(p, wish, dt, world.boxes);
471 move(p, wish, dt, world.level.boxes);
459472 finishReload(world, p);
460473 if (input.reload) reload(world, p);
461474 if (w.zoom !== undefined && input.scope !== p.scoped && world.time > p.nextShotAt - 0.05) p.scoped = input.scope;
@@ -519,7 +532,7 @@ export function update(world: World, dt: number, input: Input): void {
519532
520533 /** Is `target` visible to `from`, eye to eye? Used by bots and by the HUD. */
521534 export function canSee(world: World, from: Player, target: Player): boolean {
522 return visible(eyePos(from), eyePos(target), world.boxes);
535 return visible(eyePos(from), eyePos(target), world.level.boxes);
523536 }
524537
525538 export const facing = (p: Player, target: Vec3): number => {
src/engine/GUIDE.md +4 −2
@@ -178,8 +178,10 @@ cell-to-world arithmetic, `mergeSolid` greedily merges runs of matching cells
178178 into as few boxes as the shape allows, and `findPath` is A* with no
179179 corner-cutting. Which cells are solid and which are walkable arrive as
180180 predicates, so the engine never learns what a `"#"` means. The example has no
181 grid — one box is not a map — so read `src/client/map.ts`, which lays de_forge's
182 cell alphabet over this and hands back the level's boxes and its paths.
181 grid — one box is not a map — so read `src/client/level.ts`, which is what a
182 level is to the shooter (a grid, a predicate, boxes, spawns and sites, as one
183 value), and `src/client/map.ts`, which lays de_forge's cell alphabet over this
184 and is one of them.
183185
184186 ### mesh
185187
src/frames.test.ts +2 −1
@@ -21,6 +21,7 @@
2121 import { expect, test } from "bun:test";
2222 import type { Camera } from "./engine/index.ts";
2323 import { INSTANCE_CAPACITY, LINE_CAPACITY, buildScene, createScene } from "./client/scene.ts";
24 import { DE_FORGE } from "./client/map.ts";
2425 import { SHORT } from "./client/match.ts";
2526 import { createWorld, type Player, type World } from "./client/world.ts";
2627 import {
@@ -35,7 +36,7 @@ const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90
3536
3637 /** A match with `crowd` extra bodies standing in it, all of them drawable. */
3738 function crowded(crowd: number): World {
38 const world = createWorld(SHORT, "you", "ct", 5, 7);
39 const world = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 7);
3940 world.time = 100;
4041 world.effects.length = 0;
4142 const bot = world.players.find((p) => p !== world.me) as Player;
src/games.ts +9
@@ -69,6 +69,15 @@ export type Game = {
6969 * it, because the container ships whole to a game that uses any of it. Measured
7070 * 2026-09-18 after #78: app 71,186 raw / 33,125 gzipped, range 27,234 raw /
7171 * 11,655 gzipped. Both fit today's ceilings, so neither moved.
72 * #95 made the map a value — `src/client/level.ts` says what a level is and
73 * `DE_FORGE` is one, handed to `createWorld` instead of imported — and the app
74 * grew 460 raw / 143 gzipped bytes for it: the level's own functions, and a
75 * level argument on the calls that used to reach for a singleton. Measured
76 * 2026-09-18 after #95 merged with #103: app 71,646 raw / 33,272 gzipped,
77 * range unchanged at 27,234 raw / 11,656 gzipped — #103's headless page is a
78 * test helper and ships in neither bundle, so the app is the same size to the
79 * byte and differs only in which short names the minifier handed out. Both
80 * fit, so neither ceiling moved.
7281 */
7382 export const GAMES: readonly Game[] = [
7483 {
src/rl/arena.ts +24 −13
@@ -14,7 +14,8 @@
1414
1515 import { decode, toInput, type Intent } from "../client/action.ts";
1616 import { makeBrain, botThink } from "../client/bots.ts";
17 import * as map from "../client/map.ts";
17 import { openCells, type Level } from "../client/level.ts";
18 import { DE_FORGE } from "../client/map.ts";
1819 import { angleDelta, clamp, dist, rng, type Vec3 } from "../engine/index.ts";
1920 import { observe, OBS_SIZE, type Memory } from "../client/observe.ts";
2021 import type { Input, Player, World } from "../client/world.ts";
@@ -33,16 +34,24 @@ export const EPISODE_TICKS = 384; // six seconds, which is a long fight
3334 */
3435 export const SKIP = 4;
3536
36 /** Every cell a player can stand in, once, so a reset is two array lookups. */
37 const OPEN: Vec3[] = (() => {
38 const out: Vec3[] = [];
39 for (let r = 0; r < map.GRID_H; r++)
40 for (let c = 0; c < map.GRID_W; c++) if (map.walkable(c, r)) out.push(map.worldOf(c, r, 2));
41 return out;
42 })();
37 /**
38 * Every cell a player can stand in on a level, once, so a reset is two array
39 * lookups. Kept per level rather than per arena: a training run makes sixty-four
40 * arenas on the same map and they can share one list, because nothing writes it.
41 */
42 const OPEN = new WeakMap<Level, Vec3[]>();
43 const openOf = (level: Level): Vec3[] => {
44 const known = OPEN.get(level);
45 if (known !== undefined) return known;
46 const made = openCells(level, 2);
47 OPEN.set(level, made);
48 return made;
49 };
4350
4451 export type Arena = {
4552 world: World;
53 /** Where a fresh duel may put the two of them: every walkable cell of the level. */
54 open: Vec3[];
4655 learner: Player;
4756 foe: Player;
4857 memory: Memory;
@@ -79,14 +88,15 @@ export function policyFoe(think: (arena: Arena) => Intent): (arena: Arena, dt: n
7988 };
8089 }
8190
82 export function createArena(seed: number, opponent = scriptedFoe): Arena {
91 export function createArena(seed: number, opponent = scriptedFoe, level: Level = DE_FORGE): Arena {
8392 // Two players, one a side: createWorld with a team size of one gives exactly
84 // that — the person's slot and one bot — and we drive both ourselves.
85 const world = createWorld(SHORT, "learner", "ct", 1, seed);
93 // that — the person's slot and one bot — and we drive both ourselves. The
94 // level is de_forge because that is the game the policy has to be good at.
95 const world = createWorld(level, SHORT, "learner", "ct", 1, seed);
8696 const learner = world.me;
8797 const foe = world.players[1] as Player;
8898 const arena: Arena = {
89 world, learner, foe,
99 world, open: openOf(level), learner, foe,
90100 memory: { sinceSeen: 9 },
91101 foeMemory: { sinceSeen: 9 },
92102 ticks: 0,
@@ -167,7 +177,8 @@ export function reset(arena: Arena): void {
167177 // Two spots the right distance apart, preferring a pair that can or cannot
168178 // see each other as the coin said. The distance is not negotiable and the
169179 // line of sight is: a duel at the wrong range teaches the wrong weapon.
170 const cell = (): Vec3 => OPEN[Math.floor(random() * OPEN.length)] as Vec3;
180 const open = arena.open;
181 const cell = (): Vec3 => open[Math.floor(random() * open.length)] as Vec3;
171182 let a = cell(), b = cell();
172183 for (let tries = 0; tries < 400; tries++) {
173184 const d = dist(a, b);
static/app.98e3474985.js added +58

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

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

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs