import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, sceneSchema } from "../src/index.js"; const TIME_YAML = [ "id: default", "dayLengthTicks: 480", "seasons:", " - { name: spring, dayCount: 28 }", "start: { day: 1, season: spring }", "", ].join("\n"); const FARM_YAML = [ "id: farm", "width: 5", "height: 5", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [grass, grass, grass, grass, grass]", " - [grass, grass, grass, grass, grass]", " - [grass, grass, grass, grass, water]", " - [grass, grass, grass, grass, water]", " - [grass, grass, grass, grass, grass]", "", ].join("\n"); const SHED_YAML = [ "id: shed", "name: Shed", "footprint: { w: 2, h: 2 }", "buildable: any", "blocksMovement: true", "", ].join("\n"); const FENCE_YAML = [ "id: fence", "name: Fence", "footprint: { w: 1, h: 1 }", "buildable: { tiles: [grass] }", "blocksMovement: true", "", ].join("\n"); const CHICKEN_YAML = [ "id: chicken", "stages:", " - { name: chick, days: 2, feedRequired: false }", " - { name: grown, days: 2, feedRequired: true }", "produces:", " - { itemId: egg, qty: 1 }", "", ].join("\n"); interface Setup { engine: ReturnType; close: () => Promise; } async function boot(sceneYamls: Record = {}): Promise { const tmp = await mkdtemp(join(tmpdir(), "scene-")); await mkdir(join(tmp, "maps"), { recursive: true }); await mkdir(join(tmp, "buildings"), { recursive: true }); await mkdir(join(tmp, "animals"), { recursive: true }); await writeFile(join(tmp, "time.yaml"), TIME_YAML); await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML); await writeFile(join(tmp, "buildings", "shed.yaml"), SHED_YAML); await writeFile(join(tmp, "buildings", "fence.yaml"), FENCE_YAML); await writeFile(join(tmp, "animals", "chicken.yaml"), CHICKEN_YAML); if (Object.keys(sceneYamls).length > 0) { await mkdir(join(tmp, "scenes"), { recursive: true }); for (const [id, yaml] of Object.entries(sceneYamls)) { await writeFile(join(tmp, "scenes", `${id}.yaml`), yaml); } } const engine = createEngine({ contentDir: tmp }); try { await engine.start(); } catch (err) { await rm(tmp, { recursive: true, force: true }); throw err; } return { engine, close: async () => { await engine.stop(); await rm(tmp, { recursive: true, force: true }); }, }; } function sceneYaml(id: string, entities: string[]): string { return [`id: ${id}`, "entities:", ...entities.map((entity) => ` - ${entity}`), ""].join("\n"); } describe("sceneSchema", () => { it("accepts a valid building/npc/animal mix and an empty entity list", () => { expect( sceneSchema.safeParse({ id: "farm-start", entities: [ { type: "building", buildingId: "shed", x: 0, y: 0 }, { type: "npc", npcId: "mayor", x: 1, y: 1, dialogue: "welcome", label: "Mayor" }, { type: "animal", animalId: "chicken", x: 2, y: 2 }, ], }).success, ).toBe(true); expect(sceneSchema.safeParse({ id: "empty", entities: [] }).success).toBe(true); }); it("rejects discriminated-union misuse and unknown entity types", () => { const npcWithBuildingId = sceneSchema.safeParse({ id: "s", entities: [{ type: "npc", buildingId: "shed", x: 0, y: 0 }], }); expect(npcWithBuildingId.success).toBe(false); const unknownType = sceneSchema.safeParse({ id: "s", entities: [{ type: "plot", x: 0, y: 0 }], }); expect(unknownType.success).toBe(false); const missingType = sceneSchema.safeParse({ id: "s", entities: [{ buildingId: "shed", x: 0, y: 0 }], }); expect(missingType.success).toBe(false); }); it("rejects negative coords, non-integer coords, and unknown keys", () => { expect( sceneSchema.safeParse({ id: "s", entities: [{ type: "building", buildingId: "shed", x: -1, y: 0 }] }).success, ).toBe(false); expect( sceneSchema.safeParse({ id: "s", entities: [{ type: "npc", npcId: "m", x: 0, y: 1.5 }] }).success, ).toBe(false); expect( sceneSchema.safeParse({ id: "s", entities: [{ type: "animal", animalId: "c", x: 0, y: 0, extra: 1 }] }).success, ).toBe(false); }); it("rejects a missing id and non-array entities", () => { expect(sceneSchema.safeParse({ entities: [] }).success).toBe(false); expect(sceneSchema.safeParse({ id: "s", entities: "nope" }).success).toBe(false); }); }); describe("scene seed instantiation", () => { it("instantiates a building via PlacementSystem semantics with the runtime entity id", async () => { const { engine, close } = await boot({ "farm-start": sceneYaml("farm-start", [ "{ type: building, buildingId: shed, x: 0, y: 0 }", "{ type: building, buildingId: fence, x: 3, y: 3 }", ]), }); try { const shed = engine.world.get("building:shed:0:0"); expect(shed).toMatchObject({ id: "building:shed:0:0", type: "building", buildingId: "shed", x: 0, y: 0, w: 2, h: 2, blocksMovement: true, }); expect(engine.world.get("building:fence:3:3")).toMatchObject({ type: "building", buildingId: "fence" }); } finally { await close(); } }); it("fails boot loudly when two scene buildings overlap", async () => { await expect( boot({ "farm-start": sceneYaml("farm-start", [ "{ type: building, buildingId: shed, x: 0, y: 0 }", "{ type: building, buildingId: fence, x: 1, y: 1 }", ]), }), ).rejects.toThrow(/overlaps existing building/); }); it("fails boot loudly when a scene building is not buildable on its tile", async () => { await expect( boot({ "farm-start": sceneYaml("farm-start", ["{ type: building, buildingId: fence, x: 4, y: 2 }"]), }), ).rejects.toThrow(/not buildable for fence/); }); it("fails boot loudly when a scene references an unknown building def", async () => { await expect( boot({ "farm-start": sceneYaml("farm-start", ["{ type: building, buildingId: barn, x: 0, y: 0 }"]), }), ).rejects.toThrow(/unknown building: barn/); }); it("creates an npc entity with optional dialogue and label fields", async () => { const { engine, close } = await boot({ "farm-start": sceneYaml("farm-start", [ "{ type: npc, npcId: mayor, x: 1, y: 1, dialogue: welcome, label: Mayor }", "{ type: npc, npcId: villager, x: 3, y: 1 }", ]), }); try { expect(engine.world.get("mayor")).toMatchObject({ id: "mayor", type: "npc", x: 1, y: 1, dialogue: "welcome", label: "Mayor", }); expect(engine.world.get("villager")).toEqual({ id: "villager", type: "npc", x: 3, y: 1 }); } finally { await close(); } }); it("fails boot loudly on a duplicate npc entity id", async () => { await expect( boot({ "farm-start": sceneYaml("farm-start", [ "{ type: npc, npcId: mayor, x: 1, y: 1 }", "{ type: npc, npcId: mayor, x: 2, y: 2 }", ]), }), ).rejects.toThrow(/duplicate npc entity id: mayor/); }); it("spawns animals via GrowthSystem.spawnAnimal with the runtime entity id", async () => { const { engine, close } = await boot({ "farm-start": sceneYaml("farm-start", ["{ type: animal, animalId: chicken, x: 2, y: 2 }"]), }); try { expect(engine.world.get("animal:chicken:1")).toMatchObject({ id: "animal:chicken:1", type: "animal", animalId: "chicken", x: 2, y: 2, stageIndex: 0, daysInStage: 0, fed: false, }); } finally { await close(); } }); it("fails boot loudly when a scene references an unknown animal def", async () => { await expect( boot({ "farm-start": sceneYaml("farm-start", ["{ type: animal, animalId: cow, x: 2, y: 2 }"]), }), ).rejects.toThrow(/unknown animal def: cow/); }); it("boots unchanged when zero scenes are registered", async () => { const { engine, close } = await boot(); try { expect(engine.registry.list("scene")).toEqual([]); expect(engine.world.query({ type: "building" })).toHaveLength(0); expect(engine.world.query({ type: "npc" })).toHaveLength(0); expect(engine.world.query({ type: "animal" })).toHaveLength(0); expect(engine.world.get("player")).toMatchObject({ id: "player", type: "player", x: 0, y: 0 }); } finally { await close(); } }); });