import { mkdtemp, readFile, rm, writeFile, mkdir, stat, readdir } from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { assetByteSize, createEngine, type ContentBundle, type IntentSystem, type TimeSystem } from "@nengine/core"; import { DEFAULT_OUT_DIR, compileBundle, packGame, parsePackArgs, splitBundleAssets, USAGE } from "../src/index.js"; import { DEFAULT_PRODUCT_NAME, generateDesktop } from "../src/desktop.js"; const repoRoot = resolve(process.cwd(), "../.."); const sampleContentDir = resolve(repoRoot, "examples", "sample-game", "content"); const sampleModsDir = resolve(repoRoot, "examples", "sample-game", "mods"); describe("parsePackArgs", () => { it("resolves --content and --out relative to cwd", () => { const args = parsePackArgs({ argv: ["--content", "examples/sample-game/content", "--out", "out"], cwd: repoRoot }); expect(args).not.toBeNull(); expect(args?.contentDir).toBe(sampleContentDir); expect(args?.outDir).toBe(resolve(repoRoot, "out")); }); it("defaults --out to ./dist-pack", () => { const args = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot }); expect(args?.outDir).toBe(resolve(repoRoot, DEFAULT_OUT_DIR)); }); it("returns null for --help", () => { expect(parsePackArgs({ argv: ["--help"] })).toBeNull(); expect(parsePackArgs({ argv: ["-h"] })).toBeNull(); expect(USAGE).toContain("Usage: nengine-pack"); }); it("throws when --content is missing", () => { expect(() => parsePackArgs({ argv: [] })).toThrow("--content is required"); }); it("parses --desktop and --name (D32)", () => { const args = parsePackArgs({ argv: ["--content", "content", "--desktop", "--name", "Pinefield Valley"], cwd: repoRoot, }); expect(args?.desktop).toBe(true); expect(args?.name).toBe("Pinefield Valley"); const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot }); expect(plain?.desktop).toBe(false); expect(plain?.name).toBeUndefined(); expect(USAGE).toContain("--desktop"); expect(USAGE).toContain("--name"); }); it("parses --mods and resolves it relative to cwd (D36)", () => { const args = parsePackArgs({ argv: ["--content", "content", "--mods", "examples/sample-game/mods"], cwd: repoRoot }); expect(args?.modsDir).toBe(sampleModsDir); const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot }); expect(plain?.modsDir).toBeUndefined(); expect(USAGE).toContain("--mods "); }); it("--installer implies --desktop (D36)", () => { const args = parsePackArgs({ argv: ["--content", "content", "--installer"], cwd: repoRoot }); expect(args?.installer).toBe(true); expect(args?.desktop).toBe(true); const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot }); expect(plain?.installer).toBe(false); expect(plain?.desktop).toBe(false); expect(USAGE).toContain("--installer"); }); it("parses --split-assets (D37) with the default staying inline", () => { const args = parsePackArgs({ argv: ["--content", "content", "--split-assets"], cwd: repoRoot }); expect(args?.splitAssets).toBe(true); expect(USAGE).toContain("--split-assets"); const plain = parsePackArgs({ argv: ["--content", "content"], cwd: repoRoot }); expect(plain?.splitAssets).toBe(false); }); }); describe("desktop packaging (D32)", () => { it("generateDesktop writes a runnable shell: valid package.json JSON + main.cjs wiring + node --check", async () => { const out = await mkdtemp(join(tmpdir(), "nengine-desktop-")); try { const result = await generateDesktop(out, { productName: "Pinefield Valley" }); expect(result.dir).toBe(join(out, "desktop")); expect(existsSync(join(out, "desktop", "main.cjs"))).toBe(true); expect(existsSync(join(out, "desktop", "package.json"))).toBe(true); expect(existsSync(join(out, "desktop", "README-desktop.md"))).toBe(true); const pkg = JSON.parse(await readFile(result.packageFile, "utf8")); expect(pkg.name).toBe("pinefield-valley"); expect(pkg.main).toBe("main.cjs"); expect(pkg.scripts.start).toBe("electron ."); expect(pkg.devDependencies.electron).toMatch(/^\^\d+\./); const main = await readFile(result.mainFile, "utf8"); expect(main).toContain("createServer"); expect(main).toContain("127.0.0.1"); expect(main).toContain("loadURL"); expect(main).toContain("Pinefield Valley"); expect(main).toContain("contextIsolation: true"); expect(main).toContain("nodeIntegration: false"); expect(main).toContain("window-all-closed"); expect(main).toContain('resolve(__dirname, "..")'); execFileSync(process.execPath, ["--check", result.mainFile], { stdio: "pipe" }); } finally { await rm(out, { recursive: true, force: true }); } }); it("generateDesktop defaults the product name when --name is absent", async () => { const out = await mkdtemp(join(tmpdir(), "nengine-desktop-")); try { const result = await generateDesktop(out); const main = await readFile(result.mainFile, "utf8"); expect(main).toContain(DEFAULT_PRODUCT_NAME); } finally { await rm(out, { recursive: true, force: true }); } }); it("packGame --desktop writes the desktop app alongside the static site", async () => { const out = await mkdtemp(join(tmpdir(), "nengine-pack-desktop-")); try { const result = await packGame({ contentDir: sampleContentDir, outDir: out, desktop: true, name: "Pinefield Valley", }); expect(result.desktopDir).toBe(join(out, "desktop")); expect(existsSync(join(out, "desktop", "main.cjs"))).toBe(true); expect(existsSync(join(out, "desktop", "package.json"))).toBe(true); const pkg = JSON.parse(await readFile(join(out, "desktop", "package.json"), "utf8")); expect(pkg.name).toBe("pinefield-valley"); } finally { await rm(out, { recursive: true, force: true }); } }); it("generateDesktop installer: electron-builder.yml + dist scripts + site files copied (D36)", async () => { const out = await mkdtemp(join(tmpdir(), "nengine-installer-")); try { await mkdir(join(out, "assets"), { recursive: true }); await writeFile(join(out, "index.html"), "", "utf8"); await writeFile(join(out, "bundle.json"), "{}", "utf8"); const result = await generateDesktop(out, { productName: "Pinefield Valley", installer: true }); expect(result.installerFile).toBe(join(out, "desktop", "electron-builder.yml")); expect(existsSync(result.installerFile as string)).toBe(true); const yaml = await readFile(result.installerFile as string, "utf8"); expect(yaml).toContain("appId: com.nengine.pinefield-valley"); expect(yaml).toContain("productName: Pinefield Valley"); expect(yaml).toContain("files:"); expect(yaml).toContain("bundle.json"); expect(yaml).toContain("assets/**"); expect(yaml).toContain("linux:"); expect(yaml).toContain("- AppImage"); expect(yaml).toContain("category: Game"); expect(yaml).toContain("win:"); expect(yaml).toContain("- nsis"); expect(yaml).toContain("oneClick: true"); const pkg = JSON.parse(await readFile(result.packageFile, "utf8")); expect(pkg.scripts.dist).toBe("electron-builder --linux"); expect(pkg.scripts["dist:win"]).toBe("electron-builder --win"); expect(pkg.devDependencies["electron-builder"]).toMatch(/^\^\d+\./); // installer mode copies the site into desktop/ so electron-builder can // package it (main.cjs resolves the site as ".." — the app dir once packaged) expect(existsSync(join(out, "desktop", "index.html"))).toBe(true); expect(existsSync(join(out, "desktop", "bundle.json"))).toBe(true); expect(existsSync(join(out, "desktop", "assets"))).toBe(true); const readme = await readFile(join(out, "desktop", "README-desktop.md"), "utf8"); expect(readme).toContain("Build an installer"); expect(readme).toContain("npm run dist"); } finally { await rm(out, { recursive: true, force: true }); } }); }); describe("compileBundle", () => { it("compiles a valid content dir into the nengine-bundle format", async () => { const bundle = await compileBundle(sampleContentDir); expect(bundle.format).toBe("nengine-bundle"); expect(bundle.version).toBe(1); expect(bundle.content["map"]?.[0]).toMatchObject({ id: "farm", width: 20, height: 16 }); expect(bundle.content["time"]?.[0]).toMatchObject({ id: "default" }); expect(bundle.content["item"]?.some((d) => (d as { id?: string }).id === "hoe")).toBe(true); expect(bundle.content["sprite"]?.some((d) => (d as { id?: string }).id === "player")).toBe(true); }); it("collects content/assets/ binaries into bundle.assets as base64 (D31)", async () => { const bundle = await compileBundle(sampleContentDir); expect(bundle.assets).toBeDefined(); const ids = Object.keys(bundle.assets ?? {}); expect(ids).toEqual( expect.arrayContaining(["grass-1", "water-1", "tilled-1", "plant-sprout", "coop-1", "fence-1"]), ); expect(bundle.assets?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" }); expect(typeof bundle.assets?.["grass-1"]?.data).toBe("string"); expect(bundle.assets?.["click-1"]).toMatchObject({ kind: "audio", mime: "audio/ogg" }); }); it("inline bundles carry a metadata assetsIndex alongside the base64 assets (D37)", async () => { const bundle = await compileBundle(sampleContentDir); expect(bundle.assetsIndex).toBeDefined(); expect(bundle.assetsIndex?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" }); expect(typeof bundle.assetsIndex?.["grass-1"]?.size).toBe("number"); expect(bundle.assetsIndex?.["grass-1"]?.file).toBe("nengine-assets/grass-1.png"); expect(bundle.assets?.["grass-1"]?.data).toBeDefined(); }); it("splitBundleAssets strips the bytes into a metadata-only assetsIndex (D37)", async () => { const inline = await compileBundle(sampleContentDir); const split = splitBundleAssets(inline); expect(split.assets).toBeUndefined(); expect(split.assetsIndex).toBeDefined(); const grass = split.assetsIndex?.["grass-1"]; expect(grass).toMatchObject({ kind: "image", mime: "image/png", size: assetByteSize(inline.assets?.["grass-1"] as { data: string }) }); expect(grass?.file).toBe("nengine-assets/grass-1.png"); expect(grass?.file?.endsWith(".png")).toBe(true); expect(split.assetsIndex?.["click-1"]?.file?.endsWith(".ogg")).toBe(true); // no base64 bytes in the split form expect(JSON.stringify(split)).not.toContain(";base64,"); // content/mods are untouched expect(split.content).toEqual(inline.content); }); it("compileBundle(splitAssets: true) returns the split layout", async () => { const split = await compileBundle(sampleContentDir, undefined, true); expect(split.assets).toBeUndefined(); expect(Object.keys(split.assetsIndex ?? {}).length).toBeGreaterThan(0); expect(split.assetsIndex?.["grass-1"]?.file).toBe("nengine-assets/grass-1.png"); }); it("throws for a missing content dir", async () => { await expect(compileBundle(resolve(repoRoot, "does-not-exist"))).rejects.toThrow(/validation failed/); }); it("fails loudly with per-kind/per-id messages for invalid content", async () => { const tmp = await mkdtemp(join(tmpdir(), "nengine-pack-")); try { await mkdir(join(tmp, "maps"), { recursive: true }); await writeFile(join(tmp, "maps", "bad.yaml"), ["id: bad", "width: nope", "height: 2", ""].join("\n")); await expect(compileBundle(tmp)).rejects.toThrow(/map\/bad/); } finally { await rm(tmp, { recursive: true, force: true }); } }); it("packages a mods dir into bundle.mods with manifest + entry source text (D36)", async () => { const bundle = await compileBundle(sampleContentDir, sampleModsDir); expect(bundle.mods).toBeDefined(); const mod = bundle.mods?.["sample-game-mod"]; expect(mod).toBeDefined(); expect(mod?.manifest).toEqual({ id: "sample-game-mod", version: "0.1.0" }); expect(typeof mod?.code).toBe("string"); expect(mod?.code.length).toBeGreaterThan(100); expect(mod?.code).toContain("onDayStart"); }); it("leaves bundle.mods undefined without --mods (zero regression)", async () => { const bundle = await compileBundle(sampleContentDir); expect(bundle.mods).toBeUndefined(); }); it("an enabledMods list packages only the listed mods (F1; absent = all)", async () => { const all = await compileBundle(sampleContentDir, sampleModsDir); expect(Object.keys(all.mods ?? {})).toEqual(["sample-game-mod"]); const filtered = await compileBundle(sampleContentDir, sampleModsDir, false, ["sample-game-mod"]); expect(Object.keys(filtered.mods ?? {})).toEqual(["sample-game-mod"]); const none = await compileBundle(sampleContentDir, sampleModsDir, false, ["ghost-mod"]); expect(none.mods).toBeUndefined(); const unknown = await compileBundle(sampleContentDir, sampleModsDir, false, ["ghost-mod", "sample-game-mod"]); expect(Object.keys(unknown.mods ?? {})).toEqual(["sample-game-mod"]); }); it("packGame honors PackArgs.enabledMods (F1)", async () => { const out = await mkdtemp(join(tmpdir(), "nengine-pack-mods-filter-")); try { const result = await packGame({ contentDir: sampleContentDir, outDir: out, modsDir: sampleModsDir, enabledMods: ["ghost-mod"], }); expect(result.modCount).toBe(0); const raw = await readFile(join(out, "bundle.json"), "utf8"); const bundle = JSON.parse(raw) as ContentBundle; expect(bundle.mods).toBeUndefined(); } finally { await rm(out, { recursive: true, force: true }); } }); it("fails loudly when a mods dir entry is missing or its manifest is invalid (D36)", async () => { const tmp = await mkdtemp(join(tmpdir(), "nengine-modpack-")); try { await mkdir(join(tmp, "no-entry"), { recursive: true }); await mkdir(join(tmp, "no-manifest"), { recursive: true }); await writeFile(join(tmp, "no-manifest", "index.mjs"), "export default { hooks: {} };", "utf8"); await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/mod packaging failed/); await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/no-entry: no index\.mjs or index\.js entry/); await expect(compileBundle(sampleContentDir, tmp)).rejects.toThrow(/no-manifest: manifest \{ id, version \} required/); } finally { await rm(tmp, { recursive: true, force: true }); } }); }); describe("packGame integration", () => { it("builds a static site + bundle.json and the bundle boots via createEngine({bundle})", async () => { const out = await mkdtemp(join(tmpdir(), "nengine-pack-out-")); try { const result = await packGame({ contentDir: sampleContentDir, outDir: out }); expect(existsSync(join(out, "bundle.json"))).toBe(true); expect(existsSync(join(out, "index.html"))).toBe(true); expect(existsSync(join(out, "assets"))).toBe(true); const html = await readFile(join(out, "index.html"), "utf8"); expect(html).toMatch(/src="\.\/assets\/[^"]+\.js"/); expect(html).not.toContain("apps/dev"); const raw = await readFile(join(out, "bundle.json"), "utf8"); const bundle = JSON.parse(raw) as ContentBundle; expect(bundle.format).toBe("nengine-bundle"); expect(bundle.version).toBe(1); expect(Object.keys(bundle.content).length).toBeGreaterThanOrEqual(11); expect(Object.keys(bundle.assets ?? {}).length).toBeGreaterThan(0); expect(bundle.assets?.["grass-1"]?.kind).toBe("image"); const engine = createEngine({ bundle }); await engine.start(); try { const player = engine.world.get("player"); expect(player?.type).toBe("player"); expect(player?.x).toBe(2); expect(player?.y).toBe(4); expect(engine.assets.size).toBeGreaterThan(0); expect(engine.assets.get("grass-1")?.kind).toBe("image"); const time = engine.systems.get("time"); expect(time?.get()).toEqual({ day: 1, season: "spring", tickInDay: 0 }); const intent = engine.systems.get("intent"); if (!intent) throw new Error("intent system not booted"); const interact = intent.handle("interact", { tile: { x: 10, y: 10 } }); expect(interact.ok).toBe(true); expect(engine.world.get("plot:10:10")?.tilled).toBe(true); engine.tick(480); expect(time?.get().day).toBe(2); // engine.tick is the packaged game's day driver (D23 — the dev // "Advance day" button steps the same simulation path). const sow = intent.handle("interact", { tile: { x: 10, y: 10 } }); expect(sow.ok).toBe(true); expect(sow.detail).toContain("sowed"); expect(engine.world.get("plot:10:10")?.spriteRef).toBe("parsnip-sprout"); expect(intent.handle("interact", { tile: { x: 10, y: 10 } }).detail).toBe("watered plot"); engine.tick(480); engine.tick(480); const grown = engine.world.get("plot:10:10") as { stageIndex?: number; spriteRef?: string }; expect(grown?.stageIndex).toBe(1); expect(grown?.spriteRef).toBe("parsnip-grown"); } finally { await engine.stop(); } } finally { await rm(out, { recursive: true, force: true }); } }); it("packGame --mods packages Mods into bundle.json and they boot in bundle mode (D36)", async () => { const out = await mkdtemp(join(tmpdir(), "nengine-pack-mods-")); try { const result = await packGame({ contentDir: sampleContentDir, outDir: out, modsDir: sampleModsDir }); expect(result.modCount).toBe(1); const raw = await readFile(join(out, "bundle.json"), "utf8"); const bundle = JSON.parse(raw) as ContentBundle; expect(bundle.mods?.["sample-game-mod"]?.manifest).toEqual({ id: "sample-game-mod", version: "0.1.0" }); expect(typeof bundle.mods?.["sample-game-mod"]?.code).toBe("string"); const engine = createEngine({ bundle }); await engine.start(); try { expect(engine.mods).toHaveLength(1); expect(engine.mods[0]?.id).toBe("sample-game-mod"); engine.tick(480); expect(engine.world.get("mod:sample-stats")?.greetings).toBe(1); expect(engine.world.get("npc:mayor")?.type).toBe("npc"); } finally { await engine.stop(); } } finally { await rm(out, { recursive: true, force: true }); } }); it("packGame --split-assets writes nengine-assets/ files + a metadata-only bundle.json (D37)", async () => { const out = await mkdtemp(join(tmpdir(), "nengine-pack-split-")); try { const inline = await compileBundle(sampleContentDir); const result = await packGame({ contentDir: sampleContentDir, outDir: out, splitAssets: true }); expect(result.bundle.assets).toBeUndefined(); expect(result.bundle.assetsIndex).toBeDefined(); const raw = await readFile(join(out, "bundle.json"), "utf8"); const bundle = JSON.parse(raw) as ContentBundle; expect(bundle.assets).toBeUndefined(); expect(bundle.assetsIndex).toBeDefined(); expect(bundle.assetsIndex?.["grass-1"]).toMatchObject({ kind: "image", mime: "image/png", file: "nengine-assets/grass-1.png" }); expect(bundle.assetsIndex?.["click-1"]?.file).toBe("nengine-assets/click-1.ogg"); expect(raw).not.toContain(";base64,"); // sibling files exist and match the source bytes const nengineAssets = join(out, "nengine-assets"); expect(existsSync(nengineAssets)).toBe(true); expect(existsSync(join(nengineAssets, "grass-1.png"))).toBe(true); expect(existsSync(join(nengineAssets, "click-1.ogg"))).toBe(true); const files = await readdir(nengineAssets); expect(files.length).toBe(Object.keys(inline.assets ?? {}).length); expect(files.filter((f) => f.endsWith(".png")).length).toBeGreaterThan(0); expect(files.filter((f) => f.endsWith(".ogg")).length).toBeGreaterThan(0); // sizes recorded in the index match the actual split files (and the source) for (const [id, meta] of Object.entries(bundle.assetsIndex ?? {})) { const file = join(nengineAssets, meta.file?.split("/")[1] ?? ""); const bytes = (await stat(file)).size; expect(bytes).toBe(meta.size); expect(bytes).toBe(assetByteSize(inline.assets?.[id] as { data: string })); } // the split bundle still boots via createEngine (metadata-only asset table) const engine = createEngine({ bundle }); await engine.start(); try { expect(engine.assets.get("grass-1")).toMatchObject({ kind: "image", mime: "image/png" }); expect(engine.assets.get("grass-1")?.data).toBeUndefined(); expect(engine.assets.get("grass-1")?.file).toBe("nengine-assets/grass-1.png"); } finally { await engine.stop(); } } finally { await rm(out, { recursive: true, force: true }); } }); });