Public: anyone can read this; the crew can change it.
forked from trevato/cs
The door's version is enforced, not remembered: the surface changes, the number moves, or the run fails #227
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/191-the-door-s-version-is-enforced-not-remem → main 1 commit fork
7 files
changed +726
−48
README.md +18 −3
| @@ -23,6 +23,8 @@ bun run scripts/train.ts --iters 1200 # retrain the bots' poli | ||
| 23 | 23 | bun run scripts/train.ts --iters 1200 --maps 1 # the way it used to train: de_forge only |
| 24 | 24 | bun run scripts/train.ts --bench # how fast the arena steps |
| 25 | 25 | bun run scripts/pack.ts [dir] # write the engine out as a package (default dist/engine) |
| 26 | bun run scripts/surface.ts # is the door still the surface the version was recorded for? | |
| 27 | bun run scripts/surface.ts --write 0.2.0 # record today's door as that version | |
| 26 | 28 | bun test # the check every pull runs |
| 27 | 29 | bun run scripts/mutate-result.ts # break each rule in src/result.ts, see a test go red |
| 28 | 30 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface |
| @@ -429,9 +431,10 @@ never checked itself. | ||
| 429 | 431 | hash: the quantiser, the fold with a checkpoint every so many ticks, the pin |
| 430 | 432 | and the complaint that says which span two runs parted at — the tool both |
| 431 | 433 | games pin themselves with, and the one no browser loads). |
| 432 | Four rules keep it a package, and a test holds each: the engine imports | |
| 434 | Five rules keep it a package, and a test holds each: the engine imports | |
| 433 | 435 | nothing outside itself, it needs nothing from a browser to load, everything |
| 434 | outside it comes in through one door, and it is complete on its own — | |
| 436 | outside it comes in through one door, the version of a pack moves when that | |
| 437 | door's names do (`scripts/surface.ts`, below), and it is complete on its own — | |
| 435 | 438 | `bun run scripts/pack.ts` takes a copy out of this repository and |
| 436 | 439 | `scripts/pack.test.ts` builds the guide's game against that copy alone. |
| 437 | 440 | `boundary.test.ts` reads the imports of every file in here and fails if one of |
| @@ -500,7 +503,7 @@ never checked itself. | ||
| 500 | 503 | - `scripts/pack.ts` — the engine, taken out. `bun run scripts/pack.ts [dir]` |
| 501 | 504 | writes a standalone copy into the directory it is given (`dist/engine` when it |
| 502 | 505 | is given none, which git ignores): every shipping module, `GUIDE.md`, a |
| 503 | generated `package.json` — a name, a version from one constant in the script, | |
| 506 | generated `package.json` — a name, the version from `src/engine/surface.json`, | |
| 504 | 507 | an ES module, `"exports": { ".": "./index.ts" }` and no dependencies, because |
| 505 | 508 | there are none — and `example.ts` beside them as the sample. No `.test.ts` and |
| 506 | 509 | no `.probe.ts` come along, and the script writes only under that directory. |
| @@ -512,6 +515,18 @@ never checked itself. | ||
| 512 | 515 | and compares its names against the surface `boundary.test.ts` pins, so a pack |
| 513 | 516 | that dropped a file fails here rather than in somebody else's repository. The |
| 514 | 517 | copy is Bun's: the modules import each other with `.ts` extensions. |
| 518 | - `scripts/surface.ts` — the door's surface and the version it belongs to, kept | |
| 519 | together. `src/engine/surface.json` is the record: the sorted list of names | |
| 520 | the door offers and the version stamped into a pack of them, and nothing | |
| 521 | else. `boundary.test.ts` reads it for `SURFACE`, so there is one written-down | |
| 522 | copy of the engine's API; `scripts/surface.test.ts` reads today's door and | |
| 523 | fails when the two disagree, naming the names that arrived or left and saying | |
| 524 | which part of the version that moves — a name added moves the middle number, | |
| 525 | because a game that packed an older copy still builds; a name removed or | |
| 526 | renamed moves the first, because it may not. The failure prints the one step | |
| 527 | that fixes it, `bun run scripts/surface.ts --write <version>`, which refuses a | |
| 528 | version that stands still while the surface moved. The promise itself is one | |
| 529 | section of `src/engine/GUIDE.md`. | |
| 515 | 530 | - `src/client/` — the game, in modules named for one thing each: `level` (what a |
| 516 | 531 | level *is* to this game — a grid of lettered cells, the predicate that says |
| 517 | 532 | which letters you can stand on, the boxes, the spawns, the sites, the wall |
scripts/pack.ts +14 −7
| @@ -36,6 +36,7 @@ | ||
| 36 | 36 | |
| 37 | 37 | import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; |
| 38 | 38 | import { dirname, join, relative, resolve } from "node:path"; |
| 39 | import { VERSION } from "./surface.ts"; | |
| 39 | 40 | |
| 40 | 41 | const ROOT = join(import.meta.dir, ".."); |
| 41 | 42 | |
| @@ -50,14 +51,20 @@ export const NAME = "cs-engine"; | ||
| 50 | 51 | |
| 51 | 52 | /** |
| 52 | 53 | * The version written into the generated `package.json`. It means the engine's |
| 53 | * surface — the names `index.ts` offers — and is bumped by hand when one of | |
| 54 | * them arrives, leaves or is renamed. This repository keeps no other version | |
| 55 | * and does not otherwise number its releases, so it is a constant here rather | |
| 56 | * than a guess at semver read from somewhere that does not exist: the commit is | |
| 57 | * the real identity of a pack, and this is what a reader of the package can | |
| 58 | * compare against the guide. | |
| 54 | * surface — the names `index.ts` offers — and it moves when one of them | |
| 55 | * arrives, leaves or is renamed. This repository keeps no other version and | |
| 56 | * does not otherwise number its releases: the commit is the real identity of a | |
| 57 | * pack, and this is what a reader of the package can compare against the guide. | |
| 58 | * | |
| 59 | * It is not a constant here, because a number that means a surface and sits | |
| 60 | * nowhere near one goes stale in the first pull that widens the door. It lives | |
| 61 | * in `src/engine/surface.json`, committed *with* the sorted list of names it | |
| 62 | * belongs to; `scripts/surface.ts` reads that record, `scripts/surface.test.ts` | |
| 63 | * holds today's door against it and says which part of this number to move, and | |
| 64 | * `src/engine/GUIDE.md` says what a move of it promises a game that packed an | |
| 65 | * older copy. | |
| 59 | 66 | */ |
| 60 | export const VERSION = "0.1.0"; | |
| 67 | export { VERSION }; | |
| 61 | 68 | |
| 62 | 69 | /** The guide, which ships: a package whose how lives elsewhere is not one. */ |
| 63 | 70 | export const GUIDE = "GUIDE.md"; |
scripts/surface.test.ts added +213
| @@ -0,0 +1,213 @@ | ||
| 1 | /** | |
| 2 | * The version, enforced. `src/engine/surface.json` says which names the door | |
| 3 | * offered when the version in it was set, and `scripts/pack.ts` stamps that | |
| 4 | * version into every pack. This file is what makes the number true: it reads | |
| 5 | * today's door, holds it against the record, and fails when the surface moved | |
| 6 | * and the number did not — naming the names that arrived or left, saying which | |
| 7 | * part of the version that moves, and printing the one step that records it. | |
| 8 | * | |
| 9 | * The rest of this file is that message and that arithmetic, tested on made-up | |
| 10 | * surfaces rather than on the real one, so the promises hold for a change that | |
| 11 | * has not happened yet. | |
| 12 | */ | |
| 13 | ||
| 14 | import { expect, test } from "bun:test"; | |
| 15 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 16 | import { tmpdir } from "node:os"; | |
| 17 | import { join } from "node:path"; | |
| 18 | import { | |
| 19 | COMMAND, | |
| 20 | DOOR, | |
| 21 | RECORD, | |
| 22 | VERSION, | |
| 23 | bump, | |
| 24 | changed, | |
| 25 | doorNames, | |
| 26 | isVersion, | |
| 27 | line, | |
| 28 | moved, | |
| 29 | parse, | |
| 30 | read, | |
| 31 | render, | |
| 32 | today, | |
| 33 | verdict, | |
| 34 | write, | |
| 35 | } from "./surface.ts"; | |
| 36 | import { manifest } from "./pack.ts"; | |
| 37 | import { SURFACE, reExportedNames } from "../src/engine/boundary.test.ts"; | |
| 38 | ||
| 39 | /** A record of this test's own, removed however the body ends. */ | |
| 40 | function inTemp<T>(body: (path: string) => T): T { | |
| 41 | const dir = mkdtempSync(join(tmpdir(), "surface-")); | |
| 42 | try { | |
| 43 | return body(join(dir, "surface.json")); | |
| 44 | } finally { | |
| 45 | rmSync(dir, { recursive: true, force: true }); | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | const seed = (path: string, version: string, names: string[]): string => { | |
| 50 | writeFileSync(path, render({ version, names })); | |
| 51 | return path; | |
| 52 | }; | |
| 53 | ||
| 54 | // --- the record on disk --------------------------------------------------- | |
| 55 | ||
| 56 | test("today's door is the surface the recorded version belongs to", () => { | |
| 57 | const record = read(); | |
| 58 | const why = verdict(today(), record); | |
| 59 | // Thrown rather than asserted, the way a pinned run's complaint is thrown in | |
| 60 | // `src/range/pin.test.ts`: the message is the whole point of this file, so a | |
| 61 | // failure here prints it entire rather than as a diff of two hundred names. | |
| 62 | if (why !== null) throw new Error(why); | |
| 63 | expect(today()).toEqual(record.names); | |
| 64 | expect(record.names.length).toBeGreaterThan(50); | |
| 65 | }); | |
| 66 | ||
| 67 | test("the version a pack is stamped with is the version the record pins", () => { | |
| 68 | expect(VERSION).toBe(read().version); | |
| 69 | expect(manifest().version).toBe(read().version); | |
| 70 | expect(isVersion(VERSION)).toBe(true); | |
| 71 | }); | |
| 72 | ||
| 73 | test("the record is exactly the bytes a write produces, so a diff is the names", () => { | |
| 74 | const text = readFileSync(RECORD, "utf8"); | |
| 75 | expect(text).toBe(render(read())); | |
| 76 | // Data, not prose: two keys, and a name to a line. | |
| 77 | expect(Object.keys(JSON.parse(text) as object).sort()).toEqual(["names", "version"]); | |
| 78 | // One name to a line: the two keys, the brackets, the braces and the names. | |
| 79 | expect(text.trimEnd().split("\n").length).toBe(read().names.length + 5); | |
| 80 | expect(text.endsWith("]\n}\n")).toBe(true); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the surface the record holds is the surface the engine's own test pins", () => { | |
| 84 | // `boundary.test.ts` reads this record for `SURFACE` — one written-down copy | |
| 85 | // of the API — and reads the door with its own regular expression, which may | |
| 86 | // not import this one. Both readings, on the real door, agree here or never. | |
| 87 | expect(SURFACE).toEqual(read().names); | |
| 88 | expect(doorNames(readFileSync(DOOR, "utf8"))).toEqual( | |
| 89 | reExportedNames(readFileSync(DOOR, "utf8")).sort(), | |
| 90 | ); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a type-only export is in the record, because a game imports types too", () => { | |
| 94 | const record = read().names; | |
| 95 | // `Body` and `Hull` are types; `move` is a function. All three are promises. | |
| 96 | expect(record).toContain("Body"); | |
| 97 | expect(record).toContain("Hull"); | |
| 98 | expect(record).toContain("move"); | |
| 99 | }); | |
| 100 | ||
| 101 | // --- reading the record --------------------------------------------------- | |
| 102 | ||
| 103 | test("the record is checked rather than trusted", () => { | |
| 104 | expect(parse(`{"version":"1.2.3","names":["a","b"]}`)).toEqual({ version: "1.2.3", names: ["a", "b"] }); | |
| 105 | expect(() => parse("{")).toThrow(/is not JSON/); | |
| 106 | expect(() => parse("[]")).toThrow(/is not an object/); | |
| 107 | expect(() => parse(`{"names":["a"]}`)).toThrow(/a version and a list of names/); | |
| 108 | expect(() => parse(`{"version":"1.0.0","names":["a"],"note":"why"}`)).toThrow(/a version and a list of names/); | |
| 109 | expect(() => parse(`{"version":"1.0","names":["a"]}`)).toThrow(/three numbers/); | |
| 110 | expect(() => parse(`{"version":"1.0.0","names":[]}`)).toThrow(/no names/); | |
| 111 | expect(() => parse(`{"version":"1.0.0","names":[3]}`)).toThrow(/not a name/); | |
| 112 | expect(() => parse(`{"version":"1.0.0","names":["a","a"]}`)).toThrow(/twice/); | |
| 113 | expect(() => parse(`{"version":"1.0.0","names":["b","a"]}`)).toThrow(/not sorted/); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a version is three numbers and two dots", () => { | |
| 117 | expect(isVersion("0.1.0")).toBe(true); | |
| 118 | expect(isVersion("10.20.30")).toBe(true); | |
| 119 | expect(isVersion("1.0")).toBe(false); | |
| 120 | expect(isVersion("v1.0.0")).toBe(false); | |
| 121 | expect(isVersion("1.0.0-beta")).toBe(false); | |
| 122 | }); | |
| 123 | ||
| 124 | // --- what moved, and what that asks of the number ------------------------- | |
| 125 | ||
| 126 | test("what arrived and what left, and a rename is both", () => { | |
| 127 | expect(changed(["a", "b"], ["a", "b"])).toEqual({ added: [], removed: [] }); | |
| 128 | expect(changed(["a"], ["a", "b"])).toEqual({ added: ["b"], removed: [] }); | |
| 129 | expect(changed(["a", "b"], ["a"])).toEqual({ added: [], removed: ["b"] }); | |
| 130 | expect(changed(["move"], ["walk"])).toEqual({ added: ["walk"], removed: ["move"] }); | |
| 131 | expect(moved(changed(["a"], ["a"]))).toBe(false); | |
| 132 | expect(moved(changed(["a"], ["a", "b"]))).toBe(true); | |
| 133 | }); | |
| 134 | ||
| 135 | test("a name that arrived moves the middle number; one that left moves the first", () => { | |
| 136 | expect(bump("0.1.0", { added: ["b"], removed: [] })).toBe("0.2.0"); | |
| 137 | expect(bump("1.4.2", { added: ["b"], removed: [] })).toBe("1.5.0"); | |
| 138 | expect(bump("0.1.0", { added: [], removed: ["a"] })).toBe("1.0.0"); | |
| 139 | // A rename is a removal wearing an arrival, and the removal decides. | |
| 140 | expect(bump("0.1.0", { added: ["walk"], removed: ["move"] })).toBe("1.0.0"); | |
| 141 | expect(bump("0.1.0", { added: [], removed: [] })).toBe("0.1.0"); | |
| 142 | expect(() => bump("0.1", { added: [], removed: [] })).toThrow(/not a version/); | |
| 143 | }); | |
| 144 | ||
| 145 | // --- the message ---------------------------------------------------------- | |
| 146 | ||
| 147 | test("a surface that did not move says nothing at all", () => { | |
| 148 | expect(verdict(["a", "b"], { version: "0.1.0", names: ["a", "b"] })).toBeNull(); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a name that arrived is named, with the middle number and the step to take", () => { | |
| 152 | const why = verdict(["a", "b", "sway"], { version: "0.1.0", names: ["a", "b"] }) as string; | |
| 153 | expect(why).toContain("1 name arrived: `sway`."); | |
| 154 | expect(why).not.toContain("left:"); | |
| 155 | expect(why).toContain("still builds"); | |
| 156 | expect(why).toContain("the middle number moves: 0.1.0 → 0.2.0"); | |
| 157 | expect(why).toContain(`${COMMAND} 0.2.0`); | |
| 158 | expect(why).toContain("src/engine/surface.json"); | |
| 159 | expect(why).toContain("src/engine/GUIDE.md"); | |
| 160 | }); | |
| 161 | ||
| 162 | test("a name that left is named, with the first number and the step to take", () => { | |
| 163 | const why = verdict(["a"], { version: "0.1.0", names: ["a", "b"] }) as string; | |
| 164 | expect(why).toContain("1 name left: `b`."); | |
| 165 | expect(why).toContain("stop a game that packed an older copy from building"); | |
| 166 | expect(why).toContain("the first number moves: 0.1.0 → 1.0.0"); | |
| 167 | expect(why).toContain(`${COMMAND} 1.0.0`); | |
| 168 | }); | |
| 169 | ||
| 170 | test("a rename says both halves, and the removal decides the number", () => { | |
| 171 | const why = verdict(["walk"], { version: "2.3.4", names: ["move"] }) as string; | |
| 172 | expect(why).toContain("1 name arrived: `walk`."); | |
| 173 | expect(why).toContain("1 name left: `move`."); | |
| 174 | expect(why).toContain("2.3.4 → 3.0.0"); | |
| 175 | expect(why).toContain(`${COMMAND} 3.0.0`); | |
| 176 | }); | |
| 177 | ||
| 178 | test("several names read as a sentence, not as a diff", () => { | |
| 179 | const why = verdict(["a", "b", "c", "d"], { version: "0.1.0", names: ["a"] }) as string; | |
| 180 | expect(why).toContain("3 names arrived: `b`, `c` and `d`."); | |
| 181 | }); | |
| 182 | ||
| 183 | // --- the one step --------------------------------------------------------- | |
| 184 | ||
| 185 | test("the write records today's names, sorted, against the version it was given", () => { | |
| 186 | inTemp((path) => { | |
| 187 | seed(path, "0.1.0", ["a", "b"]); | |
| 188 | const written = write(["sway", "a", "b"], "0.2.0", path); | |
| 189 | expect(written).toEqual({ version: "0.2.0", names: ["a", "b", "sway"] }); | |
| 190 | expect(read(path)).toEqual(written); | |
| 191 | expect(readFileSync(path, "utf8")).toBe(render(written)); | |
| 192 | expect(verdict(["a", "b", "sway"], read(path))).toBeNull(); | |
| 193 | }); | |
| 194 | }); | |
| 195 | ||
| 196 | test("a write that leaves the number where it was when the surface moved is refused", () => { | |
| 197 | inTemp((path) => { | |
| 198 | seed(path, "0.1.0", ["a", "b"]); | |
| 199 | expect(() => write(["a", "b", "sway"], "0.1.0", path)).toThrow(/asks for 0\.2\.0/); | |
| 200 | expect(() => write(["a"], "0.1.0", path)).toThrow(/asks for 1\.0\.0/); | |
| 201 | expect(() => write(["a", "b"], "0.1", path)).toThrow(/not a version/); | |
| 202 | // The refusals left the record alone. | |
| 203 | expect(read(path)).toEqual({ version: "0.1.0", names: ["a", "b"] }); | |
| 204 | // A surface that did not move may be rewritten at the same version. | |
| 205 | expect(write(["a", "b"], "0.1.0", path).version).toBe("0.1.0"); | |
| 206 | }); | |
| 207 | }); | |
| 208 | ||
| 209 | test("the run prints the version and how many names it covers", () => { | |
| 210 | expect(line({ version: "0.1.0", names: ["a", "b"] }, "src/engine/surface.json")).toBe( | |
| 211 | "0.1.0 — 2 names behind the door, recorded in src/engine/surface.json", | |
| 212 | ); | |
| 213 | }); |
scripts/surface.ts added +241
| @@ -0,0 +1,241 @@ | ||
| 1 | /** | |
| 2 | * The engine's surface, recorded against the version it belongs to. | |
| 3 | * | |
| 4 | * bun run scripts/surface.ts # is today's door the recorded one? | |
| 5 | * bun run scripts/surface.ts --write 0.2.0 # record today's door as that version | |
| 6 | * | |
| 7 | * `scripts/pack.ts` writes a version into the `package.json` it generates, and | |
| 8 | * that number means one thing: the names `src/engine/index.ts` offers. A number | |
| 9 | * like that is a promise to somebody who packed an older copy, and a promise | |
| 10 | * nothing checks is a lie waiting for the first pull that widens the door. So | |
| 11 | * the surface and the version are committed *together*, as one record — | |
| 12 | * `src/engine/surface.json`, the sorted list of names and the version they | |
| 13 | * belong to, and nothing else — and `scripts/surface.test.ts` holds today's | |
| 14 | * door against it on every run of the tests. | |
| 15 | * | |
| 16 | * The rule the record enforces, in one line each, and written out for a reader | |
| 17 | * of the package in `src/engine/GUIDE.md`: | |
| 18 | * | |
| 19 | * - a name arrives and none leaves → the middle number moves. A game that | |
| 20 | * packed an older copy still builds: every name it imported is still here; | |
| 21 | * - a name leaves or is renamed → the first number moves. A game that packed an | |
| 22 | * older copy may stop building, and the number is the warning; | |
| 23 | * - the surface stands still → so does the version, however much behaviour | |
| 24 | * moved underneath it. The commit is the real identity of a pack. | |
| 25 | * | |
| 26 | * Type-only exports are in the record, because a game that imports a type from | |
| 27 | * this door is a game the surface is a promise to — the same list, types and | |
| 28 | * all, that `src/engine/boundary.test.ts` has always pinned the door against. | |
| 29 | * That file reads this record for its `SURFACE`, so there is one written-down | |
| 30 | * copy of the engine's API in this repository rather than two that drift. | |
| 31 | * | |
| 32 | * It is a script and not the app: it runs when a person runs it, it never runs | |
| 33 | * at request time, it reads two files in the repository and writes exactly one | |
| 34 | * — the record — and only when told to. Nothing here reads or writes | |
| 35 | * `FORGE_APP_DATA`. | |
| 36 | */ | |
| 37 | ||
| 38 | import { readFileSync, writeFileSync } from "node:fs"; | |
| 39 | import { join, relative } from "node:path"; | |
| 40 | ||
| 41 | const ROOT = join(import.meta.dir, ".."); | |
| 42 | ||
| 43 | /** The door the surface is read off: the engine's one entry point. */ | |
| 44 | export const DOOR = join(ROOT, "src", "engine", "index.ts"); | |
| 45 | ||
| 46 | /** The committed record. Data, beside the door whose names it holds. */ | |
| 47 | export const RECORD = join(ROOT, "src", "engine", "surface.json"); | |
| 48 | ||
| 49 | /** The step a builder takes when the surface moves, as it is typed. */ | |
| 50 | export const COMMAND = "bun run scripts/surface.ts --write"; | |
| 51 | ||
| 52 | /** A surface, pinned: the names, sorted, and the version they belong to. */ | |
| 53 | export type Surface = { version: string; names: string[] }; | |
| 54 | ||
| 55 | /** Three numbers and two dots. This repository numbers nothing else. */ | |
| 56 | export const isVersion = (version: string): boolean => /^\d+\.\d+\.\d+$/.test(version); | |
| 57 | ||
| 58 | /** | |
| 59 | * The record, parsed and checked rather than trusted: a version of the right | |
| 60 | * shape, a non-empty list of distinct names in sorted order, and no third key, | |
| 61 | * because a record that grows prose stops being readable in a diff. | |
| 62 | */ | |
| 63 | export function parse(text: string, where = RECORD): Surface { | |
| 64 | let data: unknown; | |
| 65 | try { | |
| 66 | data = JSON.parse(text); | |
| 67 | } catch (error) { | |
| 68 | throw new Error(`${where} is not JSON: ${(error as Error).message}`); | |
| 69 | } | |
| 70 | if (data === null || typeof data !== "object" || Array.isArray(data)) | |
| 71 | throw new Error(`${where} is not an object`); | |
| 72 | const record = data as Record<string, unknown>; | |
| 73 | const keys = Object.keys(record).sort(); | |
| 74 | if (keys.join(",") !== "names,version") | |
| 75 | throw new Error(`${where} holds ${keys.join(", ") || "nothing"}; a record is a version and a list of names`); | |
| 76 | const { version, names } = record; | |
| 77 | if (typeof version !== "string" || !isVersion(version)) | |
| 78 | throw new Error(`${where} has version ${JSON.stringify(version)}; a version is three numbers, like 0.1.0`); | |
| 79 | if (!Array.isArray(names) || names.length === 0) | |
| 80 | throw new Error(`${where} has no names; the door offers some`); | |
| 81 | for (const name of names) | |
| 82 | if (typeof name !== "string" || name === "") | |
| 83 | throw new Error(`${where} holds ${JSON.stringify(name)}, which is not a name`); | |
| 84 | const list = names as string[]; | |
| 85 | if (new Set(list).size !== list.length) throw new Error(`${where} names something twice`); | |
| 86 | if ([...list].sort().join(",") !== list.join(",")) | |
| 87 | throw new Error(`${where} is not sorted; the record is a sorted list so a diff reads as one`); | |
| 88 | return { version, names: list }; | |
| 89 | } | |
| 90 | ||
| 91 | /** The record as it is committed. Read once: a stale one helps nobody. */ | |
| 92 | export function read(path = RECORD): Surface { | |
| 93 | return parse(readFileSync(path, "utf8"), path); | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * The version a pack is stamped with, read from the record rather than typed | |
| 98 | * beside it. `scripts/pack.ts` re-exports this as its own. | |
| 99 | */ | |
| 100 | export const VERSION: string = read().version; | |
| 101 | ||
| 102 | /** | |
| 103 | * The names a door offers: everything inside the braces of an `export { … } | |
| 104 | * from` or `export type { … } from`, under the name it leaves by, sorted. | |
| 105 | * | |
| 106 | * `src/engine/boundary.test.ts` reads the door the same way and may not import | |
| 107 | * this file — a module in the engine imports nothing outside the engine, and | |
| 108 | * that boundary is the point of it. So the reading is written twice on purpose, | |
| 109 | * and `scripts/surface.test.ts` holds the two readings against each other on | |
| 110 | * the real door, where a difference between them would matter. | |
| 111 | */ | |
| 112 | export function doorNames(source: string): string[] { | |
| 113 | const out: string[] = []; | |
| 114 | const pattern = /\bexport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["'][^"']+["']/g; | |
| 115 | for (let m = pattern.exec(source); m !== null; m = pattern.exec(source)) | |
| 116 | for (const clause of (m[1] as string).split(",")) { | |
| 117 | const name = clause.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop()?.trim(); | |
| 118 | if (name !== undefined && name !== "") out.push(name); | |
| 119 | } | |
| 120 | return out.sort(); | |
| 121 | } | |
| 122 | ||
| 123 | /** Today's surface, read off the door. */ | |
| 124 | export function today(path = DOOR): string[] { | |
| 125 | return doorNames(readFileSync(path, "utf8")); | |
| 126 | } | |
| 127 | ||
| 128 | /** What moved between a recorded surface and today's: names, not counts. */ | |
| 129 | export type Change = { added: string[]; removed: string[] }; | |
| 130 | ||
| 131 | /** Which names arrived and which left, sorted, between `was` and `now`. */ | |
| 132 | export function changed(was: readonly string[], now: readonly string[]): Change { | |
| 133 | const before = new Set(was); | |
| 134 | const after = new Set(now); | |
| 135 | return { | |
| 136 | added: now.filter((name) => !before.has(name)).sort(), | |
| 137 | removed: was.filter((name) => !after.has(name)).sort(), | |
| 138 | }; | |
| 139 | } | |
| 140 | ||
| 141 | /** Did anything move at all? A rename is both halves of one. */ | |
| 142 | export const moved = (change: Change): boolean => change.added.length + change.removed.length > 0; | |
| 143 | ||
| 144 | /** | |
| 145 | * The version that change asks for. A name that left — which is half of every | |
| 146 | * rename — can break a game that packed an older copy, so the first number | |
| 147 | * moves; a name that only arrived cannot, so the middle one does. Nothing moved | |
| 148 | * means nothing moves. | |
| 149 | */ | |
| 150 | export function bump(version: string, change: Change): string { | |
| 151 | if (!isVersion(version)) throw new Error(`${version} is not a version`); | |
| 152 | const [major = 0, minor = 0] = version.split(".").map(Number); | |
| 153 | if (change.removed.length > 0) return `${major + 1}.0.0`; | |
| 154 | if (change.added.length > 0) return `${major}.${minor + 1}.0`; | |
| 155 | return version; | |
| 156 | } | |
| 157 | ||
| 158 | /** `` `a`, `b` and `c` `` — names in a sentence, quoted the way a message is. */ | |
| 159 | const names = (list: readonly string[]): string => { | |
| 160 | const quoted = list.map((name) => `\`${name}\``); | |
| 161 | if (quoted.length <= 1) return quoted.join(""); | |
| 162 | return `${quoted.slice(0, -1).join(", ")} and ${quoted[quoted.length - 1] as string}`; | |
| 163 | }; | |
| 164 | ||
| 165 | /** | |
| 166 | * Why today's door and the record disagree, or null if they do not. A test that | |
| 167 | * only says "no" sends a reader to a diff of two hundred names: this one names | |
| 168 | * what arrived and what left, says which part of the version that moves and | |
| 169 | * why, and prints the one step that records today's surface against it. | |
| 170 | */ | |
| 171 | export function verdict(now: readonly string[], record: Surface, where = relative(ROOT, RECORD)): string | null { | |
| 172 | const change = changed(record.names, now); | |
| 173 | if (!moved(change)) return null; | |
| 174 | const next = bump(record.version, change); | |
| 175 | const lines = [ | |
| 176 | `The engine's door no longer offers the surface recorded in ${where}, which version ${record.version} belongs to.`, | |
| 177 | ]; | |
| 178 | if (change.added.length > 0) | |
| 179 | lines.push(`${change.added.length} name${change.added.length === 1 ? "" : "s"} arrived: ${names(change.added)}.`); | |
| 180 | if (change.removed.length > 0) | |
| 181 | lines.push(`${change.removed.length} name${change.removed.length === 1 ? "" : "s"} left: ${names(change.removed)}.`); | |
| 182 | lines.push( | |
| 183 | change.removed.length > 0 | |
| 184 | ? `A name that left or was renamed can stop a game that packed an older copy from building, ` + | |
| 185 | `so the first number moves: ${record.version} → ${next}.` | |
| 186 | : `Nothing left, so a game that packed an older copy still builds, ` + | |
| 187 | `and the middle number moves: ${record.version} → ${next}.`, | |
| 188 | `If that is what this pull means to do, record today's surface against that version in one step:`, | |
| 189 | ``, | |
| 190 | ` ${COMMAND} ${next}`, | |
| 191 | ``, | |
| 192 | `That rewrites ${where}. ${relative(ROOT, join(ROOT, "src", "engine", "GUIDE.md"))} says what the move promises.`, | |
| 193 | ); | |
| 194 | return lines.join("\n"); | |
| 195 | } | |
| 196 | ||
| 197 | /** The record as bytes: sorted names, one to a line, and a trailing newline. */ | |
| 198 | export function render(surface: Surface): string { | |
| 199 | return `${JSON.stringify({ version: surface.version, names: [...surface.names].sort() }, null, 2)}\n`; | |
| 200 | } | |
| 201 | ||
| 202 | /** | |
| 203 | * Write the record: today's names against the version the builder named. The | |
| 204 | * version has to move when the surface did — that is the whole promise — so a | |
| 205 | * write that leaves it where it was is refused with the number it should be. | |
| 206 | */ | |
| 207 | export function write(now: readonly string[], version: string, path = RECORD): Surface { | |
| 208 | if (!isVersion(version)) throw new Error(`${version} is not a version: three numbers, like 0.2.0`); | |
| 209 | const was = read(path); | |
| 210 | const change = changed(was.names, now); | |
| 211 | if (moved(change) && version === was.version) | |
| 212 | throw new Error( | |
| 213 | `the surface moved and ${version} is where it already was; ` + | |
| 214 | `this change asks for ${bump(was.version, change)}`, | |
| 215 | ); | |
| 216 | const surface: Surface = { version, names: [...now].sort() }; | |
| 217 | writeFileSync(path, render(surface)); | |
| 218 | return surface; | |
| 219 | } | |
| 220 | ||
| 221 | /** `cs-engine 0.1.0 — 184 names behind the door, recorded in src/engine/surface.json`. */ | |
| 222 | export function line(surface: Surface, where = relative(ROOT, RECORD)): string { | |
| 223 | return `${surface.version} — ${surface.names.length} names behind the door, recorded in ${where}`; | |
| 224 | } | |
| 225 | ||
| 226 | if (import.meta.main) { | |
| 227 | const args = process.argv.slice(2); | |
| 228 | const now = today(); | |
| 229 | if (args.length === 0) { | |
| 230 | const why = verdict(now, read()); | |
| 231 | if (why !== null) { | |
| 232 | console.error(why); | |
| 233 | process.exit(1); | |
| 234 | } | |
| 235 | console.log(line(read())); | |
| 236 | } else if (args[0] === "--write" && args.length === 2) { | |
| 237 | console.log(line(write(now, args[1] as string))); | |
| 238 | } else { | |
| 239 | throw new Error(`usage: bun run scripts/surface.ts [--write <version>]`); | |
| 240 | } | |
| 241 | } |
src/engine/GUIDE.md +32
| @@ -789,6 +789,38 @@ the copy came from — then imports the packed door and compares the names it | ||
| 789 | 789 | offers against the list the boundary test pins, so a pack that dropped a file |
| 790 | 790 | fails there rather than here. |
| 791 | 791 | |
| 792 | ## The version, and what it promises | |
| 793 | ||
| 794 | The version in that generated `package.json` means one thing: the names this | |
| 795 | door offers. It moves when one of them does, and it moves in a way you can act | |
| 796 | on without reading a diff. | |
| 797 | ||
| 798 | - A name arrives and none leaves — the middle number moves, 0.1.0 to 0.2.0. A | |
| 799 | game that packed an older copy still builds: everything it imported is still | |
| 800 | here, under the same name. | |
| 801 | - A name leaves or is renamed — the first number moves, 0.1.0 to 1.0.0. A game | |
| 802 | that packed an older copy may stop building, and the number is the warning it | |
| 803 | gets before it tries. | |
| 804 | - The surface stands still — so does the number, however much behaviour moved | |
| 805 | underneath it. A pack's real identity is the commit it came from; this number | |
| 806 | answers one question, and the question is whether your imports still resolve. | |
| 807 | A pinned run is what says behaviour held, and there is one of those per game. | |
| 808 | ||
| 809 | None of that is left to memory. `src/engine/surface.json` is the record — the | |
| 810 | sorted list of names, the version they belong to, and nothing else — and | |
| 811 | `scripts/surface.test.ts` holds today's door against it on every run of the | |
| 812 | tests. Widen or narrow the surface without moving the number and that test | |
| 813 | fails, names the names that arrived or left, says which part of the version | |
| 814 | that makes move, and prints the one step that records today's surface against | |
| 815 | it: `bun run scripts/surface.ts --write 0.2.0`, with the number it worked out | |
| 816 | for you. Run it yourself any time with `bun run scripts/surface.ts`, which | |
| 817 | prints the version and the number of names behind the door. | |
| 818 | ||
| 819 | Type-only exports are in that record and in that promise. A game that imports a | |
| 820 | type from this door is a game the surface owes something to, and the list makes | |
| 821 | no distinction between a type and a function — it is the names, as the door | |
| 822 | offers them. | |
| 823 | ||
| 792 | 824 | ## What the engine deliberately does not know |
| 793 | 825 | |
| 794 | 826 | - **The map layout.** The cell alphabet of `de_forge`, its areas, its crates, |
src/engine/boundary.test.ts +30 −38
| @@ -262,12 +262,12 @@ test("the example imports the door and nothing else, which is what it is for", ( | ||
| 262 | 262 | }); |
| 263 | 263 | |
| 264 | 264 | /** |
| 265 | * The surface. This list is the engine's public API written down: a name that | |
| 266 | * arrives, leaves or is renamed fails here and is read in the pull. Sorted the | |
| 267 | * way `Array.prototype.sort` sorts, so capitals come first — which is why a | |
| 268 | * group that lands behind the door arrives scattered through this list rather | |
| 265 | * The surface: the engine's public API written down, so that a name that | |
| 266 | * arrives, leaves or is renamed fails here and is read in the pull. The list is | |
| 267 | * sorted the way `Array.prototype.sort` sorts, so capitals come first — which | |
| 268 | * is why a group that lands behind the door arrives scattered through it rather | |
| 269 | 269 | * than as the run of lines it is in `index.ts`. So a group is also written out |
| 270 | * as a group in the comment above its names, and #106's is the first: | |
| 270 | * as a group here, and #106's is the first: | |
| 271 | 271 | * |
| 272 | 272 | * net — `Shape`, `Policy`, `Activations`, `Packed`, `logitCount`, |
| 273 | 273 | * `paramCount`, `tensorsOf`, `zeroPolicy`, `createPolicy`, `copyPolicy`, |
| @@ -294,44 +294,36 @@ test("the example imports the door and nothing else, which is what it is for", ( | ||
| 294 | 294 | * effects — `Effects`, `createEffects`, `pushEffect`, `expireEffects`, |
| 295 | 295 | * `effectAt`, `resetEffects`. |
| 296 | 296 | * |
| 297 | * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one | |
| 298 | * copy of the surface, read twice, rather than two that drift. | |
| 297 | * The list itself is `surface.json` beside this file — data, sorted, one name | |
| 298 | * to a line, so a name that moves is a line in a diff — and it is committed | |
| 299 | * with the *version* that surface belongs to, which is the version | |
| 300 | * `scripts/pack.ts` stamps into a pack. `scripts/surface.test.ts` is where the | |
| 301 | * two are held together: it reads the same door this file reads and, when the | |
| 302 | * surface has moved, says which names arrived or left, which part of the | |
| 303 | * version that moves, and the one command that records today's surface. This | |
| 304 | * file only asks whether the door and the record agree. | |
| 305 | * | |
| 306 | * Exported because `guide.test.ts` holds `GUIDE.md` against the same list, and | |
| 307 | * read out of the record rather than typed again beside it: one written-down | |
| 308 | * copy of the surface in this repository, read three times, rather than copies | |
| 309 | * that drift. Read with `readFileSync` and not an import because a module in | |
| 310 | * here may import nothing outside the engine, and because the record is data | |
| 311 | * about the package rather than a module of it — it stays in this repository | |
| 312 | * and `scripts/pack.ts` does not pack it. | |
| 299 | 313 | */ |
| 300 | export const SURFACE = [ | |
| 301 | "Activations", "Adam", "Body", "Box", "Builder", "Burst", "Button", "ButtonOptions", | |
| 302 | "CHECKPOINT_CHARS", "Camera", "Complaint", "Controls", "Ctor", "DEFAULT_HYPER", "ESCAPE", | |
| 303 | "Effects", "Escape", "EscapeHost", | |
| 304 | "FakeCanvas", | |
| 305 | "FakeDocument", "FakeElement", "FakeKeyboard", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", "Fold", | |
| 306 | "Folded", "Frames", "Fullscreen", "FullscreenHost", "FullscreenOwner", "FullscreenTarget", | |
| 307 | "Grads", "Grid", "Hit", "Host", "Hull", "Hyper", "Instance", "KeyEventish", "KeyboardLock", | |
| 308 | "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", "MouseEventish", "MoveTuning", "Options", | |
| 309 | "Overlay", "Packed", "PageOptions", "Paint", "Painter", "Pin", "Policy", "QUANTUM", | |
| 310 | "Renderer", "RendererAssets", "Rollout", "Scene", "SceneCapacity", "Screen", "Shader", "Shape", | |
| 311 | "SoundDevice", "Stick", "Surface", "Tone", "TouchEventish", "Touchish", "UpdateStats", "Vec3", | |
| 312 | "Wish", "accelerate", "add", "addBox", "airAccelerate", "angleDelta", "angleVectors", | |
| 313 | "applyGrads", "attachButtons", "attenuate", | |
| 314 | "backward", "builder", "cellAt", "clamp", "clearGrads", "colOf", "compare", | |
| 315 | "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createEffects", | |
| 316 | "createEscape", "createFullscreen", | |
| 317 | "createLoop", "createPolicy", "createRenderer", "createRollout", "createScene", | |
| 318 | "createSoundDevice", | |
| 319 | "cross", "deg", "dist", "dist2d", "dot", "effectAt", "expireEffects", "eyeHeight", "fakePage", | |
| 320 | "fbm", "findPath", "finish", | |
| 321 | "fold", "forward", "greedy", "groundUnder", "hash", "headOffset", "hullHeight", "inside", | |
| 322 | "length", "length2d", "lerp", | |
| 323 | "logitCount", "mat4", "mergeSolid", "modelMatrix", "move", "mul", "multiply", "newActivations", | |
| 324 | "nextInstance", "noise", "normalize", "overlay", "pack", "paintAtlas", "painter", "panOf", | |
| 325 | "paramCount", "perspective", "pushEffect", "pushLine", "quadMesh", "quantise", "rad", "rayBox", | |
| 326 | "region", "resetEffects", "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", | |
| 327 | "softmax", "stuck", "sub", | |
| 328 | "tensorsOf", "traceWorld", "unpack", "update", "v3", "viewMatrix", "visible", "worldOf", | |
| 329 | "zeroGrads", "zeroPolicy", | |
| 330 | ]; | |
| 314 | const RECORD = "surface.json"; | |
| 315 | ||
| 316 | export const SURFACE: string[] = (() => { | |
| 317 | const record: unknown = JSON.parse(readFileSync(join(ENGINE, RECORD), "utf8")); | |
| 318 | const names = (record as { names?: unknown }).names; | |
| 319 | if (!Array.isArray(names) || names.length === 0) throw new Error(`${RECORD} holds no list of names`); | |
| 320 | return names as string[]; | |
| 321 | })(); | |
| 331 | 322 | |
| 332 | 323 | test("the door offers exactly the names written down here", () => { |
| 333 | 324 | const offered = reExportedNames(readFileSync(join(ENGINE, "index.ts"), "utf8")); |
| 334 | 325 | expect(offered.length).toBe(new Set(offered).size); |
| 326 | expect(SURFACE.length).toBeGreaterThan(50); | |
| 335 | 327 | expect(offered.sort()).toEqual(SURFACE); |
| 336 | 328 | }); |
| 337 | 329 |
src/engine/surface.json added +178
| @@ -0,0 +1,178 @@ | ||
| 1 | { | |
| 2 | "version": "0.1.0", | |
| 3 | "names": [ | |
| 4 | "Activations", | |
| 5 | "Adam", | |
| 6 | "Body", | |
| 7 | "Box", | |
| 8 | "Builder", | |
| 9 | "Burst", | |
| 10 | "Button", | |
| 11 | "ButtonOptions", | |
| 12 | "CHECKPOINT_CHARS", | |
| 13 | "Camera", | |
| 14 | "Complaint", | |
| 15 | "Controls", | |
| 16 | "Ctor", | |
| 17 | "DEFAULT_HYPER", | |
| 18 | "ESCAPE", | |
| 19 | "Effects", | |
| 20 | "Escape", | |
| 21 | "EscapeHost", | |
| 22 | "FakeCanvas", | |
| 23 | "FakeDocument", | |
| 24 | "FakeElement", | |
| 25 | "FakeKeyboard", | |
| 26 | "FakePage", | |
| 27 | "FakePaint", | |
| 28 | "FakeTarget", | |
| 29 | "FakeWindow", | |
| 30 | "Fold", | |
| 31 | "Folded", | |
| 32 | "Frames", | |
| 33 | "Fullscreen", | |
| 34 | "FullscreenHost", | |
| 35 | "FullscreenOwner", | |
| 36 | "FullscreenTarget", | |
| 37 | "Grads", | |
| 38 | "Grid", | |
| 39 | "Hit", | |
| 40 | "Host", | |
| 41 | "Hull", | |
| 42 | "Hyper", | |
| 43 | "Instance", | |
| 44 | "KeyEventish", | |
| 45 | "KeyboardLock", | |
| 46 | "Listens", | |
| 47 | "LockOwner", | |
| 48 | "Loop", | |
| 49 | "LoopOptions", | |
| 50 | "Mat4", | |
| 51 | "Mesh", | |
| 52 | "MouseEventish", | |
| 53 | "MoveTuning", | |
| 54 | "Options", | |
| 55 | "Overlay", | |
| 56 | "Packed", | |
| 57 | "PageOptions", | |
| 58 | "Paint", | |
| 59 | "Painter", | |
| 60 | "Pin", | |
| 61 | "Policy", | |
| 62 | "QUANTUM", | |
| 63 | "Renderer", | |
| 64 | "RendererAssets", | |
| 65 | "Rollout", | |
| 66 | "Scene", | |
| 67 | "SceneCapacity", | |
| 68 | "Screen", | |
| 69 | "Shader", | |
| 70 | "Shape", | |
| 71 | "SoundDevice", | |
| 72 | "Stick", | |
| 73 | "Surface", | |
| 74 | "Tone", | |
| 75 | "TouchEventish", | |
| 76 | "Touchish", | |
| 77 | "UpdateStats", | |
| 78 | "Vec3", | |
| 79 | "Wish", | |
| 80 | "accelerate", | |
| 81 | "add", | |
| 82 | "addBox", | |
| 83 | "airAccelerate", | |
| 84 | "angleDelta", | |
| 85 | "angleVectors", | |
| 86 | "applyGrads", | |
| 87 | "attachButtons", | |
| 88 | "attenuate", | |
| 89 | "backward", | |
| 90 | "builder", | |
| 91 | "cellAt", | |
| 92 | "clamp", | |
| 93 | "clearGrads", | |
| 94 | "colOf", | |
| 95 | "compare", | |
| 96 | "computeAdvantages", | |
| 97 | "copyPolicy", | |
| 98 | "createAdam", | |
| 99 | "createControls", | |
| 100 | "createEffects", | |
| 101 | "createEscape", | |
| 102 | "createFullscreen", | |
| 103 | "createLoop", | |
| 104 | "createPolicy", | |
| 105 | "createRenderer", | |
| 106 | "createRollout", | |
| 107 | "createScene", | |
| 108 | "createSoundDevice", | |
| 109 | "cross", | |
| 110 | "deg", | |
| 111 | "dist", | |
| 112 | "dist2d", | |
| 113 | "dot", | |
| 114 | "effectAt", | |
| 115 | "expireEffects", | |
| 116 | "eyeHeight", | |
| 117 | "fakePage", | |
| 118 | "fbm", | |
| 119 | "findPath", | |
| 120 | "finish", | |
| 121 | "fold", | |
| 122 | "forward", | |
| 123 | "greedy", | |
| 124 | "groundUnder", | |
| 125 | "hash", | |
| 126 | "headOffset", | |
| 127 | "hullHeight", | |
| 128 | "inside", | |
| 129 | "length", | |
| 130 | "length2d", | |
| 131 | "lerp", | |
| 132 | "logitCount", | |
| 133 | "mat4", | |
| 134 | "mergeSolid", | |
| 135 | "modelMatrix", | |
| 136 | "move", | |
| 137 | "mul", | |
| 138 | "multiply", | |
| 139 | "newActivations", | |
| 140 | "nextInstance", | |
| 141 | "noise", | |
| 142 | "normalize", | |
| 143 | "overlay", | |
| 144 | "pack", | |
| 145 | "paintAtlas", | |
| 146 | "painter", | |
| 147 | "panOf", | |
| 148 | "paramCount", | |
| 149 | "perspective", | |
| 150 | "pushEffect", | |
| 151 | "pushLine", | |
| 152 | "quadMesh", | |
| 153 | "quantise", | |
| 154 | "rad", | |
| 155 | "rayBox", | |
| 156 | "region", | |
| 157 | "resetEffects", | |
| 158 | "resetScene", | |
| 159 | "rng", | |
| 160 | "rowOf", | |
| 161 | "sample", | |
| 162 | "scale", | |
| 163 | "segmentLength", | |
| 164 | "softmax", | |
| 165 | "stuck", | |
| 166 | "sub", | |
| 167 | "tensorsOf", | |
| 168 | "traceWorld", | |
| 169 | "unpack", | |
| 170 | "update", | |
| 171 | "v3", | |
| 172 | "viewMatrix", | |
| 173 | "visible", | |
| 174 | "worldOf", | |
| 175 | "zeroGrads", | |
| 176 | "zeroPolicy" | |
| 177 | ] | |
| 178 | } |
new above
offline