| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429 |
- 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 <dir> 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 <dir>");
- });
- 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"), "<html></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: 16, 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("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<TimeSystem>("time");
- expect(time?.get()).toEqual({ day: 1, season: "spring", tickInDay: 0 });
- const intent = engine.systems.get<IntentSystem>("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 });
- }
- });
- });
|