enabled-mods.test.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join, resolve } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import {
  6. ContentLoader,
  7. ContentRegistry,
  8. HookRegistry,
  9. ModLoader,
  10. TimeSystem,
  11. World,
  12. contentSchemas,
  13. createEngine,
  14. type ContentBundle,
  15. type ContentKind,
  16. type HookContext,
  17. } from "../src/index.js";
  18. const repoRoot = resolve(process.cwd(), "../..");
  19. const contentDir = resolve(repoRoot, "examples", "sample-game", "content");
  20. function modSource(id: string, tag: string): string {
  21. return [
  22. "export default {",
  23. ` manifest: { id: "${id}", version: "1.0.0" },`,
  24. " hooks: {",
  25. " onDayStart(ctx) {",
  26. " const world = ctx.world;",
  27. ` const stats = world.get("${tag}") ?? world.create({ id: "${tag}", type: "mod-state", fired: 0 });`,
  28. " stats.fired += 1;",
  29. " },",
  30. " },",
  31. "};",
  32. "",
  33. ].join("\n");
  34. }
  35. async function modsFixture(mods: Record<string, string>): Promise<string> {
  36. const dir = await mkdtemp(join(tmpdir(), "core-enabled-mods-"));
  37. for (const [id, source] of Object.entries(mods)) {
  38. const target = join(dir, id);
  39. await mkdir(target, { recursive: true });
  40. await writeFile(join(target, "index.mjs"), source, "utf8");
  41. }
  42. return dir;
  43. }
  44. async function rmRecursive(dir: string): Promise<void> {
  45. await rm(dir, { recursive: true, force: true });
  46. }
  47. function makeCtx(): HookContext {
  48. return {
  49. world: new World(),
  50. registry: new ContentRegistry(),
  51. time: new TimeSystem(),
  52. emit: () => {},
  53. };
  54. }
  55. describe("ModLoader enabledMods filter (F1)", () => {
  56. it("loads only the listed mods and binds only their hooks", async () => {
  57. const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") });
  58. try {
  59. const hooks = new HookRegistry();
  60. const loader = new ModLoader(hooks);
  61. const statuses = await loader.load(dir, ["a-mod"]);
  62. expect(statuses.map((s) => s.id)).toEqual(["a-mod"]);
  63. expect(statuses[0]?.loaded).toBe(true);
  64. hooks.fire("onDayStart", makeCtx());
  65. expect(hooks.bound("onDayStart")).toBe(true);
  66. } finally {
  67. await rmRecursive(dir);
  68. }
  69. });
  70. it("undefined enabledMods loads everything (backward compatible)", async () => {
  71. const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") });
  72. try {
  73. const hooks = new HookRegistry();
  74. const loader = new ModLoader(hooks);
  75. const statuses = await loader.load(dir);
  76. expect(statuses.map((s) => s.id).sort()).toEqual(["a-mod", "b-mod"]);
  77. } finally {
  78. await rmRecursive(dir);
  79. }
  80. });
  81. it("unknown ids in the list are ignored, not errors", async () => {
  82. const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats") });
  83. try {
  84. const hooks = new HookRegistry();
  85. const loader = new ModLoader(hooks);
  86. const statuses = await loader.load(dir, ["ghost", "a-mod"]);
  87. expect(statuses.map((s) => s.id)).toEqual(["a-mod"]);
  88. const none = await loader.load(dir, ["ghost"]);
  89. expect(none).toEqual([]);
  90. } finally {
  91. await rmRecursive(dir);
  92. }
  93. });
  94. });
  95. describe("createEngine enabledMods (F1)", () => {
  96. it("excludes disabled mods from engine.mods and their hooks never fire", async () => {
  97. const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") });
  98. try {
  99. const engine = createEngine({ contentDir, modsDir: dir, enabledMods: ["a-mod"] });
  100. await engine.start();
  101. try {
  102. expect(engine.enabledMods).toEqual(["a-mod"]);
  103. expect(engine.mods.map((m) => m.id)).toEqual(["a-mod"]);
  104. engine.tick(480);
  105. expect(engine.world.get("mod:a-stats")?.fired).toBe(1);
  106. expect(engine.world.get("mod:b-stats")).toBeUndefined();
  107. } finally {
  108. await engine.stop();
  109. }
  110. } finally {
  111. await rmRecursive(dir);
  112. }
  113. });
  114. it("reload respects the same filter and a disabled id reload reports not loaded", async () => {
  115. const dir = await modsFixture({ "a-mod": modSource("a-mod", "mod:a-stats"), "b-mod": modSource("b-mod", "mod:b-stats") });
  116. try {
  117. const engine = createEngine({ contentDir, modsDir: dir, enabledMods: ["a-mod"] });
  118. await engine.start();
  119. try {
  120. const statuses = await engine.reloadMods();
  121. expect(statuses.map((s) => s.id)).toEqual(["a-mod"]);
  122. const single = await engine.reloadMods("b-mod");
  123. expect(single[0]?.loaded).toBe(false);
  124. expect(single[0]?.errors?.[0]).toContain("mod not loaded");
  125. } finally {
  126. await engine.stop();
  127. }
  128. } finally {
  129. await rmRecursive(dir);
  130. }
  131. });
  132. it("bundle mode filters bundled mods too", async () => {
  133. const { readFile } = await import("node:fs/promises");
  134. const source = await readFile(resolve(repoRoot, "examples", "sample-game", "mods", "sample-game-mod", "index.mjs"), "utf8");
  135. const registry = new ContentRegistry();
  136. const loader = new ContentLoader(registry);
  137. const report = await loader.load(contentDir);
  138. if (!report.ok) throw new Error("sample content load failed");
  139. const content: Record<string, unknown[]> = {};
  140. for (const kind of Object.keys(contentSchemas) as ContentKind[]) {
  141. const defs = registry.list(kind);
  142. if (defs.length > 0) content[kind] = defs;
  143. }
  144. const bundle: ContentBundle = { format: "nengine-bundle", version: 1, content };
  145. bundle.mods = {
  146. "sample-game-mod": { manifest: { id: "sample-game-mod", version: "0.1.0" }, code: source },
  147. "other-mod": { manifest: { id: "other-mod", version: "0.1.0" }, code: `export default { manifest: { id: "other-mod", version: "0.1.0" }, hooks: {} };` },
  148. };
  149. const engine = createEngine({ bundle, enabledMods: ["sample-game-mod"] });
  150. await engine.start();
  151. try {
  152. expect(engine.mods.map((m) => m.id)).toEqual(["sample-game-mod"]);
  153. engine.tick(480);
  154. expect(engine.world.get("mod:sample-stats")?.greetings).toBe(1);
  155. } finally {
  156. await engine.stop();
  157. }
  158. });
  159. });