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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/159-a-match-on-a-generated-map-is-pinned-too → main 3 commits fork
8 files
changed +441
−153
README.md +12 −2
| @@ -854,8 +854,11 @@ never checked itself. | ||
| 854 | 854 | and this file gained no rule for it. |
| 855 | 855 | `src/server.ts` and `src/pages/` belong to no game and are held to neither |
| 856 | 856 | 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 | |
| 859 | 862 | by tick (positions, velocities, view angles, health, armour, duck and |
| 860 | 863 | magazine, plus the phase, the timer and the score, all quantised to 1e-4 so a |
| 861 | 864 | CPU's last bit cannot fail the test), against digests committed in |
| @@ -867,6 +870,13 @@ never checked itself. | ||
| 867 | 870 | quantiser, the fold, the checkpoints and the complaint, which this file and |
| 868 | 871 | `src/range/pin.ts` had each written out in full until #110 put one copy behind |
| 869 | 872 | 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. | |
| 870 | 880 | - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the |
| 871 | 881 | schema and its queries, `src/result.ts` validates what a browser may claim |
| 872 | 882 | — 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"; | ||
| 15 | 15 | import { botThink } from "./bots.ts"; |
| 16 | 16 | import { levelOf, type Cell } from "./cells.ts"; |
| 17 | 17 | import { flaws, generateGrid, generateLevel, nameOf, page, seedOf, SIZE } from "./generate.ts"; |
| 18 | import { compare as compareMap, layout } from "./layout.ts"; | |
| 18 | 19 | import { colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Box, type Level } from "./level.ts"; |
| 19 | 20 | import { LOOKS, lookFor, type Look } from "./looks.ts"; |
| 20 | 21 | 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", () => { | ||
| 52 | 53 | }); |
| 53 | 54 | |
| 54 | 55 | /** |
| 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. | |
| 58 | 62 | * |
| 59 | 63 | * This is the claim the name makes: a person writes `gen_14u5j6g` down, hands |
| 60 | 64 | * 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", () => | ||
| 77 | 81 | expect(level.name).toBe(NAMED); |
| 78 | 82 | expect(nameOf(seed)).toBe(NAMED); |
| 79 | 83 | |
| 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", | |
| 98 | 88 | ); |
| 99 | 89 | if (why !== null) throw new Error(why); |
| 100 | 90 | // 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)); | |
| 102 | 92 | }); |
| 103 | 93 | |
| 104 | 94 | /** |
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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The golden digests: what this simulation does, pinned. |
| 3 | 3 | * |
| 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 | |
| 7 | 7 | * 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. | |
| 9 | 10 | * |
| 10 | 11 | * If one of these fails, read the message: it names the first second of play |
| 11 | 12 | * at which the run and the pin part company, and prints the constants to |
| @@ -13,13 +14,17 @@ | ||
| 13 | 14 | * and say in the pull what moved and why. An extraction that changes a digest |
| 14 | 15 | * is not an extraction. |
| 15 | 16 | * |
| 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. | |
| 20 | 23 | */ |
| 21 | 24 | |
| 22 | 25 | import { expect, test } from "bun:test"; |
| 26 | import { generateLevel, nameOf } from "./generate.ts"; | |
| 27 | import { compare as compareMap, layout, type MapPin } from "./layout.ts"; | |
| 23 | 28 | import { DE_FORGE } from "./map.ts"; |
| 24 | 29 | import { SHORT } from "./match.ts"; |
| 25 | 30 | import { CHECKPOINT_CHARS } from "../engine/index.ts"; |
| @@ -27,8 +32,18 @@ import { | ||
| 27 | 32 | compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND, |
| 28 | 33 | withoutEntropy, type Pin, type Run, |
| 29 | 34 | } from "./replay.ts"; |
| 35 | import { colOf, rowOf, walkable, type Level } from "./level.ts"; | |
| 30 | 36 | import { createWorld, type Player } from "./world.ts"; |
| 31 | 37 | 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(); | |
| 32 | 47 | |
| 33 | 48 | const SECONDS = 40; |
| 34 | 49 | const TICKS = SECONDS * TICKS_PER_SECOND; |
| @@ -153,6 +168,233 @@ test("a seeded duel in the training arena plays exactly as it played", () => { | ||
| 153 | 168 | expect(run.seconds.length).toBe(SECONDS); |
| 154 | 169 | }); |
| 155 | 170 | |
| 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 | ||
| 156 | 398 | test("the same seed is the same run, and another seed is another run", () => { |
| 157 | 399 | const four = 4 * TICKS_PER_SECOND; |
| 158 | 400 | 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 filestatic/app.ae4ebe5234.js deleted −58
Diff not shown — a line 75,645 characters wide (limit 2,000). −58
View filestatic/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
new above
offline