bundle.test.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import { resolve } from "node:path";
  2. import { describe, expect, it } from "vitest";
  3. import {
  4. contentSchemas,
  5. createEngine,
  6. type ContentBundle,
  7. ContentLoader,
  8. ContentRegistry,
  9. type ContentKind,
  10. type IntentSystem,
  11. type MapDef,
  12. type TimeSystem,
  13. } from "../src/index.js";
  14. const repoRoot = resolve(process.cwd(), "../..");
  15. const sampleContentDir = resolve(repoRoot, "examples", "sample-game", "content");
  16. /** Compile a bundle the way the pack tool does: fs-load the dir, then collect
  17. * every registered def per content kind. */
  18. async function compileBundle(dir: string): Promise<ContentBundle> {
  19. const registry = new ContentRegistry();
  20. const loader = new ContentLoader(registry);
  21. const report = await loader.load(dir);
  22. if (!report.ok) throw new Error("content load failed");
  23. const content: Record<string, unknown[]> = {};
  24. for (const kind of Object.keys(contentSchemas) as ContentKind[]) {
  25. const defs = registry.list(kind);
  26. if (defs.length > 0) content[kind] = defs;
  27. }
  28. return { format: "nengine-bundle", version: 1, content };
  29. }
  30. function validMap(): unknown {
  31. return {
  32. id: "farm",
  33. width: 2,
  34. height: 2,
  35. tilesetRef: "tiles",
  36. spawn: { x: 0, y: 0 },
  37. layers: [{ name: "ground", data: [["grass", "grass"], ["grass", "grass"]] }],
  38. };
  39. }
  40. function validTime(): unknown {
  41. return {
  42. id: "default",
  43. dayLengthTicks: 480,
  44. seasons: [{ name: "spring", dayCount: 28 }],
  45. start: { day: 1, season: "spring" },
  46. };
  47. }
  48. function validItem(): unknown {
  49. return { id: "hoe", name: "Hoe", kind: "tool", stackable: false, price: 500 };
  50. }
  51. describe("ContentLoader.loadBundle", () => {
  52. it("registers a valid bundle and reports per-item ok", async () => {
  53. const bundle: ContentBundle = {
  54. format: "nengine-bundle",
  55. version: 1,
  56. content: {
  57. map: [validMap()],
  58. time: [validTime()],
  59. item: [validItem()],
  60. },
  61. };
  62. const registry = new ContentRegistry();
  63. const loader = new ContentLoader(registry);
  64. const report = await loader.loadBundle(bundle);
  65. expect(report.ok).toBe(true);
  66. expect(report.items).toHaveLength(3);
  67. expect(report.items.every((i) => i.status === "ok")).toBe(true);
  68. expect(registry.ids("map")).toEqual(["farm"]);
  69. expect(registry.get<MapDef>("map", "farm")?.width).toBe(2);
  70. expect(registry.get("item", "hoe")).toMatchObject({ name: "Hoe", kind: "tool" });
  71. });
  72. it("reports an invalid kind entry as an error without aborting siblings", async () => {
  73. const bundle: ContentBundle = {
  74. format: "nengine-bundle",
  75. version: 1,
  76. content: {
  77. map: [validMap()],
  78. mystery: [{ id: "x" }],
  79. },
  80. };
  81. const registry = new ContentRegistry();
  82. const loader = new ContentLoader(registry);
  83. const report = await loader.loadBundle(bundle);
  84. expect(report.ok).toBe(false);
  85. const ok = report.items.find((i) => i.kind === "map");
  86. const bad = report.items.find((i) => i.kind === "mystery");
  87. expect(ok?.status).toBe("ok");
  88. expect(bad?.status).toBe("error");
  89. expect(bad?.errors?.[0]).toContain("unknown content kind: mystery");
  90. expect(registry.get("map", "farm")).toBeDefined();
  91. });
  92. it("reports a per-kind invalid def with per-field issues and registers nothing for it", async () => {
  93. const bundle: ContentBundle = {
  94. format: "nengine-bundle",
  95. version: 1,
  96. content: {
  97. item: [validItem(), { id: "bad", name: 42, kind: "nope" }],
  98. },
  99. };
  100. const registry = new ContentRegistry();
  101. const loader = new ContentLoader(registry);
  102. const report = await loader.loadBundle(bundle);
  103. expect(report.ok).toBe(false);
  104. const bad = report.items.find((i) => i.id === "bad");
  105. expect(bad?.status).toBe("error");
  106. expect(bad?.errors?.length).toBeGreaterThan(0);
  107. expect(registry.get("item", "hoe")).toBeDefined();
  108. expect(registry.get("item", "bad")).toBeUndefined();
  109. });
  110. it("treats a missing kind as a no-op", async () => {
  111. const bundle: ContentBundle = {
  112. format: "nengine-bundle",
  113. version: 1,
  114. content: { map: [validMap()] },
  115. };
  116. const registry = new ContentRegistry();
  117. const loader = new ContentLoader(registry);
  118. const report = await loader.loadBundle(bundle);
  119. expect(report.ok).toBe(true);
  120. expect(report.items).toHaveLength(1);
  121. expect(registry.ids("sprite")).toEqual([]);
  122. });
  123. it("throws a clear error on a structurally invalid bundle", async () => {
  124. const registry = new ContentRegistry();
  125. const loader = new ContentLoader(registry);
  126. await expect(
  127. loader.loadBundle({ format: "nengine-bundle", version: 99, content: {} } as unknown as ContentBundle),
  128. ).rejects.toThrow("not a valid nengine-bundle");
  129. });
  130. it("accepts an additive mods map and rejects a malformed one (D36)", async () => {
  131. const registry = new ContentRegistry();
  132. const loader = new ContentLoader(registry);
  133. const withMods: ContentBundle = {
  134. format: "nengine-bundle",
  135. version: 1,
  136. content: { map: [validMap()] },
  137. mods: { "my-mod": { manifest: { id: "my-mod", version: "1.0.0" }, code: "export default {};" } },
  138. };
  139. const report = await loader.loadBundle(withMods);
  140. expect(report.ok).toBe(true);
  141. const registry2 = new ContentRegistry();
  142. const loader2 = new ContentLoader(registry2);
  143. await expect(
  144. loader2.loadBundle({
  145. format: "nengine-bundle",
  146. version: 1,
  147. content: { map: [validMap()] },
  148. mods: { "my-mod": { manifest: { id: "my-mod" }, code: 42 } },
  149. } as unknown as ContentBundle),
  150. ).rejects.toThrow("not a valid nengine-bundle");
  151. });
  152. it("fs load() behavior is unchanged (zero regression)", async () => {
  153. const registry = new ContentRegistry();
  154. const loader = new ContentLoader(registry);
  155. const report = await loader.load(sampleContentDir);
  156. expect(report.ok).toBe(true);
  157. expect(report.items.length).toBeGreaterThan(0);
  158. expect(registry.ids("map")).toEqual(["farm"]);
  159. expect(registry.ids("time")).toEqual(["default"]);
  160. });
  161. it("loadBundle populates a metadata-only asset table from assetsIndex (split mode, D37)", async () => {
  162. const registry = new ContentRegistry();
  163. const loader = new ContentLoader(registry);
  164. const split: ContentBundle = {
  165. format: "nengine-bundle",
  166. version: 1,
  167. content: { map: [validMap()], time: [validTime()] },
  168. assetsIndex: {
  169. "grass-1": { kind: "image", mime: "image/png", size: 128, file: "nengine-assets/grass-1.png" },
  170. "click-1": { kind: "audio", mime: "audio/ogg", size: 64, file: "nengine-assets/click-1.ogg" },
  171. },
  172. };
  173. const report = await loader.loadBundle(split);
  174. expect(report.ok).toBe(true);
  175. const grass = registry.assets.get("grass-1");
  176. expect(grass).toMatchObject({ kind: "image", mime: "image/png", size: 128, file: "nengine-assets/grass-1.png" });
  177. expect(grass?.data).toBeUndefined();
  178. expect(registry.assets.get("click-1")?.kind).toBe("audio");
  179. });
  180. it("loadBundle inline mode merges index metadata (size) onto base64 entries (D37)", async () => {
  181. const registry = new ContentRegistry();
  182. const loader = new ContentLoader(registry);
  183. const bundle: ContentBundle = {
  184. format: "nengine-bundle",
  185. version: 1,
  186. content: { map: [validMap()] },
  187. assets: { "grass-1": { kind: "image", mime: "image/png", data: "aGVsbG8=" } },
  188. assetsIndex: { "grass-1": { kind: "image", mime: "image/png", size: 5, file: "nengine-assets/grass-1.png" } },
  189. };
  190. const report = await loader.loadBundle(bundle);
  191. expect(report.ok).toBe(true);
  192. const grass = registry.assets.get("grass-1");
  193. expect(grass?.data).toBe("aGVsbG8=");
  194. expect(grass?.size).toBe(5);
  195. expect(grass?.file).toBe("nengine-assets/grass-1.png");
  196. });
  197. });
  198. describe("createEngine bundle mode", () => {
  199. it("boots from a bundle, seeds the world, and ticks systems with no fs access", async () => {
  200. const bundle = await compileBundle(sampleContentDir);
  201. const engine = createEngine({ bundle });
  202. await engine.start();
  203. try {
  204. expect(engine.contentDir).toBeUndefined();
  205. expect(engine.registry.ids("map")).toEqual(["farm"]);
  206. expect(engine.registry.ids("item")).toContain("hoe");
  207. expect(engine.registry.ids("sprite")).toContain("player");
  208. const player = engine.world.get("player");
  209. expect(player?.type).toBe("player");
  210. expect(player?.x).toBe(2);
  211. expect(player?.y).toBe(4);
  212. const time = engine.systems.get<TimeSystem>("time");
  213. expect(time?.get()).toEqual({ day: 1, season: "spring", tickInDay: 0 });
  214. const intent = engine.systems.get<IntentSystem>("intent");
  215. if (!intent) throw new Error("intent system not booted");
  216. const result = intent.handle("move", { tile: { x: 3, y: 4 } });
  217. expect(result.ok).toBe(true);
  218. expect(engine.world.get("player")?.x).toBe(3);
  219. engine.tick(480);
  220. expect(time?.get().day).toBe(2);
  221. } finally {
  222. await engine.stop();
  223. }
  224. });
  225. });