| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- 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<string> {
- 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<void> {
- 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));
- }
- }
|