import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { ContentLoader, ContentRegistry, type AnimalDef, type BuildingDef, type CropDef, type DialogueDef, type ItemDef, type MapDef, type ShopDef, type StateMachineDef, type TimeDef, type TriggerDef, type WeatherDef, } from "../src/index.js"; const repoRoot = resolve(process.cwd(), "../.."); const contentDir = resolve(repoRoot, "content"); describe("ContentLoader", () => { it("loads the real content dir and registers map+time with correct ids", async () => { const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const report = await loader.load(contentDir); expect(report.ok).toBe(true); expect(report.items.length).toBe(18); expect(report.items.every((i) => i.status === "ok")).toBe(true); expect(registry.ids("map")).toEqual(["farm"]); expect(registry.ids("time")).toEqual(["default"]); expect(registry.ids("item")).toEqual(["egg", "hoe", "parsnip-seed", "parsnip"]); expect(registry.ids("crop")).toEqual(["parsnip"]); expect(registry.ids("animal")).toEqual(["chicken"]); expect(registry.ids("shop")).toEqual(["general-store"]); expect(registry.ids("behavior")).toEqual(["season-welcome", "spring-gift"]); expect(registry.ids("statemachine")).toEqual(["chicken-egg", "daily-routine", "spring-quest"]); expect(registry.ids("dialogue")).toEqual(["welcome"]); expect(registry.ids("weather")).toEqual(["default"]); expect(registry.ids("building")).toEqual(["fence", "shed"]); const map = registry.get("map", "farm"); expect(map?.id).toBe("farm"); expect(map?.width).toBe(12); expect(map?.height).toBe(12); expect(map?.spawn).toEqual({ x: 2, y: 2 }); expect(map?.layers).toHaveLength(1); expect(map?.layers[0]?.data).toHaveLength(12); expect(map?.layers[0]?.data[0]).toHaveLength(12); const time = registry.get("time", "default"); expect(time?.dayLengthTicks).toBe(480); expect(time?.seasons).toHaveLength(4); expect(time?.seasons[0]?.name).toBe("spring"); expect(time?.start).toEqual({ day: 1, season: "spring" }); const hoe = registry.get("item", "hoe"); expect(hoe).toEqual({ id: "hoe", name: "Hoe", kind: "tool", stackable: false, price: 500, rarity: "common", use: "tills a soil tile for planting" }); const seed = registry.get("item", "parsnip-seed"); expect(seed?.kind).toBe("seed"); expect(seed?.stackable).toBe(true); const parsnip = registry.get("item", "parsnip"); expect(parsnip?.kind).toBe("material"); expect(parsnip?.price).toBe(35); const crop = registry.get("crop", "parsnip"); expect(crop).toEqual({ id: "parsnip", seedItem: "parsnip-seed", stages: [ { name: "sprout", days: 2, waterRequired: true }, { name: "grown", days: 2, waterRequired: true }, { name: "mature", days: 1, waterRequired: false }, ], seasons: ["spring"], drops: [{ itemId: "parsnip", qty: 1 }], }); const egg = registry.get("item", "egg"); expect(egg).toEqual({ id: "egg", name: "Egg", kind: "material", stackable: true, price: 15, rarity: "common", use: "a chicken's daily produce" }); const chicken = registry.get("animal", "chicken"); expect(chicken).toEqual({ id: "chicken", stages: [ { name: "chick", days: 2, feedRequired: false }, { name: "grown", days: 2, feedRequired: true }, { name: "laying", days: 1, feedRequired: true }, ], produces: [{ itemId: "egg", qty: 1 }], }); const chickenEgg = registry.get("statemachine", "chicken-egg"); expect(chickenEgg).toEqual({ id: "chicken-egg", initial: "waiting", states: [ { id: "waiting" }, { id: "laid", onEnter: [{ action: "give-item", itemId: "egg", qty: 1 }] }, ], transitions: [ { from: "waiting", to: "laid", on: "animal-produce", when: { op: "eq", path: "payload.animalDefId", value: "chicken" } }, { from: "laid", to: "waiting", on: "animal-produce", when: { op: "eq", path: "payload.animalDefId", value: "chicken" } }, ], }); const shop = registry.get("shop", "general-store"); expect(shop).toEqual({ id: "general-store", buys: [{ itemId: "parsnip", price: 30 }], sells: [ { itemId: "hoe", price: 500 }, { itemId: "parsnip-seed", price: 20, stock: 99 }, ], }); const springGift = registry.get("behavior", "spring-gift"); expect(springGift).toEqual({ id: "spring-gift", when: { op: "gte", path: "time.day", value: 5 }, fire: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }], }); const seasonWelcome = registry.get("behavior", "season-welcome"); expect(seasonWelcome?.when).toEqual({ and: [ { op: "eq", path: "time.day", value: 1 }, { or: [ { op: "eq", path: "time.season", value: "summer" }, { op: "eq", path: "time.season", value: "autumn" }, ], }, { not: { op: "exists", path: "player.flag" } }, ], }); expect(seasonWelcome?.fire).toEqual([ { action: "emit-event", event: "behavior:season-welcome", payload: { season: "welcome" } }, { action: "set-state", path: "player.flag", value: "welcomed" }, ]); const dailyRoutine = registry.get("statemachine", "daily-routine"); expect(dailyRoutine).toEqual({ id: "daily-routine", initial: "sleeping", states: [ { id: "sleeping" }, { id: "working", onEnter: [{ action: "emit-event", event: "behavior:daily-routine-working", payload: { state: "working" } }], }, { id: "resting" }, ], transitions: [{ from: "working", to: "resting", on: "item-acquire" }], onSchedule: [ { at: "day-start", to: "resting", when: { op: "gte", path: "payload.day", value: 28 } }, { at: "day-start", to: "working" }, ], }); const springQuest = registry.get("statemachine", "spring-quest"); expect(springQuest?.initial).toBe("inactive"); expect(springQuest?.states).toHaveLength(3); expect(springQuest?.states[1]?.onEnter).toEqual([ { action: "give-item", itemId: "parsnip-seed", qty: 1 }, ]); expect(springQuest?.transitions).toHaveLength(2); expect(springQuest?.transitions[1]?.when).toEqual({ op: "gte", path: "player.inventory.parsnip", value: 5 }); const welcome = registry.get("dialogue", "welcome"); expect(welcome).toEqual({ id: "welcome", startNode: "hello", nodes: [ { id: "hello", text: "Welcome to your farm! A new season is about to begin.", choices: [ { text: "Thanks! What should I plant first?", to: "seed-tip", actions: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }], }, { text: "You remember me from last season?", to: "returning", when: { op: "exists", path: "player.flag" } }, { text: "I am just exploring today.", to: "farewell" }, ], }, { id: "seed-tip", text: "Parsnip seeds grow well in spring — here is one to start with.", to: "farewell" }, { id: "returning", text: "Of course! Welcome back to the farm.", to: "farewell" }, { id: "farewell", text: "See you around!" }, ], }); const weather = registry.get("weather", "default"); expect(weather).toEqual({ id: "default", defaults: { sunny: { name: "Sunny" }, rain: { name: "Rain" }, wind: { name: "Wind" }, storm: { name: "Storm" }, }, seasons: [ { season: "spring", weathers: [ { id: "sunny", weight: 50 }, { id: "rain", weight: 30 }, { id: "wind", weight: 15 }, { id: "storm", weight: 5 }, ] }, { season: "summer", weathers: [ { id: "sunny", weight: 70 }, { id: "rain", weight: 15 }, { id: "wind", weight: 10 }, { id: "storm", weight: 5 }, ] }, { season: "autumn", weathers: [ { id: "sunny", weight: 40 }, { id: "rain", weight: 35 }, { id: "wind", weight: 20 }, { id: "storm", weight: 5 }, ] }, { season: "winter", weathers: [ { id: "sunny", weight: 20 }, { id: "rain", weight: 25 }, { id: "wind", weight: 40 }, { id: "storm", weight: 15 }, ] }, ], }); const shed = registry.get("building", "shed"); expect(shed).toEqual({ id: "shed", name: "Shed", footprint: { w: 2, h: 2 }, buildable: "map", blocksMovement: true, description: "Stores tools and harvests.", }); expect(registry.get("building", "fence")).toEqual({ id: "fence", name: "Fence", footprint: { w: 1, h: 1 }, buildable: { tiles: ["grass"] }, blocksMovement: true, description: "A wooden fence post.", }); }); it("reports an invalid item as error without aborting siblings", async () => { const tmp = await mkdtemp(join(tmpdir(), "farm-content-")); try { await mkdir(join(tmp, "maps"), { recursive: true }); await writeFile( join(tmp, "maps", "good.yaml"), [ "id: good", "width: 2", "height: 2", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [a, b]", " - [b, a]", "", ].join("\n"), ); await writeFile( join(tmp, "maps", "bad.yaml"), ["id: bad", "width: not-a-number", "height: 2", ""].join("\n"), ); const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const report = await loader.load(tmp); expect(report.ok).toBe(false); const ok = report.items.find((i) => i.id === "good"); const bad = report.items.find((i) => i.id === "bad"); expect(ok?.status).toBe("ok"); expect(bad?.status).toBe("error"); expect(bad?.errors?.length).toBeGreaterThan(0); expect(registry.get("map", "good")).toBeDefined(); expect(registry.get("map", "bad")).toBeUndefined(); } finally { await rm(tmp, { recursive: true, force: true }); } }); });