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

forked from trevato/cs

Every generated map looks like its own place: a palette drawn from the seed, out of the materials already in the atlas #239

Merged opened this

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

Merged

vesper/cs-engine:vesper/151-every-generated-map-looks-like-its-own-p → main 1 commit fork

  • Merged. vesper/151-every-generated-map-looks-like-its-own-p was merged into main as 01414fc.

Review

Sign in to review this change.
15 files changed +494 −136
README.md +19
@@ -208,6 +208,21 @@ down and hand to somebody who has never played the round it happened in.
208208 - **Open it.** `/?map=gen_14u5j6g` preselects that map. `?map=` is the shooter's
209209 parameter and no other page reads it.
210210
211 A name is a look as well as a layout. The atlas holds nine materials, painted
212 once at boot; `src/client/looks.ts` writes down eight named sets of them —
213 `forge`, `foundry`, `quarry`, `depot`, `bunker`, `kiln`, `plaza`, `hangar` — and
214 a generated map picks one from the same seed that carved it, so `gen_9ix` is a
215 metal-floored hangar with red-painted sites on every machine and `gen_5` is a
216 quarry. Nothing is repainted and nothing more is downloaded: the layers were in
217 the texture already, and what changed is which one a box's vertices name. The
218 sets are named rather than rolled per surface because nine materials do not
219 combine freely — `looks.test.ts` measures each set against the actual shaders
220 and holds it to the distances a room needs to read: cover off its floor and off
221 its wall, a bombsite off the floor it sits on, and a player legible against all
222 three. de_forge is the `forge` set, which is the numbers this game shipped with,
223 unchanged. The line beside the menu's map preview ends with the look's name, so
224 a player reading rather than looking gets it too.
225
211226 A name is an input and it is checked twice against one definition:
212227 `src/mapname.ts` says what a name is — `gen_` and base36, and the test is the
213228 round trip, so `gen_0ya` and `gen_zzzzzzzz` are typos rather than maps —
@@ -596,6 +611,10 @@ never checked itself.
596611 rooms, corridors, two sites, two spawns and cover, every cell stamped twice
597612 so the map is its own opponent's map turned half a circle, and a gate that
598613 refuses a layout a round cannot be played on),
614 `looks` (what a map is painted in: eight named sets of the nine materials
615 already in the atlas — floor, wall, cover and sites — and the seed-to-set
616 function a generated map chooses one with; `forge` is de_forge's own and the
617 default, and no set is a new layer, a repaint or a byte on the wire),
599618 `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL`,
600619 `NADE_MOVE`, `NADE_HULL` and `fallDamage`, handed to the engine's mover on
601620 every call — the solver
src/client/cells.ts +28 −13
@@ -18,6 +18,7 @@
1818 import * as g from "../engine/index.ts";
1919 import type { Box, Grid, Vec3 } from "../engine/index.ts";
2020 import type { Level } from "./level.ts";
21 import { FORGE, type Look } from "./looks.ts";
2122
2223 /** World units along one side of a cell: Source units, like everything here. */
2324 export const CELL = 128;
@@ -31,8 +32,14 @@ export const WALL_HEIGHT = 384;
3132 */
3233 export type Cell = "#" | "." | "x" | "X" | "A" | "B" | "t" | "c";
3334
34 /** The material ids this game's boxes are tagged with, by name. */
35 export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const;
35 /**
36 * The material ids this game's boxes are tagged with, by name — de_forge's own,
37 * and what a level is dressed in when it asks for nothing else. Which numbers
38 * these are is `looks.ts`'s business now: a generated map hands `levelOf` a
39 * different set from the same seed that carved it, and everything below is
40 * written in the names rather than in the numbers, so it never has to know.
41 */
42 export const MAT = FORGE.materials;
3643
3744 /**
3845 * Solid to a walking bot: walls and both kinds of crate. This predicate is the
@@ -41,8 +48,8 @@ export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const;
4148 */
4249 export const passable = (c: string): boolean => c !== "#" && c !== "x" && c !== "X";
4350
44 /** The bounding slab of every cell paved with one letter, two units thick. */
45 function siteSlab(grid: Grid<Cell>, fill: Cell): Box {
51 /** The bounding slab of every cell paved with one letter, two units thick, in the level's own site material. */
52 function siteSlab(grid: Grid<Cell>, fill: Cell, mat: number): Box {
4653 let c0 = grid.width, r0 = grid.height, c1 = 0, r1 = 0;
4754 for (let r = 0; r < grid.height; r++)
4855 for (let c = 0; c < grid.width; c++)
@@ -55,7 +62,7 @@ function siteSlab(grid: Grid<Cell>, fill: Cell): Box {
5562 return {
5663 min: [a[0] - CELL / 2, b[1] - CELL / 2, 0],
5764 max: [b[0] + CELL / 2, a[1] + CELL / 2, 2],
58 mat: MAT.SITE,
65 mat,
5966 };
6067 }
6168
@@ -82,11 +89,18 @@ function centre(list: Vec3[]): Vec3 {
8289 * where bots walk, where the bomb can be planted — so a map can never disagree
8390 * with itself. A grid with no `A`, no `B`, or no cell for a side to spawn on is
8491 * not a map anybody can play and says so here rather than at the first round.
92 *
93 * `look` is what it is painted in: one of the named sets in `looks.ts`, de_forge's
94 * own unless a caller says otherwise. It touches the boxes' material tags and
95 * nothing else — not a cell, not a spawn, not a site, not one coordinate — so
96 * the same page of letters in two looks is the same map twice over, and a
97 * generated one can be a different place for free.
8598 */
86 export function levelOf(name: string, grid: Grid<Cell>): Level {
99 export function levelOf(name: string, grid: Grid<Cell>, look: Look = FORGE): Level {
87100 const halfX = (grid.width / 2) * CELL;
88101 const halfY = (grid.height / 2) * CELL;
89 const floor: Box = { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: MAT.FLOOR };
102 const mat = look.materials;
103 const floor: Box = { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: mat.FLOOR };
90104 const merge = (match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] =>
91105 g.mergeSolid(grid, match, z0, z1, mat);
92106 const spawns = { t: cellsOf(grid, "t"), ct: cellsOf(grid, "c") };
@@ -97,15 +111,16 @@ export function levelOf(name: string, grid: Grid<Cell>): Level {
97111 grid,
98112 passable,
99113 wallHeight: WALL_HEIGHT,
100 materials: MAT,
114 look: look.name,
115 materials: mat,
101116 // Everything solid, in one list: the renderer draws it and bullets stop in it.
102117 boxes: [
103118 floor,
104 siteSlab(grid, "A"),
105 siteSlab(grid, "B"),
106 ...merge((c) => c === "#", 0, WALL_HEIGHT, MAT.WALL),
107 ...merge((c) => c === "x", 0, 64, MAT.CRATE),
108 ...merge((c) => c === "X", 0, 128, MAT.CRATE),
119 siteSlab(grid, "A", mat.SITE),
120 siteSlab(grid, "B", mat.SITE),
121 ...merge((c) => c === "#", 0, WALL_HEIGHT, mat.WALL),
122 ...merge((c) => c === "x", 0, 64, mat.CRATE),
123 ...merge((c) => c === "X", 0, 128, mat.CRATE),
109124 ],
110125 spawns,
111126 sites: { A: centre(cellsOf(grid, "A")), B: centre(cellsOf(grid, "B")) },
src/client/generate.test.ts +108 −4
@@ -13,9 +13,12 @@
1313
1414 import { expect, test } from "bun:test";
1515 import { botThink } from "./bots.ts";
16 import { MAT, type Cell } from "./cells.ts";
16 import { levelOf, type Cell } from "./cells.ts";
1717 import { flaws, generateGrid, generateLevel, nameOf, page, seedOf, SIZE } from "./generate.ts";
18 import { colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts";
18 import { colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Box, type Level } from "./level.ts";
19 import { LOOKS, lookFor, type Look } from "./looks.ts";
20 import { LAYERS } from "./materials.ts";
21 import { levelMesh } from "./models.ts";
1922 import { SHORT } from "./match.ts";
2023 import { digest, withoutEntropy } from "./replay.ts";
2124 import { applyInput, createWorld, type Player } from "./world.ts";
@@ -98,6 +101,103 @@ test("a map you can name is the same map on every machine, cell for cell", () =>
98101 expect(rowsOf(generateLevel(seed))).toEqual(rows);
99102 });
100103
104 /**
105 * The mesh `gen_14u5j6g` is drawn from, folded vertex by vertex: position, uv
106 * and — the point of #151 — the atlas layer each one is painted from. The map
107 * digest above pins the letters; this pins what a player actually sees, which
108 * is the letters turned into boxes *and* the look those boxes wear.
109 *
110 * It moves when the generator's shape moves, when the mesher's does, or when
111 * this seed is dressed in another look. All three are allowed and all three are
112 * a line in the pull with these constants pasted in from the failure.
113 */
114 const NAMED_LOOK = "plaza";
115 const NAMED_MESH = "16133a4f413aa063f8c76efced2dddfc9e00f6502d089ed0ccbcb3f15b9c641c";
116 const NAMED_MESH_BANDS: readonly string[] = [
117 "0e887e4b6e6f", "4f8f475e35b3", "d970b6af42c6", "8108bf5a61ad",
118 "9eb216318777", "c5a0dd4cf4b4", "4b75c09568eb", "33f24951e64b",
119 "d5ba59402c79", "d7983405773e", "0e370c4f60ef",
120 ];
121
122 test("a named map is the same geometry, painted from the same layers, everywhere", () => {
123 const seed = seedOf(NAMED);
124 if (seed === null) throw new Error(`${NAMED} is not a map name, and it is the one this test is about`);
125 const level = generateLevel(seed);
126 expect(level.look).toBe(NAMED_LOOK);
127
128 const mesh = levelMesh(level.boxes);
129 let at = 0;
130 const run = fold({
131 ticks: mesh.layer.length,
132 step: (vertex) => void (at = vertex),
133 line: () =>
134 [
135 mesh.position[at * 3], mesh.position[at * 3 + 1], mesh.position[at * 3 + 2],
136 mesh.uv[at * 2], mesh.uv[at * 2 + 1], mesh.layer[at],
137 ].join(" "),
138 every: 120,
139 });
140 const why = compare(
141 run,
142 { label: `mesh of ${NAMED}`, constant: "NAMED_MESH", digest: NAMED_MESH, checkpoints: NAMED_MESH_BANDS },
143 {
144 file: "src/client/generate.test.ts",
145 list: "NAMED_MESH_BANDS",
146 hashes: "every vertex of the level mesh: its position, its uv and its atlas layer",
147 every: 120,
148 unit: "band",
149 },
150 );
151 if (why !== null) throw new Error(why);
152 });
153
154 /**
155 * A look is the seed's, the same on every machine, and a run of seeds does not
156 * walk the table in order — the whole reason `lookFor` stirs its seed instead of
157 * taking it modulo eight.
158 *
159 * The fraction measured here, and what it is worth: over seeds 0..99 all eight
160 * looks turn up, 88 of the 99 consecutive pairs differ, and 87.5% of all 4,950
161 * pairs do — which is the 7/8 a uniform draw over eight gives. The floors below
162 * are well under those so a tweak to the stirring is not a failing test, but a
163 * generator that dressed every map the same, or that dealt the looks in order,
164 * fails both of them.
165 */
166 test("a seed is a look, and two seeds are usually two looks", () => {
167 expect(generateLevel(7).look).toBe(generateLevel(7).look);
168 expect(lookFor(7).name).toBe(generateLevel(7).look);
169
170 const looks = Array.from({ length: 100 }, (_, seed) => generateLevel(seed).look);
171 expect(new Set(looks).size).toBe(LOOKS.length);
172 let differ = 0;
173 for (let i = 1; i < looks.length; i++) if (looks[i] !== looks[i - 1]) differ++;
174 expect(differ).toBeGreaterThanOrEqual(75); // 88 of 99 measured
175 let pairs = 0;
176 for (let i = 0; i < looks.length; i++)
177 for (let j = i + 1; j < looks.length; j++) if (looks[i] !== looks[j]) pairs++;
178 expect(pairs / ((looks.length * (looks.length - 1)) / 2)).toBeGreaterThan(0.8); // 0.875 measured
179 });
180
181 test("a look paints a map and moves nothing else about it", () => {
182 // Two levels off one seed, dressed differently by hand: same cells, same
183 // spawns, same sites, same boxes to the unit — and different layers on them.
184 const grid = generateGrid(31);
185 const a = levelOf("gen_31", grid, LOOKS[0] as Look);
186 const b = levelOf("gen_31", grid, LOOKS[4] as Look);
187 expect(a.grid.cells).toEqual(b.grid.cells);
188 expect(a.spawns).toEqual(b.spawns);
189 expect(a.sites).toEqual(b.sites);
190 expect(a.boxes.length).toBe(b.boxes.length);
191 for (let i = 0; i < a.boxes.length; i++) {
192 const one = a.boxes[i] as Box, two = b.boxes[i] as Box;
193 expect(one.min).toEqual(two.min);
194 expect(one.max).toEqual(two.max);
195 }
196 expect(a.boxes.map((box) => box.mat)).not.toEqual(b.boxes.map((box) => box.mat));
197 expect(a.look).toBe("forge");
198 expect(b.look).toBe("bunker");
199 });
200
101201 test("a map is written on a page of letters, one string a row", () => {
102202 const grid = generateGrid(3);
103203 const rows = page(grid);
@@ -177,17 +277,21 @@ test("two hundred seeds all come out playable, first time", () => {
177277 });
178278
179279 test("no generated map leaks outside its own grid, or paints a surface this game has not got", () => {
180 const known = new Set<number>(Object.values(MAT));
181280 for (let seed = 500; seed < 540; seed++) {
182281 const level = generateLevel(seed);
282 // Since #151 a map is dressed from the seed, so the surfaces it may paint
283 // are its own look's and not de_forge's — but they are still nine materials
284 // already in the atlas, and never a tenth: a new layer is a repaint and a
285 // bundle nobody pays for.
286 const known = new Set<number>(Object.values(level.materials));
183287 const edge = [(level.grid.width / 2) * level.grid.cell, (level.grid.height / 2) * level.grid.cell];
184288 for (const box of level.boxes) {
185289 for (const axis of [0, 1]) {
186290 expect(box.min[axis as 0 | 1]).toBeGreaterThanOrEqual(-(edge[axis] as number));
187291 expect(box.max[axis as 0 | 1]).toBeLessThanOrEqual(edge[axis] as number);
188292 }
189 // No new material means no new atlas layer, which is a bundle nobody pays for.
190293 expect(known.has(box.mat)).toBe(true);
294 expect(box.mat).toBeLessThan(LAYERS);
191295 }
192296 }
193297 });
src/client/generate.ts +10 −1
@@ -31,6 +31,7 @@ import { region, rng, type Grid, type Vec3 } from "../engine/index.ts";
3131 import { nameOf, seedOf } from "../mapname.ts";
3232 import { CELL, levelOf, type Cell } from "./cells.ts";
3333 import { colOf, findPath, rowOf, type Level } from "./level.ts";
34 import { lookFor } from "./looks.ts";
3435
3536 /**
3637 * What a map is called and what a name means, both from `src/mapname.ts` and
@@ -319,12 +320,20 @@ export function flaws(level: Level): string[] {
319320 * attempt, so the loop is insurance rather than a plan, and a seed that somehow
320321 * spends all eight says so loudly instead of handing back a map nobody can
321322 * finish a round on.
323 *
324 * The look is the seed's too, out of `looks.ts`: the floor, the walls, the
325 * cover and the sites are painted from one of eight named sets of the nine
326 * materials already in the atlas, so a name is a layout *and* a place. It is
327 * taken from the seed a person wrote down and not from `at`, which a retry
328 * moves — the name promises the look, and a map that needed a second attempt
329 * is still the map that name asked for.
322330 */
323331 export function generateLevel(seed: number, size = SIZE): Level {
324332 let at = seed >>> 0;
325333 let last: string[] = [];
334 const look = lookFor(seed);
326335 for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
327 const level = levelOf(nameOf(seed), generateGrid(at, size));
336 const level = levelOf(nameOf(seed), generateGrid(at, size), look);
328337 last = flaws(level);
329338 if (last.length === 0) return level;
330339 at = (Math.imul(at, 1664525) + 1013904223) >>> 0;
src/client/level.test.ts +1
@@ -74,6 +74,7 @@ const ROOM: Level = {
7474 grid: GRID,
7575 passable,
7676 wallHeight: WALL,
77 look: "room",
7778 materials: { FLOOR: 0, WALL: 1 },
7879 boxes: [
7980 { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: 0 },
src/client/level.ts +14 −1
@@ -33,7 +33,20 @@ export type Level = {
3333 readonly passable: (cell: Cell) => boolean;
3434 /** How tall a wall stands, in world units. */
3535 readonly wallHeight: number;
36 /** The material ids this level's boxes are tagged with, by name. */
36 /**
37 * What this level is dressed in: the name of the set of materials below, out
38 * of `looks.ts`. A level carries it rather than a global holding it, so two
39 * levels in one process — the menu's preview and the match's map — can wear
40 * different ones, and a name that says "gen_14u5j6g looks like this" is the
41 * level's own word rather than a lookup somebody has to repeat.
42 */
43 readonly look: string;
44 /**
45 * The material ids this level's boxes are tagged with, by name: which layer
46 * of the atlas its floor, its walls, its cover and its sites are painted
47 * from. Every box in `boxes` is already tagged with one of these — this is
48 * the reader's index to them, not a second source of truth.
49 */
3750 readonly materials: Readonly<Record<string, number>>;
3851 /** Everything solid, in one list: the renderer draws it and bullets stop in it. */
3952 readonly boxes: Box[];
src/client/looks.test.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * The looks, measured rather than admired.
3 *
4 * A named set of materials is only worth having if a room dressed in it still
5 * reads: cover has to stand off the floor and off the wall from across the
6 * room, a bombsite has to read as painted ground rather than as more floor, and
7 * a player has to be a shape against all three. Nine materials do not combine
8 * freely, so every set in `looks.ts` is held to those distances here — against
9 * the actual shaders in `materials.ts`, sampled, not against a colour somebody
10 * wrote in a comment.
11 *
12 * The distance is a crude perceptual one: each material's mean colour over a
13 * 32×32 sample of its shader, differenced with green weighted heaviest and blue
14 * lightest, which is the order the eye weighs them in. It is not a colour
15 * science; it is enough to tell "sand under wooden crates" (90) from "sand
16 * under a sand crate" (0), which is the whole question being asked.
17 */
18
19 import { expect, test } from "bun:test";
20 import { FORGE, LOOKS, lookFor, lookNamed, SKIN, FATIGUES, GEAR, type Look } from "./looks.ts";
21 import { LAYERS, MATERIALS } from "./materials.ts";
22 import { MAT } from "./cells.ts";
23
24 /** Each material's mean colour, its shader sampled over the square it is painted into. */
25 const MEAN: readonly [number, number, number][] = MATERIALS.map((shader) => {
26 const N = 32;
27 let r = 0, g = 0, b = 0;
28 for (let i = 0; i < N; i++)
29 for (let j = 0; j < N; j++) {
30 const c = shader((i + 0.5) / N, (j + 0.5) / N);
31 r += c[0]; g += c[1]; b += c[2];
32 }
33 return [r / (N * N), g / (N * N), b / (N * N)];
34 });
35
36 /** How far apart two materials look, 0 being the same surface. */
37 function apart(one: number, two: number): number {
38 const a = MEAN[one] as [number, number, number], b = MEAN[two] as [number, number, number];
39 return Math.hypot((a[0] - b[0]) * 0.6, (a[1] - b[1]) * 1.0, (a[2] - b[2]) * 0.4);
40 }
41
42 /**
43 * The bars, and where they come from: de_forge is the map this game was tuned
44 * on, and its own numbers are site/floor 70, cover/floor 90, cover/wall 63 and
45 * floor/wall 31. A look may be quieter than de_forge but not by much — the
46 * floors below are under every one of the eight and over every pairing that
47 * failed to read when it was looked at.
48 */
49 const SITE_OFF_FLOOR = 45;
50 const COVER_OFF_FLOOR = 45;
51 const COVER_OFF_WALL = 45;
52 const FLOOR_OFF_WALL = 30;
53
54 /** What a player is made of: the three materials `models.ts` builds one out of. */
55 const PLAYER = [SKIN, FATIGUES, GEAR];
56
57 test("every look is a set of materials this game has already painted", () => {
58 expect(LOOKS.length).toBeGreaterThan(1);
59 const names = LOOKS.map((look) => look.name);
60 expect(new Set(names).size).toBe(names.length);
61 for (const look of LOOKS) {
62 expect(look.name).toMatch(/^[a-z]+$/);
63 const roles = Object.keys(look.materials).sort();
64 expect(roles).toEqual(["CRATE", "FLOOR", "METAL", "SITE", "WALL"]);
65 for (const [role, mat] of Object.entries(look.materials)) {
66 expect(Number.isInteger(mat)).toBe(true);
67 expect(mat, `${look.name}.${role}`).toBeGreaterThanOrEqual(0);
68 expect(mat, `${look.name}.${role}`).toBeLessThan(LAYERS);
69 }
70 expect(lookNamed(look.name)).toBe(look);
71 }
72 expect(lookNamed("nothing this game paints")).toBeUndefined();
73 });
74
75 test("whatever the look, a site reads as a site and cover reads as cover", () => {
76 for (const look of LOOKS) {
77 const { FLOOR, WALL, CRATE, SITE } = look.materials;
78 const why = (what: string, d: number, bar: number): string =>
79 `${look.name}: ${what} are ${d.toFixed(0)} apart, under the ${bar} a room needs to read`;
80 expect(apart(SITE, FLOOR), why("its site and its floor", apart(SITE, FLOOR), SITE_OFF_FLOOR))
81 .toBeGreaterThanOrEqual(SITE_OFF_FLOOR);
82 expect(apart(CRATE, FLOOR), why("its cover and its floor", apart(CRATE, FLOOR), COVER_OFF_FLOOR))
83 .toBeGreaterThanOrEqual(COVER_OFF_FLOOR);
84 expect(apart(CRATE, WALL), why("its cover and its wall", apart(CRATE, WALL), COVER_OFF_WALL))
85 .toBeGreaterThanOrEqual(COVER_OFF_WALL);
86 expect(apart(FLOOR, WALL), why("its floor and its wall", apart(FLOOR, WALL), FLOOR_OFF_WALL))
87 .toBeGreaterThanOrEqual(FLOOR_OFF_WALL);
88 }
89 });
90
91 test("whatever the look, a player is a shape against it", () => {
92 // A player is skin, fatigues and dark gear. A look may match one of the three
93 // — a dark floor under a dark torso is a real place, and Counter-Strike is
94 // full of them — but never two, or the silhouette a game is played on goes.
95 for (const look of LOOKS)
96 for (const [role, surface] of Object.entries(look.materials)) {
97 if (role === "METAL") continue; // a name, not a surface: nothing is tagged with it
98 const clear = PLAYER.filter((worn) => apart(worn, surface) >= 40);
99 expect(clear.length, `${look.name}: a player is lost against its ${role.toLowerCase()}`)
100 .toBeGreaterThanOrEqual(2);
101 }
102 });
103
104 test("de_forge is dressed in exactly the materials it always was", () => {
105 expect(FORGE.name).toBe("forge");
106 expect(FORGE).toBe(LOOKS[0] as Look);
107 expect(FORGE.materials).toEqual({ FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 });
108 expect(MAT).toEqual(FORGE.materials);
109 });
110
111 test("a seed is a look, for ever, and a run of seeds is not a run of looks", () => {
112 for (const seed of [0, 1, 7, 4294967295]) expect(lookFor(seed)).toBe(lookFor(seed));
113 // The seed is a uint32 and is treated as one, however it arrives.
114 expect(lookFor(-1)).toBe(lookFor(4294967295));
115 // Ten seeds in a row are not the table in order, which `seed % LOOKS.length`
116 // would be: that is what the stirring in `lookFor` is for.
117 const run = Array.from({ length: LOOKS.length }, (_, i) => lookFor(i).name);
118 expect(run).not.toEqual(LOOKS.map((look) => look.name));
119 });
src/client/looks.ts added +90
@@ -0,0 +1,90 @@
1 /**
2 * What a map is dressed in: which of the nine materials in the atlas its floor,
3 * its walls, its cover and its bombsites are painted with, as a handful of
4 * named sets rather than an index per surface.
5 *
6 * The atlas is painted once at boot and never repainted, and a box in a level
7 * mesh carries a layer number. So a generated map can *look* like its own place
8 * for nothing: it picks one of the sets below from the same seed that carved
9 * it, `cells.ts` tags its boxes with that set's numbers, and not one byte more
10 * goes over the wire or into the texture. Nothing downstream learns anything —
11 * the renderer has always drawn whatever layer a vertex names.
12 *
13 * (Issue #151 calls these palettes. The file is `looks.ts` because
14 * `palette.ts` next door is already the colours the 2D is drawn in, and two
15 * palettes in one directory is a file nobody can name.)
16 *
17 * Named, and not random per surface, for one reason: nine materials do not
18 * combine freely. A map wants its cover to stand off its floor from across the
19 * room, its bombsite to read as painted ground rather than as more floor, and
20 * its players to stay legible against all three. `looks.test.ts` measures each
21 * set below against the shaders in `materials.ts` and holds it to those three
22 * distances, so a look added here without looking at it fails there.
23 */
24
25 /** The jobs a level gives a material. `METAL` is a name, not a surface: nothing tags a box with it yet. */
26 export type Role = "FLOOR" | "WALL" | "CRATE" | "SITE" | "METAL";
27
28 /** One dressing of a map: what to call it, and the atlas layer each job is painted from. */
29 export type Look = {
30 /** What this set is called, in a log, a terminal and a pull. */
31 readonly name: string;
32 /** The material id each job is tagged with. The whole of what a look is. */
33 readonly materials: Readonly<Record<Role, number>>;
34 };
35
36 /** The nine materials by name, in the order `materials.ts` paints them. */
37 export const SAND = 0, PLASTER = 1, WOOD = 2, PAINT = 3, METAL = 4;
38 export const SKIN = 5, FATIGUES = 6, GEAR = 7, WARNING = 8;
39
40 const look = (name: string, FLOOR: number, WALL: number, CRATE: number, SITE: number): Look =>
41 ({ name, materials: { FLOOR, WALL, CRATE, SITE, METAL } });
42
43 /**
44 * The looks, written down. Eight of them, each one looked at in the preview and
45 * measured in `looks.test.ts`; `forge` is first because it is de_forge's own,
46 * the numbers this game shipped with, and the default every level gets unless
47 * it asks for another.
48 *
49 * Read them as places: sand under plaster with wooden crates is the desert town
50 * this game started as; metal under dark steel with a red-painted site is a
51 * foundry floor; a timber deck inside plaster walls is a depot. The site is
52 * either the painted ground it always was or the warning red — never the floor
53 * it sits on, which is the one thing a player has to see from a doorway.
54 */
55 export const LOOKS: readonly Look[] = [
56 look("forge", SAND, PLASTER, WOOD, PAINT),
57 look("foundry", METAL, GEAR, WOOD, WARNING),
58 look("quarry", SAND, GEAR, METAL, WARNING),
59 look("depot", WOOD, PLASTER, GEAR, WARNING),
60 look("bunker", GEAR, METAL, WOOD, WARNING),
61 look("kiln", SAND, METAL, GEAR, PAINT),
62 look("plaza", PAINT, PLASTER, GEAR, WARNING),
63 look("hangar", METAL, PLASTER, GEAR, WARNING),
64 ];
65
66 /** de_forge's own, and what a level is dressed in when it does not choose. */
67 export const FORGE = LOOKS[0] as Look;
68
69 /** The look of that name, or `undefined`: what a terminal or a test looks one up with. */
70 export const lookNamed = (name: string): Look | undefined => LOOKS.find((l) => l.name === name);
71
72 /**
73 * A seed's thirty-two bits stirred into thirty-two others. The seed itself is
74 * no good to index with — seeds arrive in runs (`gen_1`, `gen_2`, `gen_3` are a
75 * person trying maps), and `seed % 8` would walk the table in order and make
76 * the look look like a counter. This is murmur3's finaliser, which is one
77 * multiply-xor-shift more than we need and costs nothing.
78 */
79 const stir = (seed: number): number => {
80 let h = Math.imul(seed ^ 0x9e3779b9, 0x85ebca6b) >>> 0;
81 h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
82 return (h ^ (h >>> 16)) >>> 0;
83 };
84
85 /**
86 * The look a seed wears. The same seed wears the same one on every machine, for
87 * ever: a map name is a layout and a look together, which is what makes one
88 * worth writing down and handing to somebody.
89 */
90 export const lookFor = (seed: number): Look => LOOKS[stir(seed >>> 0) % LOOKS.length] as Look;
src/client/map.test.ts +19
@@ -9,6 +9,7 @@
99 import { expect, test } from "bun:test";
1010 import { cellAt, colOf, findPath, rowOf, siteAt, walkable, worldOf } from "./level.ts";
1111 import { DE_FORGE, GRID_H, GRID_W } from "./map.ts";
12 import { levelMesh } from "./models.ts";
1213
1314 const { boxes, sites, spawns } = DE_FORGE;
1415
@@ -130,4 +131,22 @@ test("de_forge carries its own alphabet, its numbers and its material table", ()
130131 expect(DE_FORGE.wallHeight).toBe(384);
131132 expect(DE_FORGE.materials).toEqual({ FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 });
132133 expect(DE_FORGE.siteCells).toEqual({ A: "A", B: "B" });
134 expect(DE_FORGE.look).toBe("forge");
135 });
136
137 /**
138 * #151 lets a map choose which of the nine materials it is painted from. This
139 * is the claim that de_forge did not choose: the same fifty boxes tagged with
140 * the same layers they always were, and the mesh drawn from them the same
141 * 1,200 vertices split the same way — 24 of floor, 600 of wall, 528 of crate
142 * and 48 of site paint. A look applied to this map by accident lands here.
143 */
144 test("de_forge is painted from exactly the layers it always was", () => {
145 expect(boxes.map((box) => box.mat)).toEqual([
146 0, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
147 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
148 ]);
149 const counted: Record<number, number> = {};
150 for (const layer of levelMesh(boxes).layer) counted[layer] = (counted[layer] ?? 0) + 1;
151 expect(counted).toEqual({ 0: 24, 1: 600, 2: 528, 3: 48 });
133152 });
src/client/preview.test.ts +6
@@ -112,9 +112,15 @@ test("the line beside it is the level, in words", () => {
112112 // It does not repeat the map's name — the name is the element above it.
113113 expect(line).not.toContain(GENERATED.name);
114114
115 // And it ends with what the map is painted in, which is the other half of
116 // what a generated map is and is not in the picture beside it.
117 expect(line.endsWith(` · ${GENERATED.look}`)).toBe(true);
118 expect(GENERATED.look).toBe("hangar");
119
115120 // de_forge is a different map and gets a different sentence.
116121 expect(previewText(DE_FORGE)).not.toBe(line);
117122 expect(previewText(DE_FORGE)).toContain("44×44 cells");
123 expect(previewText(DE_FORGE)).toContain("forge");
118124 });
119125
120126 test("a compass word is dropped when a thing is not really off centre", () => {
src/client/preview.ts +7 −1
@@ -61,13 +61,19 @@ export function compass(at: Vec3, span: number): string {
6161 * level and nothing else. It does not repeat the map's name — the name is the
6262 * element above it on the page — and it stays short because it sits in a column
6363 * beside a 96-pixel square on a 360-pixel screen.
64 *
65 * It ends with the level's look — which of the named sets of materials in
66 * `looks.ts` it is painted in — because since #151 that is half of what a
67 * generated map *is*, and the picture beside this sentence is drawn in the
68 * sides' colours rather than the map's: the one word is how a player reading
69 * instead of looking knows `gen_9ix` is a hangar and not a quarry.
6470 */
6571 export function previewText(level: Level): string {
6672 const { grid, sites, spawns } = level;
6773 const span = Math.max(grid.width, grid.height) * grid.cell;
6874 const where = (at: Vec3): string => compass(at, span);
6975 return `${grid.width}×${grid.height} cells · bombsite A ${where(sites.A)}, B ${where(sites.B)} · ` +
70 `spawns ${where(middleOf(spawns.t))} and ${where(middleOf(spawns.ct))}`;
76 `spawns ${where(middleOf(spawns.t))} and ${where(middleOf(spawns.ct))} · ${level.look}`;
7177 }
7278
7379 /**
src/games.ts +15
@@ -60,6 +60,21 @@ export type Game = {
6060 /**
6161 * In the order the header lists them, which is also the order they are built.
6262 *
63 * #151 gave a generated map its own look — `src/client/looks.ts`, eight named
64 * sets of the nine materials already in the atlas, one chosen from the seed —
65 * and the app grew 441 raw / 211 gzipped bytes for it: the table, the stirring
66 * that indexes it, the look a level now carries, and the one word the menu's
67 * preview sentence ends with. No repaint, no tenth layer, nothing new over the
68 * wire: the pixels were uploaded at boot either way, and what changed is which
69 * layer a box's vertices name. It fits today's
70 * 98,000 / 39,000 with 2,530 raw and 1,258 gzipped bytes to spare, so no
71 * ceiling moved. Measured 2026-09-21 against a *rebuild* of main rather than
72 * against main's committed bundle: #235 left two app bundles in `static/` and
73 * the newer of them was already stale, so the number quoted below (92,978) is
74 * not what main's source builds — that is 95,029 raw / 37,531 gzipped, and
75 * this branch rebuilds it and commits the one file, which is also what puts
76 * `bundle.test.ts` back to passing.
77 *
6378 * #228 gave the bots a round to play rather than five separate ones — a site, a
6479 * shape, a tempo and a save per side, and a job per bot cut from it — and the
6580 * app grew 3,804 raw bytes for it (92,978 raw / 36,716 gzipped now): `src/plan/plan.ts` (the plan,
static/app.07fd7f4c9f.js deleted −58

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

View file
static/app.5a2ef4f929.js added +58

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

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

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs