/** * The property that makes the engine folders copyable at all: nothing under * `src/engine/` or `static/engine/` imports anything outside them. A module * that reached for the game's map, agents or match would come along with the * copy and bring the whole game with it. * * Tests count: an engine test that needs the game to run is a test the next * game cannot take with it. */ import { expect, test } from "bun:test"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; const ROOT = resolve(import.meta.dir, "..", ".."); const ENGINE = [join(ROOT, "src", "engine"), join(ROOT, "static", "engine")]; /** * The game's own nouns, read as data rather than imported as code — an * import from here would itself be a crossing the test below refuses. A * copy of the engine that lands in a fresh game starts with no such file * and so no such guard, until that game writes one down. */ const WORDS_FILE = join(ROOT, "src", "words.json"); const gameWords: string[] = existsSync(WORDS_FILE) ? JSON.parse(readFileSync(WORDS_FILE, "utf8")) : []; const inEngine = (path: string): boolean => ENGINE.some((dir) => resolve(path).startsWith(dir + "/")); /** Every import specifier in a file: static, re-exported, or dynamic. */ function importsOf(source: string): string[] { const out: string[] = []; for (const match of source.matchAll(/^\s*(?:import|export)\b[^;]*?\bfrom\s+["']([^"']+)["']/gm)) out.push(match[1]!); for (const match of source.matchAll(/\bimport\(\s*["']([^"']+)["']\s*\)/g)) out.push(match[1]!); return out; } const files = ENGINE.flatMap((dir) => readdirSync(dir) .filter((name) => /\.(ts|js)$/.test(name)) .map((name) => join(dir, name)), ); test("the engine folders exist and hold the pieces a game stands on", () => { const names = files.map((f) => f.split("/").slice(-2).join("/")); for (const need of [ "engine/glide.ts", "engine/aim.ts", "engine/grid.ts", "engine/arena.ts", "engine/interp.js", "engine/predict.js", "engine/presence.js", "engine/yaw.js", "engine/steer.js", "engine/raycast.js", "engine/scene.js", "engine/slices.js", "engine/resolution.js", "engine/budget.js", "engine/topdown.js", "engine/gun.js", ]) expect(names).toContain(need); }); test("no engine module, and no engine test, imports a game module", () => { const crossings: string[] = []; for (const file of files) { for (const spec of importsOf(readFileSync(file, "utf8"))) { if (/^(bun|node):/.test(spec)) continue; if (!spec.startsWith(".")) { crossings.push(`${file}: ${spec} (a package)`); continue; } const target = resolve(dirname(file), spec); if (!inEngine(target)) crossings.push(`${file.slice(ROOT.length + 1)} -> ${spec}`); } } expect(crossings).toEqual([]); }); test.skipIf(gameWords.length === 0)("the engine does not know what this game calls things", () => { // The words are the game's own, from src/words.json, never hardcoded here: // a game that legitimately has a "spike" should not inherit a guard against // a game it has never played. An engine file that says one of them has a // hole that should have been a parameter. const words = new RegExp(`\\b(${gameWords.join("|")})\\b`, "i"); const said: string[] = []; for (const file of files) { if (file.endsWith(".test.ts")) continue; const source = readFileSync(file, "utf8") // Comments may explain things by example; the code may not. .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/\/\/.*$/gm, ""); const hit = source.match(words); if (hit) said.push(`${file.slice(ROOT.length + 1)}: ${hit[0]}`); } expect(said).toEqual([]); });