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

forked from trevato/cs · proposes

Two games, one engine, no sharing — and a test that says so in both directions #92

Merged opened this

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

Merged

torque/cs-engine:torque/68-two-games-one-engine-no-sharing-and-a-te → main 1 commit fork

  • Merged. torque/68-two-games-one-engine-no-sharing-and-a-te was merged into main as cacb7ec.

Review

Sign in to review this change.
8 files changed +298 −136
README.md +24 −8
@@ -168,7 +168,7 @@ subtends — and the panel says which of the two you are looking at.
168168 module that is not behind the door, and any change to that list of names that
169169 is not also a change to the list written out in the test. One exception, and
170170 it is written down there too: a `.test.ts` may import another `.test.ts`, which
171 is how `src/range/independence.test.ts` reuses the source-reading helpers
171 is how `src/independence.test.ts` reuses the source-reading helpers
172172 rather than keeping a second copy of them.
173173 - `src/engine/GUIDE.md` — how to build a game on it: what a game supplies (an
174174 entry, a host, a bindings table, a tick rate, its own recipes and its own
@@ -268,11 +268,9 @@ subtends — and the panel says which of the two you are looking at.
268268 is imported by the entry, so the bundle has not grown by a byte; `level.test.ts`
269269 proves the shell has no seam a bullet can leave through and `paint.test.ts`
270270 pins the atlas byte for byte. No movement and no WebGL yet — those need the
271 physics and the renderer, and #13 builds the finale on this. `independence.test.ts` reads every source in
272 here and fails on an import that leaves those three places, reusing the
273 `sources`/`specifiersOf` helpers from `src/engine/boundary.test.ts` rather than a
274 second copy of the rule — a test may read another test's helpers, which is the
275 one path past the door, because a `.test.ts` is in no bundle; `binding.test.ts`,
271 physics and the renderer, and #13 builds the finale on this.
272 `src/independence.test.ts` reads every source in here and fails on an import
273 that leaves those three places; `binding.test.ts`,
276274 `targets.test.ts` and `readout.test.ts` pin the table, the intent, the game and
277275 the drawing headlessly — a lane with its randomness dialled flat puts a plate
278276 where arithmetic knows, so a ray down the barrel hits, a ray 5° wide of it
@@ -295,10 +293,28 @@ subtends — and the panel says which of the two you are looking at.
295293 `pin.test.ts` — so a renderer, an atlas or a movement tuning that changes the
296294 game says so, naming the second of the run where it changed. `src/pages/range.ts` is its page: its own title, prose and overlay
297295 through the same shell, with no database, no endpoint and no cookie behind it.
298 - `src/rl/` — training, which the game never imports: `arena` is the environment
296 - `src/rl/` — training, which no game module imports: `arena` is the environment
299297 (a real duel on de_forge with the round and the economy taken out) and `ppo` is
300298 the algorithm, with its backward pass checked against finite differences.
301 `scripts/train.ts` runs them and writes `src/client/weights.ts`.
299 `scripts/train.ts` runs them and writes `src/client/weights.ts`. It is the
300 *shooter's* training and `src/independence.test.ts` counts it as the shooter:
301 it reads `src/client/` module by module because that game is the environment,
302 and for the same reason it may not read `src/range/`.
303 - `src/independence.test.ts` — **two games, one engine, no sharing, and this is
304 the test that says so**, in both directions: a game's sources may not import
305 another game's — the shooter reaching into the range fails here by name
306 (`src/client/world.ts imports "../range/targets.ts"; a game may not import
307 another game`) exactly as the range reaching into the shooter does — and the
308 range is held to the stricter rule it exists for on top of that: a module
309 under `src/range/` may import the engine's door, `src/pages/shell.ts` and its
310 own siblings, and nothing else. Both rules read the sources with the
311 `outsideSources`/`sources`/`specifiersOf` helpers from
312 `src/engine/boundary.test.ts` rather than a second copy of them — a test may
313 read another test's helpers, which is the one path past the door, because a
314 `.test.ts` is in no bundle. Who owns which directory comes from the registry
315 (`dirname(entry)`), so a third game is covered by the entry that adds it.
316 `src/server.ts` and `src/pages/` belong to no game and are held to neither
317 rule: the app is allowed to know about every game on it.
302318 - `src/client/replay.ts` — the golden digest: a seeded round and a seeded duel
303319 in the arena, each stepped through one fixed script of inputs and hashed tick
304320 by tick (positions, velocities, view angles, health, armour, duck and
src/client/stage.test-helper.ts +1 −1
@@ -15,7 +15,7 @@
1515 *
1616 * It ships in no bundle: only `main.test.ts` imports it. `src/range/dom.ts` is
1717 * the same idea for the other game — it cannot import this one, because
18 * `independence.test.ts` forbids the range from reaching into `src/client/`,
18 * `src/independence.test.ts` forbids either game from reaching into the other,
1919 * but a third game copies whichever is nearer and adds its own ids.
2020 */
2121
src/engine/boundary.test.ts +6 −6
@@ -93,10 +93,10 @@ export function outsideSources(dirs = OUTSIDE): string[] {
9393 /**
9494 * Why this file outside the engine may not import this specifier, or null if it
9595 * may. Only one path into the engine is a path: `index.ts`, the door. The one
96 * exception is a test reading another test's helpers — `src/range/` checks its
97 * own boundary with the walk and the regular expression in this file rather
98 * than a second copy of them — because the door is about what ships, and a
99 * `.test.ts` is in no bundle.
96 * exception is a test reading another test's helpers — `src/independence.test.ts`
97 * holds both games apart with the walk and the regular expression in this file
98 * rather than a second copy of them — because the door is about what ships, and
99 * a `.test.ts` is in no bundle.
100100 */
101101 export function reachesPastTheDoor(file: string, specifier: string): string | null {
102102 if (!specifier.startsWith(".")) return null;
@@ -190,9 +190,9 @@ test("the door rule is about the door and nothing else", () => {
190190 expect(reachesPastTheDoor("src/client/world.ts", "bun:test")).toBeNull();
191191 expect(reachesPastTheDoor("scripts/build.ts", "../src/engine/trace.ts")).toMatch(/door/);
192192 // A test may read another test's helpers; a module may not, test or not.
193 expect(reachesPastTheDoor("src/range/independence.test.ts", "../engine/boundary.test.ts")).toBeNull();
193 expect(reachesPastTheDoor("src/independence.test.ts", "./engine/boundary.test.ts")).toBeNull();
194194 expect(reachesPastTheDoor("src/range/main.ts", "../engine/boundary.test.ts")).toMatch(/door/);
195 expect(reachesPastTheDoor("src/range/independence.test.ts", "../engine/math.ts")).toMatch(/door/);
195 expect(reachesPastTheDoor("src/independence.test.ts", "./engine/math.ts")).toMatch(/door/);
196196 });
197197
198198 test("every module in the engine is behind the door, and the door holds nothing else", () => {
src/engine/guide.test.ts +1 −1
@@ -37,7 +37,7 @@
3737 * The list of exports is `SURFACE` from `boundary.test.ts`, imported rather
3838 * than parsed a second time: one copy of the door's surface in this repository.
3939 * Importing it registers that file's tests here again, which costs a
40 * millisecond and is what `purity.test.ts` and `src/range/independence.test.ts`
40 * millisecond and is what `purity.test.ts` and `src/independence.test.ts`
4141 * already pay for the same reason.
4242 */
4343
src/independence.test.ts added +264
@@ -0,0 +1,264 @@
1 /**
2 * Two games, one engine, no sharing — and this is the test that says so, both
3 * ways round. It holds two rules, and they are different sizes on purpose.
4 *
5 * The first is symmetric and is the direction's whole claim: **a game's sources
6 * may not import another game's**. The shooter reaching into the range is as
7 * much a lie as the range reaching into the shooter, and until this file existed
8 * only the second half was read. It is checked from the repository root over
9 * every source outside the engine, so it covers both games with one walk and
10 * covers the third the day a third entry is added to `src/games.ts`.
11 *
12 * The second is the range's alone and is stricter: a module under `src/range/`
13 * may import the engine, `src/pages/shell.ts` and its own siblings, and nothing
14 * else — no `src/rl/`, no `src/result.ts`, no package, because there is nothing
15 * to install. The shooter cannot be held to that and should not be: it is the
16 * app's own game and it legitimately shares the server's `src/result.ts` with
17 * the server. The range is the proof that the engine suffices, so it pays the
18 * higher price, and the pair of rules lives in one file because they are one
19 * question — who may import whom, once you are outside the engine.
20 *
21 * What is deliberately *not* refused: `src/server.ts` imports both games' pages
22 * and `src/pages/` is the chrome both are served inside. Neither belongs to a
23 * game, so neither is held to a rule about game sources; the app is allowed to
24 * know about every game on it, which is what an app is.
25 *
26 * Both rules read the sources with the same three helpers `src/engine/
27 * boundary.test.ts` uses, imported rather than copied: one walk and one regular
28 * expression for imports in this repository, not three that can drift apart.
29 * The price is that the engine's boundary tests are registered again here — they
30 * run twice and cost a millisecond, which is cheaper than a second copy of a
31 * rule.
32 */
33
34 import { expect, test } from "bun:test";
35 import { readFileSync } from "node:fs";
36 import { dirname, join, relative, resolve } from "node:path";
37 import { outsideSources, sources, specifiersOf } from "./engine/boundary.test.ts";
38 import { GAMES } from "./games.ts";
39
40 const SRC = import.meta.dir;
41 const ROOT = resolve(SRC, "..");
42 const RANGE = join(SRC, "range");
43 const ENGINE = join(SRC, "engine");
44 const SHELL = join(SRC, "pages", "shell.ts");
45 const BUILD = resolve(ROOT, "scripts", "build.ts");
46
47 /**
48 * The directories that are a game's without being the game: today `src/rl/` is
49 * the shooter's and nothing is the range's. The training is the arena — a
50 * de_forge duel with the round and the economy taken out — and PPO over it, and
51 * it imports `src/client/` module by module because the environment *is* that
52 * game. So it counts as the shooter here: it may read `src/client/`, and by the
53 * same token it may not read `src/range/`, because the range is not its game
54 * and a training run reaching for the other game's plates is exactly the
55 * sharing this file exists to refuse.
56 */
57 const ALSO: Record<string, readonly string[]> = { app: ["src/rl"] };
58
59 /**
60 * The game a source belongs to, or null if it belongs to none. A game's own
61 * directory is its registry entry with the file name taken off, so a third game
62 * is covered by the entry that adds it and by nothing else.
63 */
64 export function gameOf(file: string): string | null {
65 for (const game of GAMES)
66 for (const dir of [dirname(game.entry), ...(ALSO[game.name] ?? [])])
67 if (file === dir || file.startsWith(`${dir}/`)) return game.name;
68 return null;
69 }
70
71 /**
72 * Why this file may not import this specifier, or null if it may. Both are
73 * written the way `outsideSources` hands them out — a path from the repository
74 * root — and so is the message, because the point of a failure here is that it
75 * names the offender and the line to delete.
76 *
77 * A specifier that is not a path is another rule's business: the range's is
78 * below, and the engine's is `leavesEngine`.
79 */
80 export function importsAnotherGame(file: string, specifier: string): string | null {
81 const mine = gameOf(file);
82 if (mine === null || !specifier.startsWith(".")) return null;
83 const theirs = gameOf(relative(ROOT, resolve(ROOT, dirname(file), specifier)));
84 if (theirs === null || theirs === mine) return null;
85 return `${file} imports "${specifier}"; a game may not import another game`;
86 }
87
88 test("both games have the sources this test is about, and the app has none", () => {
89 const files = outsideSources();
90 const of = (game: string): string[] => files.filter((f) => gameOf(f) === game);
91 expect(of("app")).toContain("src/client/world.ts");
92 expect(of("app")).toContain("src/rl/arena.ts");
93 expect(of("range")).toContain("src/range/targets.ts");
94 expect(of("range")).toContain("src/range/main.ts");
95 // The app is not a game: it is what the games are served by.
96 expect(gameOf("src/server.ts")).toBeNull();
97 expect(gameOf("src/pages/shell.ts")).toBeNull();
98 expect(gameOf("src/pages/range.ts")).toBeNull();
99 expect(gameOf("src/games.ts")).toBeNull();
100 expect(gameOf("scripts/build.ts")).toBeNull();
101 });
102
103 test("no game's sources import another game's, in either direction", () => {
104 const offences: string[] = [];
105 for (const file of outsideSources()) {
106 const source = readFileSync(join(ROOT, file), "utf8");
107 for (const specifier of specifiersOf(source)) {
108 const why = importsAnotherGame(file, specifier);
109 if (why !== null) offences.push(why);
110 }
111 }
112 expect(offences).toEqual([]);
113 });
114
115 test("the rule is refused both ways round, and names who did it", () => {
116 expect(importsAnotherGame("src/client/world.ts", "../range/targets.ts")).toBe(
117 `src/client/world.ts imports "../range/targets.ts"; a game may not import another game`,
118 );
119 expect(importsAnotherGame("src/range/main.ts", "../client/world.ts")).toBe(
120 `src/range/main.ts imports "../client/world.ts"; a game may not import another game`,
121 );
122 expect(importsAnotherGame("src/client/scene.test.ts", "../range/level.ts")).toMatch(/another game/);
123 expect(importsAnotherGame("src/range/readout.ts", "../client/hud.ts")).toMatch(/another game/);
124 // The training is the shooter's, so the range is another game to it too.
125 expect(importsAnotherGame("src/rl/arena.ts", "../range/targets.ts")).toBe(
126 `src/rl/arena.ts imports "../range/targets.ts"; a game may not import another game`,
127 );
128 expect(importsAnotherGame("src/range/pin.ts", "../rl/arena.ts")).toMatch(/another game/);
129 });
130
131 // A title that ends on the keyword, with the closing quote right behind it,
132 // reads as an import to the helper above — a false positive this test would
133 // then hold against itself. So no title here ends on one.
134 test("the engine's door, the shell, a sibling and the app are what a game may reach for", () => {
135 expect(importsAnotherGame("src/client/world.ts", "../engine/index.ts")).toBeNull();
136 expect(importsAnotherGame("src/range/main.ts", "../engine/index.ts")).toBeNull();
137 expect(importsAnotherGame("src/client/main.ts", "../pages/shell.ts")).toBeNull();
138 expect(importsAnotherGame("src/range/main.ts", "../pages/shell.ts")).toBeNull();
139 expect(importsAnotherGame("src/client/world.ts", "./physics.ts")).toBeNull();
140 expect(importsAnotherGame("src/range/main.ts", "./binding.ts")).toBeNull();
141 expect(importsAnotherGame("src/client/main.test.ts", "../result.ts")).toBeNull();
142 expect(importsAnotherGame("src/client/world.ts", "gl-matrix")).toBeNull();
143 // The training and the shooter are one game between them, both ways.
144 expect(importsAnotherGame("src/rl/arena.ts", "../client/world.ts")).toBeNull();
145 expect(importsAnotherGame("src/client/replay.test.ts", "../rl/arena.ts")).toBeNull();
146 // The app is served both games and is held to neither rule.
147 expect(importsAnotherGame("src/server.ts", "./pages/range.ts")).toBeNull();
148 expect(importsAnotherGame("src/pages/games.ts", "../games.ts")).toBeNull();
149 expect(importsAnotherGame("scripts/build.ts", "../src/range/main.ts")).toBeNull();
150 });
151
152 test("a shooter file that reached into the range is caught by reading it", () => {
153 // The keyword is spelled at run time so this fixture is not itself an import
154 // the test above would read out of this file and hold against it.
155 const F = "fr" + "om";
156 const source = [
157 `import { createWorld } ${F} "./world.ts";`,
158 `import { Camera } ${F} "../engine/index.ts";`,
159 `import { popUp } ${F} "../range/targets.ts";`,
160 ].join("\n");
161 const offences = specifiersOf(source)
162 .map((specifier) => importsAnotherGame("src/client/scene.ts", specifier))
163 .filter((why) => why !== null);
164 expect(offences).toEqual([
165 `src/client/scene.ts imports "../range/targets.ts"; a game may not import another game`,
166 ]);
167 });
168
169 /**
170 * The range's own rule, which is stricter than the one above and is why the
171 * range exists: a module under `src/range/` may import the engine, the page
172 * shell and its own siblings, and nothing else. Not `src/client/` — a shortcut
173 * import from the shooter would make the whole direction untrue, and it is one
174 * line nobody would notice in review — not `src/rl/`, and no package, because
175 * there is nothing to install.
176 *
177 * Which module of the engine is a second question, and `boundary.test.ts`
178 * answers it: the way in is `src/engine/index.ts`, the door, and these files go
179 * through it. The rule below is the wider one — where an import may point — so
180 * it still passes a deeper path; nothing in the range writes one.
181 */
182
183 /** `sources` names what it finds relative to the engine; these want their own names. */
184 const rangeSources = (): string[] => sources(RANGE).map((p) => relative(RANGE, resolve(ENGINE, p)));
185
186 /** Why this file under `src/range/` may not import this specifier, or null if it may. */
187 export function leavesRange(file: string, specifier: string): string | null {
188 const isTest = file.endsWith(".test.ts");
189 if (!specifier.startsWith(".")) {
190 if (isTest && (specifier === "bun:test" || specifier.startsWith("node:"))) return null;
191 return `"${specifier}" is not a path: the range installs nothing`;
192 }
193 const target = resolve(RANGE, join(file, ".."), specifier);
194 const sibling = relative(RANGE, target);
195 if (sibling !== "" && !sibling.startsWith("..")) return null;
196 const fromSrc = relative(ENGINE, target);
197 if (fromSrc !== "" && !fromSrc.startsWith("..")) return null;
198 if (target === SHELL) return null;
199 // A test may ask the build script where the bundles are and what one is
200 // called — `bundle.test.ts` boots the committed artifact — because a
201 // `.test.ts` ships in no bundle. A module that ships may not.
202 if (isTest && target === BUILD) return null;
203 return `"${specifier}" resolves outside src/range/, src/engine/ and src/pages/shell.ts`;
204 }
205
206 test("the range has the sources this test is about", () => {
207 const files = rangeSources();
208 expect(files).toContain("main.ts");
209 expect(files).toContain("binding.ts");
210 expect(files).toContain("targets.test.ts");
211 });
212
213 test("no range module imports the shooter, the training, or a package", () => {
214 const offences: string[] = [];
215 for (const file of rangeSources()) {
216 const source = readFileSync(join(RANGE, file), "utf8");
217 for (const specifier of specifiersOf(source)) {
218 const why = leavesRange(file, specifier);
219 if (why !== null) offences.push(`${file}: ${why}`);
220 }
221 }
222 expect(offences).toEqual([]);
223 });
224
225 test("the rule catches the import it exists to catch", () => {
226 // The shortcut that would make the second game a copy of the first.
227 expect(leavesRange("main.ts", "../client/world.ts")).toMatch(/outside/);
228 expect(leavesRange("main.ts", "../client/binding.ts")).toMatch(/outside/);
229 expect(leavesRange("main.ts", "../rl/arena.ts")).toMatch(/outside/);
230 expect(leavesRange("main.ts", "../db.ts")).toMatch(/outside/);
231 expect(leavesRange("main.ts", "../pages/game.ts")).toMatch(/outside/);
232 expect(leavesRange("main.ts", "gl-matrix")).toMatch(/installs nothing/);
233 expect(leavesRange("main.ts", "node:fs")).toMatch(/installs nothing/);
234 expect(leavesRange("main.ts", "bun:test")).toMatch(/installs nothing/);
235 });
236
237 test("the engine, the shell and a sibling are what the range may reach for", () => {
238 expect(leavesRange("main.ts", "../engine/loop.ts")).toBeNull();
239 expect(leavesRange("main.ts", "../engine/math.ts")).toBeNull();
240 expect(leavesRange("main.ts", "./binding.ts")).toBeNull();
241 expect(leavesRange("sub/deep.ts", "../readout.ts")).toBeNull();
242 expect(leavesRange("sub/deep.ts", "../../engine/trace.ts")).toBeNull();
243 expect(leavesRange("main.ts", "../pages/shell.ts")).toBeNull();
244 expect(leavesRange("binding.test.ts", "bun:test")).toBeNull();
245 expect(leavesRange("bundle.test.ts", "node:path")).toBeNull();
246 expect(leavesRange("bundle.test.ts", "../../scripts/build.ts")).toBeNull();
247 // A test file's licence is its own: a module that ships may not reach for node,
248 // nor for the script that bundles it.
249 expect(leavesRange("main.ts", "node:path")).toMatch(/installs nothing/);
250 expect(leavesRange("main.ts", "../../scripts/build.ts")).toMatch(/outside/);
251 });
252
253 test("a range file that reached into the shooter is caught by reading it", () => {
254 const F = "fr" + "om";
255 const source = [
256 `import { createLoop } ${F} "../engine/loop.ts";`,
257 `import { RANGE_KEYS } ${F} "./binding.ts";`,
258 `import { createWorld } ${F} "../client/world.ts";`,
259 ].join("\n");
260 const offences = specifiersOf(source)
261 .map((specifier) => leavesRange("main.ts", specifier))
262 .filter((why) => why !== null);
263 expect(offences).toEqual([`"../client/world.ts" resolves outside src/range/, src/engine/ and src/pages/shell.ts`]);
264 });
src/range/independence.test.ts deleted −118
@@ -1,118 +0,0 @@
1 /**
2 * The rule that makes the range a proof rather than a second copy: a module
3 * under `src/range/` may import the engine, the page shell and its own
4 * siblings, and nothing else. Not `src/client/` — a shortcut import from the
5 * shooter would make the whole direction untrue, and it is one line nobody
6 * would notice in review — not `src/rl/`, and no package, because there is
7 * nothing to install.
8 *
9 * Which module of the engine is a second question, and `boundary.test.ts`
10 * answers it: the way in is `src/engine/index.ts`, the door, and these files go
11 * through it. The rule below is the wider one — where an import may point — so
12 * it still passes a deeper path; nothing in here writes one.
13 *
14 * It reads the sources with the same two helpers `src/engine/boundary.test.ts`
15 * uses, imported rather than copied: one regular expression for imports in this
16 * repository, not two that can drift apart. The price is that the engine's four
17 * boundary tests are registered again here — they run twice and cost a
18 * millisecond, which is cheaper than a second copy of the rule.
19 */
20
21 import { expect, test } from "bun:test";
22 import { readFileSync } from "node:fs";
23 import { join, relative, resolve } from "node:path";
24 import { sources, specifiersOf } from "../engine/boundary.test.ts";
25
26 const RANGE = import.meta.dir;
27 const SRC = resolve(RANGE, "..");
28 const ENGINE = join(SRC, "engine");
29 const SHELL = join(SRC, "pages", "shell.ts");
30 const BUILD = resolve(SRC, "..", "scripts", "build.ts");
31
32 /** `sources` names what it finds relative to the engine; these want their own names. */
33 const rangeSources = (): string[] => sources(RANGE).map((p) => relative(RANGE, resolve(ENGINE, p)));
34
35 /** Why this file under `src/range/` may not import this specifier, or null if it may. */
36 export function leavesRange(file: string, specifier: string): string | null {
37 const isTest = file.endsWith(".test.ts");
38 if (!specifier.startsWith(".")) {
39 if (isTest && (specifier === "bun:test" || specifier.startsWith("node:"))) return null;
40 return `"${specifier}" is not a path: the range installs nothing`;
41 }
42 const target = resolve(RANGE, join(file, ".."), specifier);
43 const sibling = relative(RANGE, target);
44 if (sibling !== "" && !sibling.startsWith("..")) return null;
45 const fromSrc = relative(ENGINE, target);
46 if (fromSrc !== "" && !fromSrc.startsWith("..")) return null;
47 if (target === SHELL) return null;
48 // A test may ask the build script where the bundles are and what one is
49 // called — `bundle.test.ts` boots the committed artifact — because a
50 // `.test.ts` ships in no bundle. A module that ships may not.
51 if (isTest && target === BUILD) return null;
52 return `"${specifier}" resolves outside src/range/, src/engine/ and src/pages/shell.ts`;
53 }
54
55 test("the range has the sources this test is about", () => {
56 const files = rangeSources();
57 expect(files).toContain("main.ts");
58 expect(files).toContain("binding.ts");
59 expect(files).toContain("independence.test.ts");
60 });
61
62 test("no range module imports the shooter, the training, or a package", () => {
63 const offences: string[] = [];
64 for (const file of rangeSources()) {
65 const source = readFileSync(join(RANGE, file), "utf8");
66 for (const specifier of specifiersOf(source)) {
67 const why = leavesRange(file, specifier);
68 if (why !== null) offences.push(`${file}: ${why}`);
69 }
70 }
71 expect(offences).toEqual([]);
72 });
73
74 test("the rule catches the import it exists to catch", () => {
75 // The shortcut that would make the second game a copy of the first.
76 expect(leavesRange("main.ts", "../client/world.ts")).toMatch(/outside/);
77 expect(leavesRange("main.ts", "../client/binding.ts")).toMatch(/outside/);
78 expect(leavesRange("main.ts", "../rl/arena.ts")).toMatch(/outside/);
79 expect(leavesRange("main.ts", "../db.ts")).toMatch(/outside/);
80 expect(leavesRange("main.ts", "../pages/game.ts")).toMatch(/outside/);
81 expect(leavesRange("main.ts", "gl-matrix")).toMatch(/installs nothing/);
82 expect(leavesRange("main.ts", "node:fs")).toMatch(/installs nothing/);
83 expect(leavesRange("main.ts", "bun:test")).toMatch(/installs nothing/);
84 });
85
86 // A title that ends on the keyword, with the closing quote right behind it,
87 // reads as an import to the helper above — a false positive this test would
88 // then hold against itself. So no title here ends on one.
89 test("the engine, the shell and a sibling are what it may reach for", () => {
90 expect(leavesRange("main.ts", "../engine/loop.ts")).toBeNull();
91 expect(leavesRange("main.ts", "../engine/math.ts")).toBeNull();
92 expect(leavesRange("main.ts", "./binding.ts")).toBeNull();
93 expect(leavesRange("sub/deep.ts", "../readout.ts")).toBeNull();
94 expect(leavesRange("sub/deep.ts", "../../engine/trace.ts")).toBeNull();
95 expect(leavesRange("main.ts", "../pages/shell.ts")).toBeNull();
96 expect(leavesRange("binding.test.ts", "bun:test")).toBeNull();
97 expect(leavesRange("independence.test.ts", "node:path")).toBeNull();
98 expect(leavesRange("bundle.test.ts", "../../scripts/build.ts")).toBeNull();
99 // A test file's licence is its own: a module that ships may not reach for node,
100 // nor for the script that bundles it.
101 expect(leavesRange("main.ts", "node:path")).toMatch(/installs nothing/);
102 expect(leavesRange("main.ts", "../../scripts/build.ts")).toMatch(/outside/);
103 });
104
105 test("a file that reached into the shooter is caught by reading it", () => {
106 // The keyword is spelled at run time so this fixture is not itself an import
107 // the test above would read out of this file and hold against it.
108 const F = "fr" + "om";
109 const source = [
110 `import { createLoop } ${F} "../engine/loop.ts";`,
111 `import { RANGE_KEYS } ${F} "./binding.ts";`,
112 `import { createWorld } ${F} "../client/world.ts";`,
113 ].join("\n");
114 const offences = specifiersOf(source)
115 .map((specifier) => leavesRange("main.ts", specifier))
116 .filter((why) => why !== null);
117 expect(offences).toEqual([`"../client/world.ts" resolves outside src/range/, src/engine/ and src/pages/shell.ts`]);
118 });
src/range/main.ts +1 −1
@@ -20,7 +20,7 @@
2020 * spot, which is the physics' to change, not the renderer's.
2121 *
2222 * Nothing outside `src/engine/` and this directory is imported, and
23 * `independence.test.ts` reads these files to make sure it stays that way.
23 * `src/independence.test.ts` reads these files to make sure it stays that way.
2424 */
2525
2626 import { createRangeAudio } from "./audio.ts";
src/range/scene.ts +1 −1
@@ -2,7 +2,7 @@
22 * The range's frame, as data: the room, the plates standing in it and the
33 * tracer of the last shot, in the engine's `Scene` and nothing else. It is the
44 * second game's answer to `src/client/scene.ts`, written without reading it —
5 * nothing under `src/client/` is imported here, and `independence.test.ts`
5 * nothing under `src/client/` is imported here, and `src/independence.test.ts`
66 * fails the day one is.
77 *
88 * What goes in is a `Run` from `targets.ts`, where the view is pointing, and

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs