import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { ContentLoader, ContentRegistry, contentSchemas, createEngine, HookRegistry, ModLoader, TimeSystem, World, WorldEventBus, type ContentBundle, type ContentKind, type HookContext, type TimeDef, } from "../src/index.js"; const repoRoot = resolve(process.cwd(), "../.."); const modsDir = resolve(repoRoot, "mods"); const sampleGameModFile = resolve(repoRoot, "examples", "sample-game", "mods", "sample-game-mod", "index.mjs"); function makeCtx(): { ctx: HookContext; events: WorldEventBus } { const registry = new ContentRegistry(); const world = new World(); const events = new WorldEventBus(); const timeDef: TimeDef = { id: "default", dayLengthTicks: 480, seasons: [{ name: "spring", dayCount: 28 }], start: { day: 1, season: "spring" }, }; registry.register("time", timeDef); const ts = new TimeSystem(); ts.init({ world, registry, events }); const ctx: HookContext = { world, registry, time: ts, emit: (event, payload) => events.emit(event, payload), }; return { ctx, events }; } describe("ModLoader", () => { it("loads mods/example-mod and binds onDayStart", async () => { const hooks = new HookRegistry(); const loader = new ModLoader(hooks); const statuses = await loader.load(modsDir); expect(statuses).toHaveLength(1); const status = statuses[0]; expect(status?.id).toBe("example-mod"); expect(status?.version).toBe("0.1.0"); expect(status?.loaded).toBe(true); expect(status?.hooks).toEqual(["onDayStart"]); expect(hooks.bound("onDayStart")).toBe(true); }); it("firing onDayStart calls the mod fn and emits mod:example-day-start", async () => { const hooks = new HookRegistry(); const loader = new ModLoader(hooks); await loader.load(modsDir); const { ctx, events } = makeCtx(); const seen: unknown[] = []; events.on("mod:example-day-start", (p) => seen.push(p)); hooks.fire("onDayStart", ctx); expect(seen).toHaveLength(1); const payload = seen[0] as { modId: string; day: number; season: string }; expect(payload.modId).toBe("example-mod"); expect(payload.day).toBe(1); expect(payload.season).toBe("spring"); }); }); describe("ModLoader.loadFromSource (D36)", () => { it("executes ESM source from a data URL, binds hooks identically to file mods, and fires", async () => { const hooks = new HookRegistry(); const loader = new ModLoader(hooks); const source = `export default { manifest: { id: "src-mod", version: "1.2.3" }, hooks: { onDayStart(ctx) { ctx.world.create({ id: "mod:src-stats", type: "mod-state", fired: 1 }); } }, };`; const status = await loader.loadFromSource(source, "src-mod", "1.2.3"); expect(status).toMatchObject({ id: "src-mod", version: "1.2.3", loaded: true, hooks: ["onDayStart"] }); expect(status.errors).toBeUndefined(); expect(hooks.bound("onDayStart")).toBe(true); const { ctx } = makeCtx(); hooks.fire("onDayStart", ctx); expect(ctx.world.get("mod:src-stats")).toMatchObject({ type: "mod-state", fired: 1 }); }); it("reports a syntax-error source as loaded:false with errors, never throwing", async () => { const hooks = new HookRegistry(); const loader = new ModLoader(hooks); const status = await loader.loadFromSource("export default {", "broken", "1.0.0"); expect(status).toMatchObject({ id: "broken", version: "1.0.0", loaded: false, hooks: [] }); expect(status.errors?.[0]).toContain("import failed"); }); it("requires the manifest { id, version } like file mods (no version fallback)", async () => { const hooks = new HookRegistry(); const loader = new ModLoader(hooks); const status = await loader.loadFromSource("export default { hooks: {} };", "nomanifest", "1.0.0"); expect(status.loaded).toBe(false); expect(status.errors?.[0]).toBe("manifest { id, version } required"); }); it("reports non-function hooks and unknown hooks as errors while still loading", async () => { const hooks = new HookRegistry(); const loader = new ModLoader(hooks); const source = `export default { manifest: { id: "badhooks", version: "1.0.0" }, hooks: { onDayStart: "nope", notAHook() {} } };`; const status = await loader.loadFromSource(source, "badhooks", "1.0.0"); expect(status.loaded).toBe(true); expect(status.hooks).toEqual([]); expect(status.errors).toContain("hook onDayStart: not a function"); expect(status.errors).toContain("unknown hooks ignored: notAHook"); }); it("reloadFromSources re-imports with cache-busting and unbinds prior hook bindings", async () => { const hooks = new HookRegistry(); const loader = new ModLoader(hooks); const source = `export default { manifest: { id: "reload-mod", version: "1.0.0" }, hooks: { onDayStart(ctx) { ctx.world.create({ id: "mod:reload", type: "mod-state", count: (ctx.world.get("mod:reload")?.count ?? 0) + 1 }); } } };`; await loader.loadFromSource(source, "reload-mod", "1.0.0"); await loader.reloadFromSources([{ id: "reload-mod", version: "1.0.0", code: source }]); const { ctx } = makeCtx(); hooks.fire("onDayStart", ctx); expect(ctx.world.query({ type: "mod-state" }).filter((e) => e.id === "mod:reload")).toHaveLength(1); expect(ctx.world.get("mod:reload")?.count).toBe(1); }); }); describe("createEngine bundle mode with packaged Mods (D36)", () => { it("boots bundled Mod sources from a bundle and the mod's hooks fire on engine.tick", async () => { const bundle = await sampleBundleWithMods(); const engine = createEngine({ bundle }); await engine.start(); try { expect(engine.mods).toHaveLength(1); expect(engine.mods[0]).toMatchObject({ id: "sample-game-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart", "onPlayerIntent"] }); engine.tick(480); expect(engine.world.get("mod:sample-stats")).toMatchObject({ type: "mod-state", greetings: 1 }); expect(engine.world.get("npc:mayor")?.type).toBe("npc"); expect(engine.world.get("npc:mayor")?.label).toBe("Mayor"); } finally { await engine.stop(); } }); it("engine.reloadMods in bundle mode reloads from the stored sources", async () => { const bundle = await sampleBundleWithMods(); const engine = createEngine({ bundle }); await engine.start(); try { engine.tick(480); expect(engine.world.get("mod:sample-stats")?.greetings).toBe(1); const statuses = await engine.reloadMods(); expect(statuses).toHaveLength(1); expect(statuses[0]?.loaded).toBe(true); engine.tick(480); expect(engine.world.get("mod:sample-stats")?.greetings).toBe(2); } finally { await engine.stop(); } }); it("a configured modsDir wins over bundled Mods (documented precedence)", async () => { const bundle = await sampleBundleWithMods(); const engine = createEngine({ bundle, modsDir }); await engine.start(); try { expect(engine.mods).toHaveLength(1); expect(engine.mods[0]?.id).toBe("example-mod"); } finally { await engine.stop(); } }); }); async function sampleBundleWithMods(): Promise { const source = await readFile(sampleGameModFile, "utf8"); const bundle = await compileSampleBundle(); bundle.mods = { "sample-game-mod": { manifest: { id: "sample-game-mod", version: "0.1.0" }, code: source }, }; return bundle; } async function compileSampleBundle(): Promise { const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const report = await loader.load(resolve(repoRoot, "examples", "sample-game", "content")); 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 assets: Record = {}; for (const [id, entry] of registry.assets) { assets[id] = entry; } const bundle: ContentBundle = { format: "nengine-bundle", version: 1, content }; if (Object.keys(assets).length > 0) bundle.assets = assets; return bundle; }