| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- 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<ContentBundle> {
- 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<ContentBundle> {
- 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<string, unknown[]> = {};
- for (const kind of Object.keys(contentSchemas) as ContentKind[]) {
- const defs = registry.list(kind);
- if (defs.length > 0) content[kind] = defs;
- }
- const assets: Record<string, { kind: "image" | "audio"; mime: string; data: string }> = {};
- 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;
- }
|