fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files fable/22-separate-the-engine-from-the-game-so-the
| 1 | /** |
| 2 | * The property that makes the engine folders copyable at all: nothing under |
| 3 | * `src/engine/` or `static/engine/` imports anything outside them. A module |
| 4 | * that reached for the game's map, agents or match would come along with the |
| 5 | * copy and bring the whole game with it. |
| 6 | * |
| 7 | * Tests count: an engine test that needs the game to run is a test the next |
| 8 | * game cannot take with it. |
| 9 | */ |
| 10 | |
| 11 | import { expect, test } from "bun:test"; |
| 12 | import { readdirSync, readFileSync } from "node:fs"; |
| 13 | import { dirname, join, resolve } from "node:path"; |
| 14 | |
| 15 | const ROOT = resolve(import.meta.dir, "..", ".."); |
| 16 | const ENGINE = [join(ROOT, "src", "engine"), join(ROOT, "static", "engine")]; |
| 17 | |
| 18 | const inEngine = (path: string): boolean => ENGINE.some((dir) => resolve(path).startsWith(dir + "/")); |
| 19 | |
| 20 | /** Every import specifier in a file: static, re-exported, or dynamic. */ |
| 21 | function importsOf(source: string): string[] { |
| 22 | const out: string[] = []; |
| 23 | for (const match of source.matchAll(/^\s*(?:import|export)\b[^;]*?\bfrom\s+["']([^"']+)["']/gm)) out.push(match[1]!); |
| 24 | for (const match of source.matchAll(/\bimport\(\s*["']([^"']+)["']\s*\)/g)) out.push(match[1]!); |
| 25 | return out; |
| 26 | } |
| 27 | |
| 28 | const files = ENGINE.flatMap((dir) => |
| 29 | readdirSync(dir) |
| 30 | .filter((name) => /\.(ts|js)$/.test(name)) |
| 31 | .map((name) => join(dir, name)), |
| 32 | ); |
| 33 | |
| 34 | test("the engine folders exist and hold the pieces a game stands on", () => { |
| 35 | const names = files.map((f) => f.split("/").slice(-2).join("/")); |
| 36 | for (const need of [ |
| 37 | "engine/glide.ts", |
| 38 | "engine/aim.ts", |
| 39 | "engine/grid.ts", |
| 40 | "engine/arena.ts", |
| 41 | "engine/interp.js", |
| 42 | "engine/predict.js", |
| 43 | "engine/presence.js", |
| 44 | "engine/yaw.js", |
| 45 | "engine/steer.js", |
| 46 | "engine/raycast.js", |
| 47 | "engine/scene.js", |
| 48 | "engine/slices.js", |
| 49 | "engine/resolution.js", |
| 50 | "engine/budget.js", |
| 51 | "engine/topdown.js", |
| 52 | "engine/gun.js", |
| 53 | ]) |
| 54 | expect(names).toContain(need); |
| 55 | }); |
| 56 | |
| 57 | test("no engine module, and no engine test, imports a game module", () => { |
| 58 | const crossings: string[] = []; |
| 59 | for (const file of files) { |
| 60 | for (const spec of importsOf(readFileSync(file, "utf8"))) { |
| 61 | if (/^(bun|node):/.test(spec)) continue; |
| 62 | if (!spec.startsWith(".")) { |
| 63 | crossings.push(`${file}: ${spec} (a package)`); |
| 64 | continue; |
| 65 | } |
| 66 | const target = resolve(dirname(file), spec); |
| 67 | if (!inEngine(target)) crossings.push(`${file.slice(ROOT.length + 1)} -> ${spec}`); |
| 68 | } |
| 69 | } |
| 70 | expect(crossings).toEqual([]); |
| 71 | }); |
| 72 | |
| 73 | test("the engine does not know what a spike is", () => { |
| 74 | // The words of this game. An engine file that says one of them has a hole |
| 75 | // that should have been a parameter. |
| 76 | const words = /\b(spike|agent|credits|molly|defus\w*|plant\w*|orb|valorant)\b/i; |
| 77 | const said: string[] = []; |
| 78 | for (const file of files) { |
| 79 | if (file.endsWith(".test.ts")) continue; |
| 80 | const source = readFileSync(file, "utf8") |
| 81 | // Comments may explain things by example; the code may not. |
| 82 | .replace(/\/\*[\s\S]*?\*\//g, "") |
| 83 | .replace(/\/\/.*$/gm, ""); |
| 84 | const hit = source.match(words); |
| 85 | if (hit) said.push(`${file.slice(ROOT.length + 1)}: ${hit[0]}`); |
| 86 | } |
| 87 | expect(said).toEqual([]); |
| 88 | }); |