| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250 |
- import { resolve } from "node:path";
- import { describe, expect, it } from "vitest";
- import {
- contentSchemas,
- createEngine,
- type ContentBundle,
- ContentLoader,
- ContentRegistry,
- type ContentKind,
- type IntentSystem,
- type MapDef,
- type TimeSystem,
- } from "../src/index.js";
- const repoRoot = resolve(process.cwd(), "../..");
- const sampleContentDir = resolve(repoRoot, "examples", "sample-game", "content");
- /** Compile a bundle the way the pack tool does: fs-load the dir, then collect
- * every registered def per content kind. */
- async function compileBundle(dir: string): Promise<ContentBundle> {
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- const report = await loader.load(dir);
- if (!report.ok) throw new Error("content load failed");
- const content: Record<string, unknown[]> = {};
- for (const kind of Object.keys(contentSchemas) as ContentKind[]) {
- const defs = registry.list(kind);
- if (defs.length > 0) content[kind] = defs;
- }
- return { format: "nengine-bundle", version: 1, content };
- }
- function validMap(): unknown {
- return {
- id: "farm",
- width: 2,
- height: 2,
- tilesetRef: "tiles",
- spawn: { x: 0, y: 0 },
- layers: [{ name: "ground", data: [["grass", "grass"], ["grass", "grass"]] }],
- };
- }
- function validTime(): unknown {
- return {
- id: "default",
- dayLengthTicks: 480,
- seasons: [{ name: "spring", dayCount: 28 }],
- start: { day: 1, season: "spring" },
- };
- }
- function validItem(): unknown {
- return { id: "hoe", name: "Hoe", kind: "tool", stackable: false, price: 500 };
- }
- describe("ContentLoader.loadBundle", () => {
- it("registers a valid bundle and reports per-item ok", async () => {
- const bundle: ContentBundle = {
- format: "nengine-bundle",
- version: 1,
- content: {
- map: [validMap()],
- time: [validTime()],
- item: [validItem()],
- },
- };
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- const report = await loader.loadBundle(bundle);
- expect(report.ok).toBe(true);
- expect(report.items).toHaveLength(3);
- expect(report.items.every((i) => i.status === "ok")).toBe(true);
- expect(registry.ids("map")).toEqual(["farm"]);
- expect(registry.get<MapDef>("map", "farm")?.width).toBe(2);
- expect(registry.get("item", "hoe")).toMatchObject({ name: "Hoe", kind: "tool" });
- });
- it("reports an invalid kind entry as an error without aborting siblings", async () => {
- const bundle: ContentBundle = {
- format: "nengine-bundle",
- version: 1,
- content: {
- map: [validMap()],
- mystery: [{ id: "x" }],
- },
- };
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- const report = await loader.loadBundle(bundle);
- expect(report.ok).toBe(false);
- const ok = report.items.find((i) => i.kind === "map");
- const bad = report.items.find((i) => i.kind === "mystery");
- expect(ok?.status).toBe("ok");
- expect(bad?.status).toBe("error");
- expect(bad?.errors?.[0]).toContain("unknown content kind: mystery");
- expect(registry.get("map", "farm")).toBeDefined();
- });
- it("reports a per-kind invalid def with per-field issues and registers nothing for it", async () => {
- const bundle: ContentBundle = {
- format: "nengine-bundle",
- version: 1,
- content: {
- item: [validItem(), { id: "bad", name: 42, kind: "nope" }],
- },
- };
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- const report = await loader.loadBundle(bundle);
- expect(report.ok).toBe(false);
- const bad = report.items.find((i) => i.id === "bad");
- expect(bad?.status).toBe("error");
- expect(bad?.errors?.length).toBeGreaterThan(0);
- expect(registry.get("item", "hoe")).toBeDefined();
- expect(registry.get("item", "bad")).toBeUndefined();
- });
- it("treats a missing kind as a no-op", async () => {
- const bundle: ContentBundle = {
- format: "nengine-bundle",
- version: 1,
- content: { map: [validMap()] },
- };
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- const report = await loader.loadBundle(bundle);
- expect(report.ok).toBe(true);
- expect(report.items).toHaveLength(1);
- expect(registry.ids("sprite")).toEqual([]);
- });
- it("throws a clear error on a structurally invalid bundle", async () => {
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- await expect(
- loader.loadBundle({ format: "nengine-bundle", version: 99, content: {} } as unknown as ContentBundle),
- ).rejects.toThrow("not a valid nengine-bundle");
- });
- it("accepts an additive mods map and rejects a malformed one (D36)", async () => {
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- const withMods: ContentBundle = {
- format: "nengine-bundle",
- version: 1,
- content: { map: [validMap()] },
- mods: { "my-mod": { manifest: { id: "my-mod", version: "1.0.0" }, code: "export default {};" } },
- };
- const report = await loader.loadBundle(withMods);
- expect(report.ok).toBe(true);
- const registry2 = new ContentRegistry();
- const loader2 = new ContentLoader(registry2);
- await expect(
- loader2.loadBundle({
- format: "nengine-bundle",
- version: 1,
- content: { map: [validMap()] },
- mods: { "my-mod": { manifest: { id: "my-mod" }, code: 42 } },
- } as unknown as ContentBundle),
- ).rejects.toThrow("not a valid nengine-bundle");
- });
- it("fs load() behavior is unchanged (zero regression)", async () => {
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- const report = await loader.load(sampleContentDir);
- expect(report.ok).toBe(true);
- expect(report.items.length).toBeGreaterThan(0);
- expect(registry.ids("map")).toEqual(["farm"]);
- expect(registry.ids("time")).toEqual(["default"]);
- });
- it("loadBundle populates a metadata-only asset table from assetsIndex (split mode, D37)", async () => {
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- const split: ContentBundle = {
- format: "nengine-bundle",
- version: 1,
- content: { map: [validMap()], time: [validTime()] },
- assetsIndex: {
- "grass-1": { kind: "image", mime: "image/png", size: 128, file: "nengine-assets/grass-1.png" },
- "click-1": { kind: "audio", mime: "audio/ogg", size: 64, file: "nengine-assets/click-1.ogg" },
- },
- };
- const report = await loader.loadBundle(split);
- expect(report.ok).toBe(true);
- const grass = registry.assets.get("grass-1");
- expect(grass).toMatchObject({ kind: "image", mime: "image/png", size: 128, file: "nengine-assets/grass-1.png" });
- expect(grass?.data).toBeUndefined();
- expect(registry.assets.get("click-1")?.kind).toBe("audio");
- });
- it("loadBundle inline mode merges index metadata (size) onto base64 entries (D37)", async () => {
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- const bundle: ContentBundle = {
- format: "nengine-bundle",
- version: 1,
- content: { map: [validMap()] },
- assets: { "grass-1": { kind: "image", mime: "image/png", data: "aGVsbG8=" } },
- assetsIndex: { "grass-1": { kind: "image", mime: "image/png", size: 5, file: "nengine-assets/grass-1.png" } },
- };
- const report = await loader.loadBundle(bundle);
- expect(report.ok).toBe(true);
- const grass = registry.assets.get("grass-1");
- expect(grass?.data).toBe("aGVsbG8=");
- expect(grass?.size).toBe(5);
- expect(grass?.file).toBe("nengine-assets/grass-1.png");
- });
- });
- describe("createEngine bundle mode", () => {
- it("boots from a bundle, seeds the world, and ticks systems with no fs access", async () => {
- const bundle = await compileBundle(sampleContentDir);
- const engine = createEngine({ bundle });
- await engine.start();
- try {
- expect(engine.contentDir).toBeUndefined();
- expect(engine.registry.ids("map")).toEqual(["farm"]);
- expect(engine.registry.ids("item")).toContain("hoe");
- expect(engine.registry.ids("sprite")).toContain("player");
- const player = engine.world.get("player");
- expect(player?.type).toBe("player");
- expect(player?.x).toBe(2);
- expect(player?.y).toBe(4);
- 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 result = intent.handle("move", { tile: { x: 3, y: 4 } });
- expect(result.ok).toBe(true);
- expect(engine.world.get("player")?.x).toBe(3);
- engine.tick(480);
- expect(time?.get().day).toBe(2);
- } finally {
- await engine.stop();
- }
- });
- });
|