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

forked from trevato/cs

A match on a generated map is pinned too: the determinism net covers the whole pipeline, not just de_forge #243

Merged opened this

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

Merged

torque/cs-engine:torque/159-a-match-on-a-generated-map-is-pinned-too → main 3 commits fork

  • Merged. torque/159-a-match-on-a-generated-map-is-pinned-too was merged into main as 4151c12.

Review

Sign in to review this change.
8 files changed +441 −153
README.md +12 −2
@@ -854,8 +854,11 @@ never checked itself.
854854 and this file gained no rule for it.
855855 `src/server.ts` and `src/pages/` belong to no game and are held to neither
856856 rule: the app is allowed to know about every game on it.
857 - `src/client/replay.ts` — the golden digest: a seeded round and a seeded duel
858 in the arena, each stepped through one fixed script of inputs and hashed tick
857 - `src/client/replay.ts` — the golden digest: a seeded round on de_forge, a
858 seeded duel in the arena and, since #159, a seeded match on each of two maps
859 carved from a seed — `gen_o`, the roomiest of the first 120 seeds, and
860 `gen_11`, the tightest — each stepped through one fixed script of inputs and
861 hashed tick
859862 by tick (positions, velocities, view angles, health, armour, duck and
860863 magazine, plus the phase, the timer and the score, all quantised to 1e-4 so a
861864 CPU's last bit cannot fail the test), against digests committed in
@@ -867,6 +870,13 @@ never checked itself.
867870 quantiser, the fold, the checkpoints and the complaint, which this file and
868871 `src/range/pin.ts` had each written out in full until #110 put one copy behind
869872 the door; what stays here is the script, the fields and the 64 Hz.
873 - `src/client/layout.ts` — **the map, pinned beside the match played on it.** A
874 generated level's cells fold to a digest of their own through the same door,
875 ten rows of letters to a band, so a change to the generator is its own failure
876 rather than a mysterious match digest moving. `src/client/replay.test.ts` asks
877 the map first and reads the play's digest only on a map that still matches,
878 which is how a failure there says which of the two moved; `generate.test.ts`
879 pins the named map `gen_14u5j6g` with the same function.
870880 - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the
871881 schema and its queries, `src/result.ts` validates what a browser may claim
872882 — the scores add up to the rounds, a headshot is a kill, nobody dies more
src/client/generate.test.ts +12 −22
@@ -15,6 +15,7 @@ import { expect, test } from "bun:test";
1515 import { botThink } from "./bots.ts";
1616 import { levelOf, type Cell } from "./cells.ts";
1717 import { flaws, generateGrid, generateLevel, nameOf, page, seedOf, SIZE } from "./generate.ts";
18 import { compare as compareMap, layout } from "./layout.ts";
1819 import { colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Box, type Level } from "./level.ts";
1920 import { LOOKS, lookFor, type Look } from "./looks.ts";
2021 import { LAYERS } from "./materials.ts";
@@ -52,9 +53,12 @@ test("a seed is a map, and the same seed is the same map for ever", () => {
5253 });
5354
5455 /**
55 * The map `gen_14u5j6g` is, folded cell by cell into a hash through the
56 * engine's `digest` door — the same instrument the rounds are pinned with,
57 * given rows of letters instead of ticks of a world.
56 * The map `gen_14u5j6g` is, folded cell by cell into a hash by `layout.ts` —
57 * which is the engine's `digest` door, the same instrument the rounds are
58 * pinned with, given rows of letters instead of ticks of a world. The fold
59 * moved into a module of its own for #159, where a generated map is pinned
60 * beside the match played on it; the digest below is the one this test has
61 * always had, to the bit.
5862 *
5963 * This is the claim the name makes: a person writes `gen_14u5j6g` down, hands
6064 * it to somebody on another machine, and that machine carves the same forty by
@@ -77,28 +81,14 @@ test("a map you can name is the same map on every machine, cell for cell", () =>
7781 expect(level.name).toBe(NAMED);
7882 expect(nameOf(seed)).toBe(NAMED);
7983
80 const rows = rowsOf(level);
81 let at = 0;
82 const run = fold({
83 ticks: rows.length,
84 step: (row) => void (at = row),
85 line: () => rows[at] as string,
86 every: 10,
87 });
88 const why = compare(
89 run,
90 { label: `map ${NAMED}`, constant: "NAMED_MAP", digest: NAMED_MAP, checkpoints: NAMED_BANDS },
91 {
92 file: "src/client/generate.test.ts",
93 list: "NAMED_BANDS",
94 hashes: "every cell of the level's grid, one row of letters a line",
95 every: 10,
96 unit: "band",
97 },
84 const why = compareMap(
85 layout(level),
86 { label: NAMED, constant: "NAMED_MAP", list: "NAMED_BANDS", digest: NAMED_MAP, bands: NAMED_BANDS },
87 "src/client/generate.test.ts",
9888 );
9989 if (why !== null) throw new Error(why);
10090 // Asking twice is asking once: the whole point of a seed.
101 expect(rowsOf(generateLevel(seed))).toEqual(rows);
91 expect(rowsOf(generateLevel(seed))).toEqual(rowsOf(level));
10292 });
10393
10494 /**
src/client/layout.test.ts added +69
@@ -0,0 +1,69 @@
1 /**
2 * The map digest, held to the three things `replay.test.ts` leans on it for: a
3 * level folds to the same number twice, a single cell somewhere else is a
4 * different number, and the complaint names the band that cell is in.
5 *
6 * Nothing here plays a round — that is next door. This is the instrument, not
7 * the claim: 40 rows of letters, folded, in a millisecond.
8 */
9
10 import { expect, test } from "bun:test";
11 import { generateLevel } from "./generate.ts";
12 import { compare, layout, rowsOf, ROWS_PER_BAND, type MapPin } from "./layout.ts";
13 import { DE_FORGE } from "./map.ts";
14 import type { Cell, Level } from "./level.ts";
15
16 const pinOf = (level: Level): MapPin => {
17 const folded = layout(level);
18 return {
19 label: level.name, constant: "A_MAP", list: "A_MAP_BANDS",
20 digest: folded.digest, bands: folded.bands,
21 };
22 };
23
24 /** The same level with one cell rubbed out, without touching the one it came from. */
25 const withCellAt = (level: Level, col: number, row: number, cell: Cell): Level => {
26 const cells = [...level.grid.cells];
27 cells[row * level.grid.width + col] = cell;
28 return { ...level, grid: { ...level.grid, cells } };
29 };
30
31 test("a level folds to a digest, and the same level folds to the same one", () => {
32 const a = layout(generateLevel(24));
33 const b = layout(generateLevel(24));
34 expect(a.digest).toBe(b.digest);
35 expect(a.bands).toEqual(b.bands);
36 expect(a.rows).toBe(generateLevel(24).grid.height);
37 expect(a.bands.length).toBe(a.rows / ROWS_PER_BAND);
38 expect(compare(a, pinOf(generateLevel(24)))).toBe(null);
39 });
40
41 test("two seeds are two digests, and de_forge is neither", () => {
42 const seeds = [layout(generateLevel(24)).digest, layout(generateLevel(37)).digest, layout(DE_FORGE).digest];
43 expect(new Set(seeds).size).toBe(3);
44 });
45
46 test("one cell moved is a digest moved, and the complaint says which band it is in", () => {
47 const level = generateLevel(24);
48 const pin = pinOf(level);
49 // Row 30 is in the fourth band of ten rows; the cell is floor on this map.
50 const moved = withCellAt(level, 20, 30, "#");
51 const why = compare(layout(moved), pin) as string;
52 expect(why).not.toBe(null);
53 expect(why).toContain("band 4 of 4");
54 expect(why).toContain("src/client/replay.test.ts");
55 expect(why).toContain("const A_MAP = ");
56 expect(why).toContain("const A_MAP_BANDS = ");
57 // The bands the change is not in are the bands they were.
58 expect(layout(moved).bands.slice(0, 3)).toEqual([...pin.bands].slice(0, 3));
59 // And the file the digest is committed in is the caller's to say.
60 expect(compare(layout(moved), pin, "src/client/generate.test.ts")).toContain("src/client/generate.test.ts");
61 });
62
63 test("a page of letters is one string a row, each as wide as the grid", () => {
64 const level = generateLevel(37);
65 const rows = rowsOf(level.grid);
66 expect(rows.length).toBe(level.grid.height);
67 for (const row of rows) expect(row.length).toBe(level.grid.width);
68 expect(rows.join("")).toBe(level.grid.cells.join(""));
69 });
src/client/layout.ts added +93
@@ -0,0 +1,93 @@
1 /**
2 * A level folded into a digest: the place, pinned, the way `replay.ts` pins the
3 * play on it.
4 *
5 * A match on a generated map is two claims at once — that this seed still
6 * carves *this* place, and that the simulation on it still runs the way it ran.
7 * A single digest over the round confuses them: the generator gains a crate,
8 * every tick of the round moves, and the failure reads like the physics
9 * changed. So the map gets a number of its own, taken first, and the round's
10 * digest is only ever read on a map that still matches. `replay.test.ts` asks
11 * them in that order and says which of the two moved.
12 *
13 * What is hashed is the letters, one row a line, and nothing else. Everything
14 * else a `Level` carries — the boxes, the spawns, the sites, the walls a bullet
15 * stops in — is `levelOf` reading those letters, so a grid that folds to the
16 * same digest is the same place to play in. What the place is *painted* with is
17 * not in here: a look is the seed's and is pinned beside the mesh in
18 * `generate.test.ts`, and a repaint is not a map that plays differently.
19 *
20 * The arithmetic is the engine's `fold` and `compare`, the same instrument and
21 * the same complaint the rounds are pinned with — rows of letters where they
22 * hand it ticks of a world.
23 */
24
25 import { compare as complain, fold as foldRows, type Grid } from "../engine/index.ts";
26 import type { Level } from "./level.ts";
27
28 /**
29 * How many rows one checkpoint covers: four bands over a generated map, which
30 * is forty rows on a side. The digest is of every row whatever the height; a
31 * map whose height is not a whole number of bands has its last few rows in the
32 * digest and in no band, which is the engine's rule for a part-checkpoint.
33 */
34 export const ROWS_PER_BAND = 10;
35
36 /** A level's cells as a page of letters, one string a row. */
37 export const rowsOf = (grid: Grid<string>): string[] =>
38 Array.from({ length: grid.height }, (_, r) => grid.cells.slice(r * grid.width, (r + 1) * grid.width).join(""));
39
40 export type Layout = {
41 /** sha256 of every row of letters, hex. */
42 digest: string;
43 /** One short digest per band of `ROWS_PER_BAND` rows, for finding where two maps part. */
44 bands: string[];
45 rows: number;
46 };
47
48 /** A map digest committed in the repository, and what to call it when it moves. */
49 export type MapPin = {
50 /** What this map is called in the first sentence of a complaint. */
51 label: string;
52 /** The constant the whole digest is committed as. */
53 constant: string;
54 /** The constant the bands are committed as, beside it. */
55 list: string;
56 digest: string;
57 bands: readonly string[];
58 };
59
60 /** Fold a level's cells into a digest, checkpointing every `ROWS_PER_BAND` rows. */
61 export function layout(level: Level): Layout {
62 const rows = rowsOf(level.grid);
63 let at = 0;
64 const run = foldRows({
65 ticks: rows.length,
66 step: (row) => void (at = row),
67 // A row is a fixed number of cells, so the letters need no separator to be
68 // read back apart: what goes in is exactly the page a terminal prints.
69 line: () => rows[at] as string,
70 every: ROWS_PER_BAND,
71 });
72 return { digest: run.digest, bands: run.checkpoints, rows: run.ticks };
73 }
74
75 /**
76 * Why a map and its pin differ, or null if they do not. The engine finds the
77 * band that first went another way and prints the constant to paste; what this
78 * adds is what it cannot know — that a checkpoint here is ten rows of cells and
79 * that what moved is the generator rather than the game.
80 */
81 export function compare(map: Layout, pin: MapPin, file = "src/client/replay.test.ts"): string | null {
82 return complain(
83 { digest: map.digest, checkpoints: map.bands, ticks: map.rows },
84 { label: `map ${pin.label}`, constant: pin.constant, digest: pin.digest, checkpoints: pin.bands },
85 {
86 file,
87 list: pin.list,
88 hashes: "every cell the generator carved, one row of letters a line",
89 every: ROWS_PER_BAND,
90 unit: "band",
91 },
92 );
93 }
src/client/replay.test.ts +250 −8
@@ -1,11 +1,12 @@
11 /**
22 * The golden digests: what this simulation does, pinned.
33 *
4 * Two runs, both seeded, both stepped through the same script in
5 * `replay.ts`: the whole game — ten players, the match rules, the bomb, the
6 * bots and their policy — and the duel in the training arena, so an
4 * Four runs, all seeded, all stepped through the same script in
5 * `replay.ts`: the whole game on de_forge — ten players, the match rules, the
6 * bomb, the bots and their policy — the duel in the training arena, so an
77 * extraction that moves the environment PPO learned in is caught as loudly as
8 * one that moves the game.
8 * one that moves the game, and a match on each of two maps nobody wrote, so a
9 * change that only shows on a layout carved from a seed is caught at all.
910 *
1011 * If one of these fails, read the message: it names the first second of play
1112 * at which the run and the pin part company, and prints the constants to
@@ -13,13 +14,17 @@
1314 * and say in the pull what moved and why. An extraction that changes a digest
1415 * is not an extraction.
1516 *
16 * Measured: the two pinned runs together are 0.52 s (the round 0.46 s — ten
17 * bots running a policy every fourth tick for forty seconds is the work — and
18 * the duel 0.06 s), and this whole file, short runs and all, is 0.66 s of the
19 * suite's 3.3 s.
17 * Measured: the four pinned runs together are 0.86 s — the de_forge round
18 * 0.48 s (ten bots running a policy every fourth tick for forty seconds is the
19 * work), the duel 0.06 s, and the two generated matches 0.17 s and 0.15 s,
20 * which is twenty seconds of the same ten-bot play apiece, plus 10 ms to carve
21 * each map and 1 ms to fold its cells. This whole file, short runs and all, is
22 * 1.1 s of the suite's 22 s: the two generated matches added 0.4 s of it.
2023 */
2124
2225 import { expect, test } from "bun:test";
26 import { generateLevel, nameOf } from "./generate.ts";
27 import { compare as compareMap, layout, type MapPin } from "./layout.ts";
2328 import { DE_FORGE } from "./map.ts";
2429 import { SHORT } from "./match.ts";
2530 import { CHECKPOINT_CHARS } from "../engine/index.ts";
@@ -27,8 +32,18 @@ import {
2732 compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND,
2833 withoutEntropy, type Pin, type Run,
2934 } from "./replay.ts";
35 import { colOf, rowOf, walkable, type Level } from "./level.ts";
3036 import { createWorld, type Player } from "./world.ts";
3137 import { createArena, reset, stepInput } from "../rl/arena.ts";
38 import { useShipped } from "./shipped.ts";
39
40 /**
41 * The bots in every run below think with the policy this repository ships, and
42 * this file hands it to them itself rather than hoping another test file in the
43 * same process got there first: a pin you cannot reproduce by running this one
44 * file — `bun test src/client/replay.test.ts` — is not a pin.
45 */
46 useShipped();
3247
3348 const SECONDS = 40;
3449 const TICKS = SECONDS * TICKS_PER_SECOND;
@@ -153,6 +168,233 @@ test("a seeded duel in the training arena plays exactly as it played", () => {
153168 expect(run.seconds.length).toBe(SECONDS);
154169 });
155170
171 // --- a match on a map nobody wrote ----------------------------------------
172
173 /**
174 * Since #147 half the matches on this server are played on a map carved from a
175 * seed, and nothing pinned the two halves of that together: the generator had
176 * its own tests, the simulation had the digests above, and both were only ever
177 * asked about de_forge. A change that made the world behave differently *only*
178 * on a generated layout — a cell the solver reads another way, a spawn on an
179 * edge, a path the bots take through a two-wide corridor — passed every test
180 * here. These two pins are that hole closed.
181 *
182 * **Two maps, because one map is one shape of place.** Both were measured over
183 * the first 120 seeds and taken off the ends of the range:
184 *
185 * - `gen_o`, seed 24, is the open one. 40.9% of its grid is floor and only 7%
186 * of that floor is pinched — two or fewer of its four neighbours open — which
187 * is the roomiest of the 120. Halls, long sightlines, bombsites with space
188 * around them: bots spread out and shoot across it.
189 * - `gen_11`, seed 37, is the tight one. 23.8% floor, of which 22% is pinched,
190 * the most of the 120. Its spine is a two-cell corridor with crates standing
191 * in it and both routes to a site are a queue through a doorway. It is the
192 * map where a change to the mover, to what the solver thinks a cell is, or to
193 * how a bot picks its way, pinches hardest.
194 *
195 * Both take de_forge's own match seed, 7, so the ground is the only difference
196 * between these runs and the one at the top of this file. Twenty seconds each:
197 * eight of freeze and twelve of a live round, which is four seconds more than
198 * the de_forge pin needs to reach the second a bot first has somebody to decide
199 * about. The two runs share their first seven checkpoints and part at second 8
200 * — both generators happened to put the CT block on the same cells, nothing in
201 * the hash moves while the round is frozen, and the tick it goes live is the
202 * tick the two places start playing differently. That is the shape to expect: a
203 * generated-map failure that starts at second 1 is a spawn that moved.
204 *
205 * **Two digests each, asked in this order.** The map folds to one — the letters
206 * the generator carved, through `layout.ts` — and the play folds to another,
207 * through the same `digest` door, the same script and the same fields as every
208 * pin above. The map is asked first and the play's digest is only ever read on
209 * a map that still matches, so a failure names which of the two moved instead
210 * of leaving that as the first question.
211 *
212 * **To recompute either**, run `bun test src/client/replay.test.ts`, read the
213 * failure and paste the constants it prints — the map's before the play's, the
214 * play's only once the map is right — and say in the pull which half moved and
215 * why. A map digest that moves on its own is the generator's shape changing; a
216 * play digest that moves under a map that did not is the simulation.
217 */
218 const GENERATED_SECONDS = 20;
219 const GENERATED_TICKS = GENERATED_SECONDS * TICKS_PER_SECOND;
220
221 /** The match seed both generated runs take. de_forge's, so only the map differs. */
222 const GENERATED_MATCH = 7;
223
224 /** One generated map, the match played on it, and the digests the pair is held to. */
225 type Generated = { seed: number; name: string; map: MapPin; play: Pin };
226
227 const OPEN_MAP = "f5ec0f03c150cdbc92359df63271653c6a2be8c0f485bdf86d028f636eacd932";
228 const OPEN_MAP_BANDS: readonly string[] = [
229 "f6fa28441414", "aab631e1a5fa", "ebc69b760b9d", "db19d9a43600",
230 ];
231 const OPEN_ROUND = "b5d418adfbadc051285a06177f7e97ad15ce5ffde347cf8e51f47da76ea9e496";
232 const OPEN_ROUND_SECONDS: readonly string[] = [
233 "1ad83b1cc8ed", "230f205bffb1", "9a22187ee262", "b1a1b9f147cb",
234 "52bd60b8b4c1", "219b9ec017a2", "e143e86a3980", "3f54189e301c",
235 "6499a8a1375d", "a87763d45101", "a012a6608bfe", "81b538e9769f",
236 "bcd612a208ca", "a45c2ba991f2", "cc2cf4a19c6a", "0b095af1719b",
237 "5fda5a176d18", "366a4f144a4e", "09fa64b30ca6", "066be5b8acf8",
238 ];
239
240 const TIGHT_MAP = "4bd818fd2e654f16fb41b6bd718951acbb22ac24572162a2be348e7edfcccc15";
241 const TIGHT_MAP_BANDS: readonly string[] = [
242 "1ca81af8ea02", "c4888f792a55", "d08dbb1ee415", "1efd9cc4a8e9",
243 ];
244 const TIGHT_ROUND = "b283d2d6cf8b2c82d80e3d2f8e1b9bfb8f731dd70009310b12a930a056876223";
245 const TIGHT_ROUND_SECONDS: readonly string[] = [
246 "1ad83b1cc8ed", "230f205bffb1", "9a22187ee262", "b1a1b9f147cb",
247 "52bd60b8b4c1", "219b9ec017a2", "e143e86a3980", "8c22014cab10",
248 "6707adad67e0", "79b78073cafe", "b8a200b895d9", "40375defc572",
249 "6e4039ce2f67", "e7a50e9bb180", "0e56112c62be", "e2d9f7b0b01b",
250 "5e358b1dce56", "04a9df6200ea", "a575c4789200", "a32602bcecdf",
251 ];
252
253 const OPEN: Generated = {
254 seed: 24,
255 name: "gen_o",
256 map: { label: "gen_o", constant: "OPEN_MAP", list: "OPEN_MAP_BANDS", digest: OPEN_MAP, bands: OPEN_MAP_BANDS },
257 play: { label: "match on gen_o", constant: "OPEN_ROUND", digest: OPEN_ROUND, seconds: OPEN_ROUND_SECONDS },
258 };
259
260 const TIGHT: Generated = {
261 seed: 37,
262 name: "gen_11",
263 map: { label: "gen_11", constant: "TIGHT_MAP", list: "TIGHT_MAP_BANDS", digest: TIGHT_MAP, bands: TIGHT_MAP_BANDS },
264 play: { label: "match on gen_11", constant: "TIGHT_ROUND", digest: TIGHT_ROUND, seconds: TIGHT_ROUND_SECONDS },
265 };
266
267 /** Why this seed's map is not the map that was pinned, or null — and that it is the map. */
268 const mapMoved = (level: Level, pin: Generated): string | null => {
269 const why = compareMap(layout(level), pin.map);
270 return why === null
271 ? null
272 : `${why}\nThe map moved, not the play: seed ${pin.seed} no longer carves ${pin.name}. ` +
273 `Re-pin ${pin.map.constant} first and say in the pull which seeds changed shape — ` +
274 `${pin.play.constant} below is a match on this map and has moved with it.\n`;
275 };
276
277 /** Why the match on it is not the match that was pinned, or null — and that it is the play. */
278 const playMoved = (run: Run, pin: Generated): string | null => {
279 const why = compare(run, pin.play);
280 return why === null
281 ? null
282 : `${why}\nThe play moved, not the map: ${pin.name}'s cells still fold to ${pin.map.constant}, ` +
283 `so what changed is the simulation and not the ground under it. If ${ROUND_PIN.constant} above ` +
284 `still matches, the change shows only on a map carved from a seed.\n`;
285 };
286
287 /** The match on a generated map, played once however many tests ask for it. */
288 const matches = new Map<number, Run>();
289 const matchOn = (pin: Generated, level: Level): Run => {
290 const already = matches.get(pin.seed);
291 if (already !== undefined) return already;
292 const run = withoutEntropy(() =>
293 digest(createWorld(level, SHORT, "me", "ct", 5, GENERATED_MATCH), GENERATED_TICKS));
294 matches.set(pin.seed, run);
295 return run;
296 };
297
298 /** Both digests, in the order that lets a failure say which half moved. */
299 const heldTo = (pin: Generated): { level: Level; run: Run } => {
300 const level = generateLevel(pin.seed);
301 if (level.name !== pin.name)
302 throw new Error(`seed ${pin.seed} is the map ${level.name} now, and this pin is about ${pin.name}`);
303 const moved = mapMoved(level, pin);
304 if (moved !== null) throw new Error(moved);
305 const run = matchOn(pin, level);
306 const changed = playMoved(run, pin);
307 if (changed !== null) throw new Error(changed);
308 return { level, run };
309 };
310
311 /** Nobody stood in a wall at the first tick of a round on a map nobody wrote. */
312 const onTheirFeet = (level: Level): void => {
313 for (const p of createWorld(level, SHORT, "me", "ct", 5, GENERATED_MATCH).players)
314 expect(walkable(level, colOf(level, p.pos[0]), rowOf(level, p.pos[1]))).toBe(true);
315 };
316
317 test("a seeded match on an open generated map is the same map and the same play", () => {
318 const { level, run } = heldTo(OPEN);
319 expect(run.digest).toBe(OPEN_ROUND);
320 expect(run.seconds).toEqual([...OPEN_ROUND_SECONDS]);
321 expect(run.ticks).toBe(GENERATED_TICKS);
322 expect(layout(level).digest).toBe(OPEN_MAP);
323 onTheirFeet(level);
324 });
325
326 test("a seeded match on a tight generated map is the same map and the same play", () => {
327 const { level, run } = heldTo(TIGHT);
328 expect(run.digest).toBe(TIGHT_ROUND);
329 expect(run.seconds).toEqual([...TIGHT_ROUND_SECONDS]);
330 expect(run.ticks).toBe(GENERATED_TICKS);
331 expect(layout(level).digest).toBe(TIGHT_MAP);
332 onTheirFeet(level);
333 });
334
335 /**
336 * How much of a level a player can stand on, and how much of that standing room
337 * is pinched — two or fewer of its four neighbours open, which is a corridor, a
338 * doorway or the gap beside a crate rather than a room.
339 */
340 const roomOf = (level: Level): { floor: number; pinched: number } => {
341 const { width, height, cells } = level.grid;
342 const open = (col: number, row: number): boolean =>
343 col >= 0 && row >= 0 && col < width && row < height && level.passable(cells[row * width + col] as string);
344 const around: [number, number][] = [[1, 0], [-1, 0], [0, 1], [0, -1]];
345 let floor = 0;
346 let pinched = 0;
347 for (let row = 0; row < height; row++)
348 for (let col = 0; col < width; col++) {
349 if (!open(col, row)) continue;
350 floor++;
351 let neighbours = 0;
352 for (const [dc, dr] of around) if (open(col + dc, row + dr)) neighbours++;
353 if (neighbours <= 2) pinched++;
354 }
355 return { floor: floor / (width * height), pinched: pinched / floor };
356 };
357
358 test("the two seeds are two different places, and neither of them is de_forge", () => {
359 expect(OPEN_MAP).not.toBe(TIGHT_MAP);
360 expect(OPEN_ROUND).not.toBe(TIGHT_ROUND);
361 const forge = layout(DE_FORGE).digest;
362 expect(forge).not.toBe(OPEN_MAP);
363 expect(forge).not.toBe(TIGHT_MAP);
364 // Why these two seeds and not two others, as a number rather than a comment:
365 // measured, gen_o is 40.9% floor with 7% of it pinched and gen_11 is 23.8%
366 // with 22%. The floors below are loose; a generator that made every seed the
367 // same shape of place, or swapped these two round, fails them.
368 const open = roomOf(generateLevel(OPEN.seed));
369 const tight = roomOf(generateLevel(TIGHT.seed));
370 expect(open.floor).toBeGreaterThan(tight.floor * 1.4);
371 expect(open.pinched).toBeLessThan(tight.pinched / 2);
372 });
373
374 test("a failure on a generated match says which of the two moved, the map or the play", () => {
375 const level = generateLevel(OPEN.seed);
376 expect(mapMoved(level, OPEN)).toBe(null);
377 const elsewhere = [...OPEN_MAP_BANDS];
378 elsewhere[0] = "0".repeat(CHECKPOINT_CHARS);
379 const moved = mapMoved(level, { ...OPEN, map: { ...OPEN.map, digest: "0".repeat(64), bands: elsewhere } }) as string;
380 expect(moved).toContain("map gen_o");
381 expect(moved).toContain("band 1 of 4");
382 expect(moved).toContain("The map moved, not the play");
383 expect(moved).toContain("const OPEN_MAP = ");
384 expect(moved).not.toContain("The play moved");
385
386 const run = matchOn(OPEN, level);
387 expect(playMoved(run, OPEN)).toBe(null);
388 const later = [...OPEN_ROUND_SECONDS];
389 later[2] = "0".repeat(CHECKPOINT_CHARS);
390 const changed = playMoved(run, { ...OPEN, play: { ...OPEN.play, digest: "0".repeat(64), seconds: later } }) as string;
391 expect(changed).toContain("match on gen_o");
392 expect(changed).toContain(`second 3 of ${GENERATED_SECONDS}`);
393 expect(changed).toContain("The play moved, not the map");
394 expect(changed).toContain("const OPEN_ROUND = ");
395 expect(changed).not.toContain("The map moved");
396 });
397
156398 test("the same seed is the same run, and another seed is another run", () => {
157399 const four = 4 * TICKS_PER_SECOND;
158400 const a = withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), four));
static/app.8ec5c087a3.js deleted −58

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

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

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

View file
static/app.5a2ef4f929.js → static/app.ea37d3064a.js renamed +5 −5

Diff not shown — 188,456 bytes of diff (limit 100,000). +5 −5

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs