pack.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. import { mkdtemp, readFile, rm, writeFile, mkdir, stat, readdir } from "node:fs/promises";
  2. import { execFileSync } from "node:child_process";
  3. import { tmpdir } from "node:os";
  4. import { existsSync } from "node:fs";
  5. import { join, resolve } from "node:path";
  6. import { describe, expect, it } from "vitest";
  7. import { assetByteSize, createEngine, type ContentBundle, type IntentSystem, type TimeSystem } from "@nengine/core";
  8. import { DEFAULT_OUT_DIR, compileBundle, packGame, parsePackArgs, splitBundleAssets, USAGE } from "../src/index.js";
  9. import { DEFAULT_PRODUCT_NAME, generateDesktop } from "../src/desktop.js";
  10. const repoRoot = resolve(process.cwd(), "../..");
  11. const sampleContentDir = resolve(repoRoot, "examples", "sample-game", "content");
  12. const sampleModsDir = resolve(repoRoot, "examples", "sample-game", "mods");
  13. describe("parsePackArgs", () => {
  14. it("resolves --content and --out relative to cwd", () => {
  15. const args = parsePackArgs({ argv: ["--content", "examples/sample-game/content", "--out", "out"], cwd: repoRoot });
  16. expect(args).not.toBeNull();
  17. expect(args?.contentDir).toBe(sampleContentDir);
  18. expect(args?.outDir).toBe(resolve(repoRoot, "out"));
  19. });
  20. it("defaults --out to ./dist-pack", () => {
  21. const args = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
  22. expect(args?.outDir).toBe(resolve(repoRoot, DEFAULT_OUT_DIR));
  23. });
  24. it("returns null for --help", () => {
  25. expect(parsePackArgs({ argv: ["--help"] })).toBeNull();
  26. expect(parsePackArgs({ argv: ["-h"] })).toBeNull();
  27. expect(USAGE).toContain("Usage: nengine-pack");
  28. });
  29. it("throws when --content is missing", () => {
  30. expect(() => parsePackArgs({ argv: [] })).toThrow("--content <dir> is required");
  31. });
  32. it("parses --desktop and --name (D32)", () => {
  33. const args = parsePackArgs({
  34. argv: ["--content", "content", "--desktop", "--name", "Pinefield Valley"],
  35. cwd: repoRoot,
  36. });
  37. expect(args?.desktop).toBe(true);
  38. expect(args?.name).toBe("Pinefield Valley");
  39. const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
  40. expect(plain?.desktop).toBe(false);
  41. expect(plain?.name).toBeUndefined();
  42. expect(USAGE).toContain("--desktop");
  43. expect(USAGE).toContain("--name");
  44. });
  45. it("parses --mods and resolves it relative to cwd (D36)", () => {
  46. const args = parsePackArgs({ argv: ["--content", "content", "--mods", "examples/sample-game/mods"], cwd: repoRoot });
  47. expect(args?.modsDir).toBe(sampleModsDir);
  48. const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
  49. expect(plain?.modsDir).toBeUndefined();
  50. expect(USAGE).toContain("--mods <dir>");
  51. });
  52. it("--installer implies --desktop (D36)", () => {
  53. const args = parsePackArgs({ argv: ["--content", "content", "--installer"], cwd: repoRoot });
  54. expect(args?.installer).toBe(true);
  55. expect(args?.desktop).toBe(true);
  56. const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
  57. expect(plain?.installer).toBe(false);
  58. expect(plain?.desktop).toBe(false);
  59. expect(USAGE).toContain("--installer");
  60. });
  61. it("parses --split-assets (D37) with the default staying inline", () => {
  62. const args = parsePackArgs({ argv: ["--content", "content", "--split-assets"], cwd: repoRoot });
  63. expect(args?.splitAssets).toBe(true);
  64. expect(USAGE).toContain("--split-assets");
  65. const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot });
  66. expect(plain?.splitAssets).toBe(false);
  67. });
  68. });
  69. describe("desktop packaging (D32)", () => {
  70. it("generateDesktop writes a runnable shell: valid package.json JSON + main.cjs wiring + node --check", async () => {
  71. const out = await mkdtemp(join(tmpdir(), "nengine-desktop-"));
  72. try {
  73. const result = await generateDesktop(out, { productName: "Pinefield Valley" });
  74. expect(result.dir).toBe(join(out, "desktop"));
  75. expect(existsSync(join(out, "desktop", "main.cjs"))).toBe(true);
  76. expect(existsSync(join(out, "desktop", "package.json"))).toBe(true);
  77. expect(existsSync(join(out, "desktop", "README-desktop.md"))).toBe(true);
  78. const pkg = JSON.parse(await readFile(result.packageFile, "utf8"));
  79. expect(pkg.name).toBe("pinefield-valley");
  80. expect(pkg.main).toBe("main.cjs");
  81. expect(pkg.scripts.start).toBe("electron .");
  82. expect(pkg.devDependencies.electron).toMatch(/^\^\d+\./);
  83. const main = await readFile(result.mainFile, "utf8");
  84. expect(main).toContain("createServer");
  85. expect(main).toContain("127.0.0.1");
  86. expect(main).toContain("loadURL");
  87. expect(main).toContain("Pinefield Valley");
  88. expect(main).toContain("contextIsolation: true");
  89. expect(main).toContain("nodeIntegration: false");
  90. expect(main).toContain("window-all-closed");
  91. expect(main).toContain('resolve(__dirname, "..")');
  92. execFileSync(process.execPath, ["--check", result.mainFile], { stdio: "pipe" });
  93. } finally {
  94. await rm(out, { recursive: true, force: true });
  95. }
  96. });
  97. it("generateDesktop defaults the product name when --name is absent", async () => {
  98. const out = await mkdtemp(join(tmpdir(), "nengine-desktop-"));
  99. try {
  100. const result = await generateDesktop(out);
  101. const main = await readFile(result.mainFile, "utf8");
  102. expect(main).toContain(DEFAULT_PRODUCT_NAME);
  103. } finally {
  104. await rm(out, { recursive: true, force: true });
  105. }
  106. });
  107. it("packGame --desktop writes the desktop app alongside the static site", async () => {
  108. const out = await mkdtemp(join(tmpdir(), "nengine-pack-desktop-"));
  109. try {
  110. const result = await packGame({
  111. contentDir: sampleContentDir,
  112. outDir: out,
  113. desktop: true,
  114. name: "Pinefield Valley",
  115. });
  116. expect(result.desktopDir).toBe(join(out, "desktop"));
  117. expect(existsSync(join(out, "desktop", "main.cjs"))).toBe(true);
  118. expect(existsSync(join(out, "desktop", "package.json"))).toBe(true);
  119. const pkg = JSON.parse(await readFile(join(out, "desktop", "package.json"), "utf8"));
  120. expect(pkg.name).toBe("pinefield-valley");
  121. } finally {
  122. await rm(out, { recursive: true, force: true });
  123. }
  124. });
  125. it("generateDesktop installer: electron-builder.yml + dist scripts + site files copied (D36)", async () => {
  126. const out = await mkdtemp(join(tmpdir(), "nengine-installer-"));
  127. try {
  128. await mkdir(join(out, "assets"), { recursive: true });
  129. await writeFile(join(out, "index.html"), "<html></html>", "utf8");
  130. await writeFile(join(out, "bundle.json"), "{}", "utf8");
  131. const result = await generateDesktop(out, { productName: "Pinefield Valley", installer: true });
  132. expect(result.installerFile).toBe(join(out, "desktop", "electron-builder.yml"));
  133. expect(existsSync(result.installerFile as string)).toBe(true);
  134. const yaml = await readFile(result.installerFile as string, "utf8");
  135. expect(yaml).toContain("appId: com.nengine.pinefield-valley");
  136. expect(yaml).toContain("productName: Pinefield Valley");
  137. expect(yaml).toContain("files:");
  138. expect(yaml).toContain("bundle.json");
  139. expect(yaml).toContain("assets/**");
  140. expect(yaml).toContain("linux:");
  141. expect(yaml).toContain("- AppImage");
  142. expect(yaml).toContain("category: Game");
  143. expect(yaml).toContain("win:");
  144. expect(yaml).toContain("- nsis");
  145. expect(yaml).toContain("oneClick: true");
  146. const pkg = JSON.parse(await readFile(result.packageFile, "utf8"));
  147. expect(pkg.scripts.dist).toBe("electron-builder --linux");
  148. expect(pkg.scripts["dist:win"]).toBe("electron-builder --win");
  149. expect(pkg.devDependencies["electron-builder"]).toMatch(/^\^\d+\./);
  150. // installer mode copies the site into desktop/ so electron-builder can
  151. // package it (main.cjs resolves the site as ".." — the app dir once packaged)
  152. expect(existsSync(join(out, "desktop", "index.html"))).toBe(true);
  153. expect(existsSync(join(out, "desktop", "bundle.json"))).toBe(true);
  154. expect(existsSync(join(out, "desktop", "assets"))).toBe(true);
  155. const readme = await readFile(join(out, "desktop", "README-desktop.md"), "utf8");
  156. expect(readme).toContain("Build an installer");
  157. expect(readme).toContain("npm run dist");
  158. } finally {
  159. await rm(out, { recursive: true, force: true });
  160. }
  161. });
  162. });
  163. describe("compileBundle", () => {
  164. it("compiles a valid content dir into the nengine-bundle format", async () => {
  165. const bundle = await compileBundle(sampleContentDir);
  166. expect(bundle.format).toBe("nengine-bundle");
  167. expect(bundle.version).toBe(1);
  168. expect(bundle.content["map"]?.[0]).toMatchObject({ id: "farm", width: 16, height: 16 });
  169. expect(bundle.content["time"]?.[0]).toMatchObject({ id: "default" });
  170. expect(bundle.content["item"]?.some((d) => (d as { id?: string }).id === "hoe")).toBe(true);
  171. expect(bundle.content["sprite"]?.some((d) => (d as { id?: string }).id === "player")).toBe(true);
  172. });
  173. it("collects content/assets/ binaries into bundle.assets as base64 (D31)", async () => {
  174. const bundle = await compileBundle(sampleContentDir);
  175. expect(bundle.assets).toBeDefined();
  176. const ids = Object.keys(bundle.assets ?? {});
  177. expect(ids).toEqual(
  178. expect.arrayContaining(["grass-1", "water-1", "tilled-1", "plant-sprout", "coop-1", "fence-1"]),
  179. );
  180. expect(bundle.assets?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
  181. expect(typeof bundle.assets?.["grass-1"]?.data).toBe("string");
  182. expect(bundle.assets?.["click-1"]).toMatchObject({ kind: "audio", mime: "audio/ogg" });
  183. });
  184. it("inline bundles carry a metadata assetsIndex alongside the base64 assets (D37)", async () => {
  185. const bundle = await compileBundle(sampleContentDir);
  186. expect(bundle.assetsIndex).toBeDefined();
  187. expect(bundle.assetsIndex?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
  188. expect(typeof bundle.assetsIndex?.["grass-1"]?.size).toBe("number");
  189. expect(bundle.assetsIndex?.["grass-1"]?.file).toBe("nengine-assets/grass-1.png");
  190. expect(bundle.assets?.["grass-1"]?.data).toBeDefined();
  191. });
  192. it("splitBundleAssets strips the bytes into a metadata-only assetsIndex (D37)", async () => {
  193. const inline = await compileBundle(sampleContentDir);
  194. const split = splitBundleAssets(inline);
  195. expect(split.assets).toBeUndefined();
  196. expect(split.assetsIndex).toBeDefined();
  197. const grass = split.assetsIndex?.["grass-1"];
  198. expect(grass).toMatchObject({ kind: "image", mime: "image/png", size: assetByteSize(inline.assets?.["grass-1"] as { data: string }) });
  199. expect(grass?.file).toBe("nengine-assets/grass-1.png");
  200. expect(grass?.file?.endsWith(".png")).toBe(true);
  201. expect(split.assetsIndex?.["click-1"]?.file?.endsWith(".ogg")).toBe(true);
  202. // no base64 bytes in the split form
  203. expect(JSON.stringify(split)).not.toContain(";base64,");
  204. // content/mods are untouched
  205. expect(split.content).toEqual(inline.content);
  206. });
  207. it("compileBundle(splitAssets: true) returns the split layout", async () => {
  208. const split = await compileBundle(sampleContentDir, undefined, true);
  209. expect(split.assets).toBeUndefined();
  210. expect(Object.keys(split.assetsIndex ?? {}).length).toBeGreaterThan(0);
  211. expect(split.assetsIndex?.["grass-1"]?.file).toBe("nengine-assets/grass-1.png");
  212. });
  213. it("throws for a missing content dir", async () => {
  214. await expect(compileBundle(resolve(repoRoot, "does-not-exist"))).rejects.toThrow(/validation failed/);
  215. });
  216. it("fails loudly with per-kind/per-id messages for invalid content", async () => {
  217. const tmp = await mkdtemp(join(tmpdir(), "nengine-pack-"));
  218. try {
  219. await mkdir(join(tmp, "maps"), { recursive: true });
  220. await writeFile(join(tmp, "maps", "bad.yaml"), ["id: bad", "width: nope", "height: 2", ""].join("\n"));
  221. await expect(compileBundle(tmp)).rejects.toThrow(/map\/bad/);
  222. } finally {
  223. await rm(tmp, { recursive: true, force: true });
  224. }
  225. });
  226. it("packages a mods dir into bundle.mods with manifest + entry source text (D36)", async () => {
  227. const bundle = await compileBundle(sampleContentDir, sampleModsDir);
  228. expect(bundle.mods).toBeDefined();
  229. const mod = bundle.mods?.["sample-game-mod"];
  230. expect(mod).toBeDefined();
  231. expect(mod?.manifest).toEqual({ id: "sample-game-mod", version: "0.1.0" });
  232. expect(typeof mod?.code).toBe("string");
  233. expect(mod?.code.length).toBeGreaterThan(100);
  234. expect(mod?.code).toContain("onDayStart");
  235. });
  236. it("leaves bundle.mods undefined without --mods (zero regression)", async () => {
  237. const bundle = await compileBundle(sampleContentDir);
  238. expect(bundle.mods).toBeUndefined();
  239. });
  240. it("fails loudly when a mods dir entry is missing or its manifest is invalid (D36)", async () => {
  241. const tmp = await mkdtemp(join(tmpdir(), "nengine-modpack-"));
  242. try {
  243. await mkdir(join(tmp, "no-entry"), { recursive: true });
  244. await mkdir(join(tmp, "no-manifest"), { recursive: true });
  245. await writeFile(join(tmp, "no-manifest", "index.mjs"), "export default { hooks: {} };", "utf8");
  246. await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/mod packaging failed/);
  247. await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/no-entry: no index\.mjs or index\.js entry/);
  248. await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/no-manifest: manifest \{ id, version \} required/);
  249. } finally {
  250. await rm(tmp, { recursive: true, force: true });
  251. }
  252. });
  253. });
  254. describe("packGame integration", () => {
  255. it("builds a static site + bundle.json and the bundle boots via createEngine({bundle})", async () => {
  256. const out = await mkdtemp(join(tmpdir(), "nengine-pack-out-"));
  257. try {
  258. const result = await packGame({ contentDir: sampleContentDir, outDir: out });
  259. expect(existsSync(join(out, "bundle.json"))).toBe(true);
  260. expect(existsSync(join(out, "index.html"))).toBe(true);
  261. expect(existsSync(join(out, "assets"))).toBe(true);
  262. const html = await readFile(join(out, "index.html"), "utf8");
  263. expect(html).toMatch(/src="\.\/assets\/[^"]+\.js"/);
  264. expect(html).not.toContain("apps/dev");
  265. const raw = await readFile(join(out, "bundle.json"), "utf8");
  266. const bundle = JSON.parse(raw) as ContentBundle;
  267. expect(bundle.format).toBe("nengine-bundle");
  268. expect(bundle.version).toBe(1);
  269. expect(Object.keys(bundle.content).length).toBeGreaterThanOrEqual(11);
  270. expect(Object.keys(bundle.assets ?? {}).length).toBeGreaterThan(0);
  271. expect(bundle.assets?.["grass-1"]?.kind).toBe("image");
  272. const engine = createEngine({ bundle });
  273. await engine.start();
  274. try {
  275. const player = engine.world.get("player");
  276. expect(player?.type).toBe("player");
  277. expect(player?.x).toBe(2);
  278. expect(player?.y).toBe(4);
  279. expect(engine.assets.size).toBeGreaterThan(0);
  280. expect(engine.assets.get("grass-1")?.kind).toBe("image");
  281. const time = engine.systems.get<TimeSystem>("time");
  282. expect(time?.get()).toEqual({ day: 1, season: "spring", tickInDay: 0 });
  283. const intent = engine.systems.get<IntentSystem>("intent");
  284. if (!intent) throw new Error("intent system not booted");
  285. const interact = intent.handle("interact", { tile: { x: 10, y: 10 } });
  286. expect(interact.ok).toBe(true);
  287. expect(engine.world.get("plot:10:10")?.tilled).toBe(true);
  288. engine.tick(480);
  289. expect(time?.get().day).toBe(2);
  290. // engine.tick is the packaged game's day driver (D23 — the dev
  291. // "Advance day" button steps the same simulation path).
  292. const sow = intent.handle("interact", { tile: { x: 10, y: 10 } });
  293. expect(sow.ok).toBe(true);
  294. expect(sow.detail).toContain("sowed");
  295. expect(engine.world.get("plot:10:10")?.spriteRef).toBe("parsnip-sprout");
  296. expect(intent.handle("interact", { tile: { x: 10, y: 10 } }).detail).toBe("watered plot");
  297. engine.tick(480);
  298. engine.tick(480);
  299. const grown = engine.world.get("plot:10:10") as { stageIndex?: number; spriteRef?: string };
  300. expect(grown?.stageIndex).toBe(1);
  301. expect(grown?.spriteRef).toBe("parsnip-grown");
  302. } finally {
  303. await engine.stop();
  304. }
  305. } finally {
  306. await rm(out, { recursive: true, force: true });
  307. }
  308. });
  309. it("packGame --mods packages Mods into bundle.json and they boot in bundle mode (D36)", async () => {
  310. const out = await mkdtemp(join(tmpdir(), "nengine-pack-mods-"));
  311. try {
  312. const result = await packGame({ contentDir: sampleContentDir, outDir: out, modsDir: sampleModsDir });
  313. expect(result.modCount).toBe(1);
  314. const raw = await readFile(join(out, "bundle.json"), "utf8");
  315. const bundle = JSON.parse(raw) as ContentBundle;
  316. expect(bundle.mods?.["sample-game-mod"]?.manifest).toEqual({ id: "sample-game-mod", version: "0.1.0" });
  317. expect(typeof bundle.mods?.["sample-game-mod"]?.code).toBe("string");
  318. const engine = createEngine({ bundle });
  319. await engine.start();
  320. try {
  321. expect(engine.mods).toHaveLength(1);
  322. expect(engine.mods[0]?.id).toBe("sample-game-mod");
  323. engine.tick(480);
  324. expect(engine.world.get("mod:sample-stats")?.greetings).toBe(1);
  325. expect(engine.world.get("npc:mayor")?.type).toBe("npc");
  326. } finally {
  327. await engine.stop();
  328. }
  329. } finally {
  330. await rm(out, { recursive: true, force: true });
  331. }
  332. });
  333. it("packGame --split-assets writes nengine-assets/ files + a metadata-only bundle.json (D37)", async () => {
  334. const out = await mkdtemp(join(tmpdir(), "nengine-pack-split-"));
  335. try {
  336. const inline = await compileBundle(sampleContentDir);
  337. const result = await packGame({ contentDir: sampleContentDir, outDir: out, splitAssets: true });
  338. expect(result.bundle.assets).toBeUndefined();
  339. expect(result.bundle.assetsIndex).toBeDefined();
  340. const raw = await readFile(join(out, "bundle.json"), "utf8");
  341. const bundle = JSON.parse(raw) as ContentBundle;
  342. expect(bundle.assets).toBeUndefined();
  343. expect(bundle.assetsIndex).toBeDefined();
  344. expect(bundle.assetsIndex?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png", file: "nengine-assets/grass-1.png" });
  345. expect(bundle.assetsIndex?.["click-1"]?.file).toBe("nengine-assets/click-1.ogg");
  346. expect(raw).not.toContain(";base64,");
  347. // sibling files exist and match the source bytes
  348. const nengineAssets = join(out, "nengine-assets");
  349. expect(existsSync(nengineAssets)).toBe(true);
  350. expect(existsSync(join(nengineAssets, "grass-1.png"))).toBe(true);
  351. expect(existsSync(join(nengineAssets, "click-1.ogg"))).toBe(true);
  352. const files = await readdir(nengineAssets);
  353. expect(files.length).toBe(Object.keys(inline.assets ?? {}).length);
  354. expect(files.filter((f) => f.endsWith(".png")).length).toBeGreaterThan(0);
  355. expect(files.filter((f) => f.endsWith(".ogg")).length).toBeGreaterThan(0);
  356. // sizes recorded in the index match the actual split files (and the source)
  357. for (const [id, meta] of Object.entries(bundle.assetsIndex ?? {})) {
  358. const file = join(nengineAssets, meta.file?.split("/")[1] ?? "");
  359. const bytes = (await stat(file)).size;
  360. expect(bytes).toBe(meta.size);
  361. expect(bytes).toBe(assetByteSize(inline.assets?.[id] as { data: string }));
  362. }
  363. // the split bundle still boots via createEngine (metadata-only asset table)
  364. const engine = createEngine({ bundle });
  365. await engine.start();
  366. try {
  367. expect(engine.assets.get("grass-1")).toMatchObject({ kind: "image", mime: "image/png" });
  368. expect(engine.assets.get("grass-1")?.data).toBeUndefined();
  369. expect(engine.assets.get("grass-1")?.file).toBe("nengine-assets/grass-1.png");
  370. } finally {
  371. await engine.stop();
  372. }
  373. } finally {
  374. await rm(out, { recursive: true, force: true });
  375. }
  376. });
  377. });