import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ContentLoader, ContentRegistry, mapSchema, spriteSchema, type ContentBundle } from "../src/index.js"; const repoRoot = resolve(process.cwd(), "../.."); const sampleContentDir = resolve(repoRoot, "examples", "sample-game", "content"); /** Minimal valid 1x1 PNG (8x8 transparent) and OGG headers — shape only; the * loader reads bytes, it does not decode them. */ const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; const OGG_BASE64 = "T2dnUwAAAAAAAAAAAAAAAAAAAAAAAE9nZ1M="; async function makeTmp(): Promise { const tmp = await mkdtemp(join(tmpdir(), "nengine-assets-")); await mkdir(join(tmp, "assets"), { recursive: true }); await mkdir(join(tmp, "maps"), { recursive: true }); await writeFile(join(tmp, "maps", "farm.yaml"), [ "id: farm", "width: 2", "height: 2", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [grass, grass]", " - [grass, grass]", "", ].join("\n")); return tmp; } describe("ContentLoader asset table (D31)", () => { it("collects png + ogg files from content/assets/ into the registry asset table", async () => { const tmp = await makeTmp(); try { await writeFile(join(tmp, "assets", "grass-1.png"), Buffer.from(PNG_BASE64, "base64")); await writeFile(join(tmp, "assets", "till-1.ogg"), Buffer.from(OGG_BASE64, "base64")); const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const report = await loader.load(tmp); expect(report.ok).toBe(true); const grass = registry.assets.get("grass-1"); expect(grass).toEqual({ kind: "image", mime: "image/png", data: PNG_BASE64 }); const till = registry.assets.get("till-1"); expect(till).toEqual({ kind: "audio", mime: "audio/ogg", data: OGG_BASE64 }); expect(registry.asset("grass-1")).toBe(grass); } finally { await rm(tmp, { recursive: true, force: true }); } }); it("no assets dir (or empty) yields an empty asset table", async () => { const tmp = await makeTmp(); try { const registry = new ContentRegistry(); const loader = new ContentLoader(registry); await loader.load(tmp); expect(registry.assets.size).toBe(0); } finally { await rm(tmp, { recursive: true, force: true }); } }); it("asset id is the filename without extension (case-insensitive extension)", async () => { const tmp = await makeTmp(); try { await writeFile(join(tmp, "assets", "grass-1.PNG"), Buffer.from(PNG_BASE64, "base64")); await writeFile(join(tmp, "assets", "notes.txt"), "not an asset"); const registry = new ContentRegistry(); const loader = new ContentLoader(registry); await loader.load(tmp); expect(registry.assets.get("grass-1")?.kind).toBe("image"); expect(registry.assets.size).toBe(1); } finally { await rm(tmp, { recursive: true, force: true }); } }); it("watch() re-scans the asset table when a file under assets/ changes", async () => { const tmp = await makeTmp(); try { const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const stop = loader.watch(tmp); await loader.load(tmp); await writeFile(join(tmp, "assets", "new-1.png"), Buffer.from(PNG_BASE64, "base64")); await waitFor(() => registry.assets.has("new-1"), 3000); expect(registry.assets.get("new-1")?.kind).toBe("image"); stop(); } finally { await rm(tmp, { recursive: true, force: true }); } }); it("loadBundle collects bundle.assets into the same asset table", async () => { const bundle: ContentBundle = { format: "nengine-bundle", version: 1, content: { map: [ { id: "farm", width: 2, height: 2, tilesetRef: "tiles", spawn: { x: 0, y: 0 }, layers: [{ name: "ground", data: [["grass", "grass"], ["grass", "grass"]] }], }, ], }, assets: { "grass-1": { kind: "image", mime: "image/png", data: PNG_BASE64 }, "till-1": { kind: "audio", mime: "audio/ogg", data: OGG_BASE64 }, }, }; const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const report = await loader.loadBundle(bundle); expect(report.ok).toBe(true); expect(registry.assets.get("grass-1")).toEqual({ kind: "image", mime: "image/png", data: PNG_BASE64 }); expect(registry.assets.get("till-1")).toEqual({ kind: "audio", mime: "audio/ogg", data: OGG_BASE64 }); }); it("a bundle without assets loads unchanged (empty asset table)", async () => { const bundle: ContentBundle = { format: "nengine-bundle", version: 1, content: {}, }; const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const report = await loader.loadBundle(bundle); expect(report.ok).toBe(false); // no content items → ok false (unchanged D30 semantics) expect(registry.assets.size).toBe(0); }); it("rejects a structurally invalid asset entry in a bundle", async () => { const registry = new ContentRegistry(); const loader = new ContentLoader(registry); await expect( loader.loadBundle({ format: "nengine-bundle", version: 1, content: {}, assets: { "grass-1": { kind: "movie", mime: "image/png", data: "x" } }, } as unknown as ContentBundle), ).rejects.toThrow("not a valid nengine-bundle"); }); }); describe("sprite schema image layer (D31)", () => { it("accepts an image layer with src plus optional x/y/w/h", () => { const valid = spriteSchema.safeParse({ id: "s", frames: [ { layers: [ { shape: "image", src: "plant-1" }, { shape: "image", src: "plant-1", x: 4, y: 6, w: 20, h: 20 }, ], }, ], }); expect(valid.success).toBe(true); }); it("rejects an image layer with a missing/blank src, or rect layers with unknown keys", () => { expect( spriteSchema.safeParse({ id: "s", frames: [{ layers: [{ shape: "image" }] }] }).success, ).toBe(false); expect( spriteSchema.safeParse({ id: "s", frames: [{ layers: [{ shape: "image", src: "" }] }] }).success, ).toBe(false); expect( spriteSchema.safeParse({ id: "s", frames: [{ layers: [{ shape: "image", src: "a", fill: "#ffffff" }] }], }).success, ).toBe(false); expect( spriteSchema.safeParse({ id: "s", frames: [{ layers: [{ shape: "image", src: "a", w: -1 }] }] }).success, ).toBe(false); }); it("keeps the existing rect/circle layer shapes working unchanged", () => { expect( spriteSchema.safeParse({ id: "s", frames: [{ layers: [{ shape: "rect", x: 0, y: 0, w: 4, h: 4, fill: "#33691e" }] }], }).success, ).toBe(true); expect( spriteSchema.safeParse({ id: "s", frames: [{ layers: [{ shape: "triangle", x: 0, y: 0 }] }] }).success, ).toBe(false); }); }); describe("map schema tiles mapping (D31)", () => { const base = { id: "farm", width: 2, height: 2, tilesetRef: "tiles", spawn: { x: 0, y: 0 }, layers: [{ name: "ground", data: [["grass", "water"], ["grass", "tilled"]] }], }; it("accepts an optional tile-id → asset-id mapping", () => { expect(mapSchema.safeParse({ ...base, tiles: { grass: "grass-1", water: "water-1", tilled: "tilled-1" } }).success).toBe(true); }); it("accepts a map without tiles (palette fallback unchanged)", () => { expect(mapSchema.safeParse(base).success).toBe(true); }); it("rejects malformed tiles mappings", () => { expect(mapSchema.safeParse({ ...base, tiles: { grass: "" } }).success).toBe(false); expect(mapSchema.safeParse({ ...base, tiles: { grass: "a", water: 3 } }).success).toBe(false); }); }); describe("sample-game content asset adoption", () => { it("loads the sample game's assets alongside its content", async () => { const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const report = await loader.load(sampleContentDir); expect(report.ok).toBe(true); expect(registry.assets.size).toBe(21); for (const id of ["grass-1", "water-1", "tilled-1", "plant-sprout", "coop-1", "fence-1"]) { expect(registry.assets.get(id)?.kind).toBe("image"); } for (const id of ["click-1", "confirm-1", "drop-1", "pluck-1"]) { expect(registry.assets.get(id)?.kind).toBe("audio"); } }); }); async function waitFor(condition: () => boolean, timeoutMs: number): Promise { const start = Date.now(); while (!condition()) { if (Date.now() - start > timeoutMs) throw new Error("timeout waiting for condition"); await new Promise((resolveWait) => setTimeout(resolveWait, 25)); } }