mods.test.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import { readFile } from "node:fs/promises";
  2. import { resolve } from "node:path";
  3. import { describe, expect, it } from "vitest";
  4. import {
  5. ContentLoader,
  6. ContentRegistry,
  7. contentSchemas,
  8. createEngine,
  9. HookRegistry,
  10. ModLoader,
  11. TimeSystem,
  12. World,
  13. WorldEventBus,
  14. type ContentBundle,
  15. type ContentKind,
  16. type HookContext,
  17. type TimeDef,
  18. } from "../src/index.js";
  19. const repoRoot = resolve(process.cwd(), "../..");
  20. const modsDir = resolve(repoRoot, "mods");
  21. const sampleGameModFile = resolve(repoRoot, "examples", "sample-game", "mods", "sample-game-mod", "index.mjs");
  22. function makeCtx(): { ctx: HookContext; events: WorldEventBus } {
  23. const registry = new ContentRegistry();
  24. const world = new World();
  25. const events = new WorldEventBus();
  26. const timeDef: TimeDef = {
  27. id: "default",
  28. dayLengthTicks: 480,
  29. seasons: [{ name: "spring", dayCount: 28 }],
  30. start: { day: 1, season: "spring" },
  31. };
  32. registry.register("time", timeDef);
  33. const ts = new TimeSystem();
  34. ts.init({ world, registry, events });
  35. const ctx: HookContext = {
  36. world,
  37. registry,
  38. time: ts,
  39. emit: (event, payload) => events.emit(event, payload),
  40. };
  41. return { ctx, events };
  42. }
  43. describe("ModLoader", () => {
  44. it("loads mods/example-mod and binds onDayStart", async () => {
  45. const hooks = new HookRegistry();
  46. const loader = new ModLoader(hooks);
  47. const statuses = await loader.load(modsDir);
  48. expect(statuses).toHaveLength(1);
  49. const status = statuses[0];
  50. expect(status?.id).toBe("example-mod");
  51. expect(status?.version).toBe("0.1.0");
  52. expect(status?.loaded).toBe(true);
  53. expect(status?.hooks).toEqual(["onDayStart"]);
  54. expect(hooks.bound("onDayStart")).toBe(true);
  55. });
  56. it("firing onDayStart calls the mod fn and emits mod:example-day-start", async () => {
  57. const hooks = new HookRegistry();
  58. const loader = new ModLoader(hooks);
  59. await loader.load(modsDir);
  60. const { ctx, events } = makeCtx();
  61. const seen: unknown[] = [];
  62. events.on("mod:example-day-start", (p) => seen.push(p));
  63. hooks.fire("onDayStart", ctx);
  64. expect(seen).toHaveLength(1);
  65. const payload = seen[0] as { modId: string; day: number; season: string };
  66. expect(payload.modId).toBe("example-mod");
  67. expect(payload.day).toBe(1);
  68. expect(payload.season).toBe("spring");
  69. });
  70. });
  71. describe("ModLoader.loadFromSource (D36)", () => {
  72. it("executes ESM source from a data URL, binds hooks identically to file mods, and fires", async () => {
  73. const hooks = new HookRegistry();
  74. const loader = new ModLoader(hooks);
  75. const source = `export default {
  76. manifest: { id: "src-mod", version: "1.2.3" },
  77. hooks: { onDayStart(ctx) { ctx.world.create({ id: "mod:src-stats", type: "mod-state", fired: 1 }); } },
  78. };`;
  79. const status = await loader.loadFromSource(source, "src-mod", "1.2.3");
  80. expect(status).toMatchObject({ id: "src-mod", version: "1.2.3", loaded: true, hooks: ["onDayStart"] });
  81. expect(status.errors).toBeUndefined();
  82. expect(hooks.bound("onDayStart")).toBe(true);
  83. const { ctx } = makeCtx();
  84. hooks.fire("onDayStart", ctx);
  85. expect(ctx.world.get("mod:src-stats")).toMatchObject({ type: "mod-state", fired: 1 });
  86. });
  87. it("reports a syntax-error source as loaded:false with errors, never throwing", async () => {
  88. const hooks = new HookRegistry();
  89. const loader = new ModLoader(hooks);
  90. const status = await loader.loadFromSource("export default {", "broken", "1.0.0");
  91. expect(status).toMatchObject({ id: "broken", version: "1.0.0", loaded: false, hooks: [] });
  92. expect(status.errors?.[0]).toContain("import failed");
  93. });
  94. it("requires the manifest { id, version } like file mods (no version fallback)", async () => {
  95. const hooks = new HookRegistry();
  96. const loader = new ModLoader(hooks);
  97. const status = await loader.loadFromSource("export default { hooks: {} };", "nomanifest", "1.0.0");
  98. expect(status.loaded).toBe(false);
  99. expect(status.errors?.[0]).toBe("manifest { id, version } required");
  100. });
  101. it("reports non-function hooks and unknown hooks as errors while still loading", async () => {
  102. const hooks = new HookRegistry();
  103. const loader = new ModLoader(hooks);
  104. const source = `export default { manifest: { id: "badhooks", version: "1.0.0" }, hooks: { onDayStart: "nope", notAHook() {} } };`;
  105. const status = await loader.loadFromSource(source, "badhooks", "1.0.0");
  106. expect(status.loaded).toBe(true);
  107. expect(status.hooks).toEqual([]);
  108. expect(status.errors).toContain("hook onDayStart: not a function");
  109. expect(status.errors).toContain("unknown hooks ignored: notAHook");
  110. });
  111. it("reloadFromSources re-imports with cache-busting and unbinds prior hook bindings", async () => {
  112. const hooks = new HookRegistry();
  113. const loader = new ModLoader(hooks);
  114. 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 }); } } };`;
  115. await loader.loadFromSource(source, "reload-mod", "1.0.0");
  116. await loader.reloadFromSources([{ id: "reload-mod", version: "1.0.0", code: source }]);
  117. const { ctx } = makeCtx();
  118. hooks.fire("onDayStart", ctx);
  119. expect(ctx.world.query({ type: "mod-state" }).filter((e) => e.id === "mod:reload")).toHaveLength(1);
  120. expect(ctx.world.get("mod:reload")?.count).toBe(1);
  121. });
  122. });
  123. describe("createEngine bundle mode with packaged Mods (D36)", () => {
  124. it("boots bundled Mod sources from a bundle and the mod's hooks fire on engine.tick", async () => {
  125. const bundle = await sampleBundleWithMods();
  126. const engine = createEngine({ bundle });
  127. await engine.start();
  128. try {
  129. expect(engine.mods).toHaveLength(1);
  130. expect(engine.mods[0]).toMatchObject({ id: "sample-game-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart", "onPlayerIntent"] });
  131. engine.tick(480);
  132. expect(engine.world.get("mod:sample-stats")).toMatchObject({ type: "mod-state", greetings: 1 });
  133. expect(engine.world.get("npc:mayor")?.type).toBe("npc");
  134. expect(engine.world.get("npc:mayor")?.label).toBe("Mayor");
  135. } finally {
  136. await engine.stop();
  137. }
  138. });
  139. it("engine.reloadMods in bundle mode reloads from the stored sources", async () => {
  140. const bundle = await sampleBundleWithMods();
  141. const engine = createEngine({ bundle });
  142. await engine.start();
  143. try {
  144. engine.tick(480);
  145. expect(engine.world.get("mod:sample-stats")?.greetings).toBe(1);
  146. const statuses = await engine.reloadMods();
  147. expect(statuses).toHaveLength(1);
  148. expect(statuses[0]?.loaded).toBe(true);
  149. engine.tick(480);
  150. expect(engine.world.get("mod:sample-stats")?.greetings).toBe(2);
  151. } finally {
  152. await engine.stop();
  153. }
  154. });
  155. it("a configured modsDir wins over bundled Mods (documented precedence)", async () => {
  156. const bundle = await sampleBundleWithMods();
  157. const engine = createEngine({ bundle, modsDir });
  158. await engine.start();
  159. try {
  160. expect(engine.mods).toHaveLength(1);
  161. expect(engine.mods[0]?.id).toBe("example-mod");
  162. } finally {
  163. await engine.stop();
  164. }
  165. });
  166. });
  167. async function sampleBundleWithMods(): Promise<ContentBundle> {
  168. const source = await readFile(sampleGameModFile, "utf8");
  169. const bundle = await compileSampleBundle();
  170. bundle.mods = {
  171. "sample-game-mod": { manifest: { id: "sample-game-mod", version: "0.1.0" }, code: source },
  172. };
  173. return bundle;
  174. }
  175. async function compileSampleBundle(): Promise<ContentBundle> {
  176. const registry = new ContentRegistry();
  177. const loader = new ContentLoader(registry);
  178. const report = await loader.load(resolve(repoRoot, "examples", "sample-game", "content"));
  179. if (!report.ok) throw new Error("sample content load failed");
  180. const content: Record<string, unknown[]> = {};
  181. for (const kind of Object.keys(contentSchemas) as ContentKind[]) {
  182. const defs = registry.list(kind);
  183. if (defs.length > 0) content[kind] = defs;
  184. }
  185. const assets: Record<string, { kind: "image" | "audio"; mime: string; data: string }> = {};
  186. for (const [id, entry] of registry.assets) {
  187. assets[id] = entry;
  188. }
  189. const bundle: ContentBundle = { format: "nengine-bundle", version: 1, content };
  190. if (Object.keys(assets).length > 0) bundle.assets = assets;
  191. return bundle;
  192. }