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

Merged opened this

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

Merged

torque/cs-engine:torque/191-the-door-s-version-is-enforced-not-remem → main 1 commit fork

  • Merged. torque/191-the-door-s-version-is-enforced-not-remem was merged into main as 0577870.

Review

Sign in to review this change.
7 files changed +726 −48
README.md +18 −3
@@ -23,6 +23,8 @@ bun run scripts/train.ts --iters 1200 # retrain the bots' poli
2323 bun run scripts/train.ts --iters 1200 --maps 1 # the way it used to train: de_forge only
2424 bun run scripts/train.ts --bench # how fast the arena steps
2525 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
2628 bun test # the check every pull runs
2729 bun run scripts/mutate-result.ts # break each rule in src/result.ts, see a test go red
2830 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
@@ -429,9 +431,10 @@ never checked itself.
429431 hash: the quantiser, the fold with a checkpoint every so many ticks, the pin
430432 and the complaint that says which span two runs parted at — the tool both
431433 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
433435 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 —
435438 `bun run scripts/pack.ts` takes a copy out of this repository and
436439 `scripts/pack.test.ts` builds the guide's game against that copy alone.
437440 `boundary.test.ts` reads the imports of every file in here and fails if one of
@@ -500,7 +503,7 @@ never checked itself.
500503 - `scripts/pack.ts` — the engine, taken out. `bun run scripts/pack.ts [dir]`
501504 writes a standalone copy into the directory it is given (`dist/engine` when it
502505 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`,
504507 an ES module, `"exports": { ".": "./index.ts" }` and no dependencies, because
505508 there are none — and `example.ts` beside them as the sample. No `.test.ts` and
506509 no `.probe.ts` come along, and the script writes only under that directory.
@@ -512,6 +515,18 @@ never checked itself.
512515 and compares its names against the surface `boundary.test.ts` pins, so a pack
513516 that dropped a file fails here rather than in somebody else's repository. The
514517 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`.
515530 - `src/client/` — the game, in modules named for one thing each: `level` (what a
516531 level *is* to this game — a grid of lettered cells, the predicate that says
517532 which letters you can stand on, the boxes, the spawns, the sites, the wall
scripts/pack.ts +14 −7
@@ -36,6 +36,7 @@
3636
3737 import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3838 import { dirname, join, relative, resolve } from "node:path";
39 import { VERSION } from "./surface.ts";
3940
4041 const ROOT = join(import.meta.dir, "..");
4142
@@ -50,14 +51,20 @@ export const NAME = "cs-engine";
5051
5152 /**
5253 * 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.
5966 */
60 export const VERSION = "0.1.0";
67 export { VERSION };
6168
6269 /** The guide, which ships: a package whose how lives elsewhere is not one. */
6370 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
789789 offers against the list the boundary test pins, so a pack that dropped a file
790790 fails there rather than here.
791791
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
792824 ## What the engine deliberately does not know
793825
794826 - **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", (
262262 });
263263
264264 /**
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
269269 * 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:
271271 *
272272 * net — `Shape`, `Policy`, `Activations`, `Packed`, `logitCount`,
273273 * `paramCount`, `tensorsOf`, `zeroPolicy`, `createPolicy`, `copyPolicy`,
@@ -294,44 +294,36 @@ test("the example imports the door and nothing else, which is what it is for", (
294294 * effects — `Effects`, `createEffects`, `pushEffect`, `expireEffects`,
295295 * `effectAt`, `resetEffects`.
296296 *
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.
299313 */
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 })();
331322
332323 test("the door offers exactly the names written down here", () => {
333324 const offered = reExportedNames(readFileSync(join(ENGINE, "index.ts"), "utf8"));
334325 expect(offered.length).toBe(new Set(offered).size);
326 expect(SURFACE.length).toBeGreaterThan(50);
335327 expect(offered.sort()).toEqual(SURFACE);
336328 });
337329
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs