import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { ContentLoader, ContentRegistry, HookRegistry, ModLoader, TimeSystem, World, contentSchemas, createEngine, type ContentBundle, type ContentKind, type HookContext, } from "../src/index.js"; const repoRoot = resolve(process.cwd(), "../.."); const contentDir = resolve(repoRoot, "examples", "sample-game", "content"); function modSource(id: string, tag: string): string { return [ "export default {", ` manifest: { id: "${id}", version: "1.0.0" },`, " hooks: {", " onDayStart(ctx) {", " const world = ctx.world;", ` const stats = world.get("${tag}") ?? world.create({ id: "${tag}", type: "mod-state", fired: 0 });`, " stats.fired += 1;", " },", " },", "};", "", ].join("\n"); } async function modsFixture(mods: Record): Promise { const dir = await mkdtemp(join(tmpdir(), "core-enabled-mods-")); for (const [id, source] of Object.entries(mods)) { const target = join(dir, id); await mkdir(target, { recursive: true }); await writeFile(join(target, "index.mjs"), source, "utf8"); } return dir; } async function rmRecursive(dir: string): Promise { await rm(dir, { recursive: true, force: true }); } function makeCtx(): HookContext { return { world: new World(), registry: new ContentRegistry(), time: new TimeSystem(), emit: () => {}, }; } describe("ModLoader enabledMods filter (F1)", () => { it("loads only the listed mods and binds only their hooks", async () => { const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") }); try { const hooks = new HookRegistry(); const loader = new ModLoader(hooks); const statuses = await loader.load(dir, ["a-mod"]); expect(statuses.map((s) => s.id)).toEqual(["a-mod"]); expect(statuses[0]?.loaded).toBe(true); hooks.fire("onDayStart", makeCtx()); expect(hooks.bound("onDayStart")).toBe(true); } finally { await rmRecursive(dir); } }); it("undefined enabledMods loads everything (backward compatible)", async () => { const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") }); try { const hooks = new HookRegistry(); const loader = new ModLoader(hooks); const statuses = await loader.load(dir); expect(statuses.map((s) => s.id).sort()).toEqual(["a-mod", "b-mod"]); } finally { await rmRecursive(dir); } }); it("unknown ids in the list are ignored, not errors", async () => { const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats") }); try { const hooks = new HookRegistry(); const loader = new ModLoader(hooks); const statuses = await loader.load(dir, ["ghost", "a-mod"]); expect(statuses.map((s) => s.id)).toEqual(["a-mod"]); const none = await loader.load(dir, ["ghost"]); expect(none).toEqual([]); } finally { await rmRecursive(dir); } }); }); describe("createEngine enabledMods (F1)", () => { it("excludes disabled mods from engine.mods and their hooks never fire", async () => { const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") }); try { const engine = createEngine({ contentDir, modsDir: dir, enabledMods: ["a-mod"] }); await engine.start(); try { expect(engine.enabledMods).toEqual(["a-mod"]); expect(engine.mods.map((m) => m.id)).toEqual(["a-mod"]); engine.tick(480); expect(engine.world.get("mod:a-stats")?.fired).toBe(1); expect(engine.world.get("mod:b-stats")).toBeUndefined(); } finally { await engine.stop(); } } finally { await rmRecursive(dir); } }); it("reload respects the same filter and a disabled id reload reports not loaded", async () => { const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") }); try { const engine = createEngine({ contentDir, modsDir: dir, enabledMods: ["a-mod"] }); await engine.start(); try { const statuses = await engine.reloadMods(); expect(statuses.map((s) => s.id)).toEqual(["a-mod"]); const single = await engine.reloadMods("b-mod"); expect(single[0]?.loaded).toBe(false); expect(single[0]?.errors?.[0]).toContain("mod not loaded"); } finally { await engine.stop(); } } finally { await rmRecursive(dir); } }); it("bundle mode filters bundled mods too", async () => { const { readFile } = await import("node:fs/promises"); const source = await readFile(resolve(repoRoot, "examples", "sample-game", "mods", "sample-game-mod", "index.mjs"), "utf8"); const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const report = await loader.load(contentDir); if (!report.ok) throw new Error("sample content load failed"); const content: Record = {}; for (const kind of Object.keys(contentSchemas) as ContentKind[]) { const defs = registry.list(kind); if (defs.length > 0) content[kind] = defs; } const bundle: ContentBundle = { format: "nengine-bundle", version: 1, content }; bundle.mods = { "sample-game-mod": { manifest: { id: "sample-game-mod", version: "0.1.0" }, code: source }, "other-mod": { manifest: { id: "other-mod", version: "0.1.0" }, code: `export default { manifest: { id: "other-mod", version: "0.1.0" }, hooks: {} };` }, }; const engine = createEngine({ bundle, enabledMods: ["sample-game-mod"] }); await engine.start(); try { expect(engine.mods.map((m) => m.id)).toEqual(["sample-game-mod"]); engine.tick(480); expect(engine.world.get("mod:sample-stats")?.greetings).toBe(1); } finally { await engine.stop(); } }); });