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, type AnimalDef, type AnimalEntity, type AnimalProduceEvent, type GrowthSystem, type SaveSystem, type System, } 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: 2", "height: 2", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [a, b]", " - [b, a]", "", ].join("\n"); const CHICKEN_DEF: AnimalDef = { 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 }], }; interface Setup { engine: ReturnType; close: () => Promise; } async function setup(spy?: System): Promise { const tmp = await mkdtemp(join(tmpdir(), "farm-animal-")); await mkdir(join(tmp, "maps"), { recursive: true }); await writeFile(join(tmp, "time.yaml"), TIME_YAML); await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML); const systems: System[] = []; if (spy) systems.push(spy); const engine = createEngine({ contentDir: tmp, systems }); await engine.start(); engine.registry.register("animal", CHICKEN_DEF); return { engine, close: async () => { await engine.stop(); await rm(tmp, { recursive: true, force: true }); }, }; } function growthOf(engine: ReturnType): GrowthSystem { const growth = engine.systems.get("growth"); if (!growth) throw new Error("growth system not booted"); return growth; } function saveOf(engine: ReturnType): SaveSystem { const save = engine.systems.get("save"); if (!save) throw new Error("save system not booted"); return save; } function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } { const seen: { event: string; payload: unknown }[] = []; const system: System = { id: "spy", init() {}, on(event, payload) { seen.push({ event, payload }); }, }; return { system, seen }; } function eventsOf(seen: { event: string; payload: unknown }[], event: string): AnimalProduceEvent[] { return seen.filter((entry) => entry.event === event).map((entry) => entry.payload as AnimalProduceEvent); } describe("GrowthSystem animals", () => { it("spawnAnimal creates an entity at stage 0 with the next available number", async () => { const { engine, close } = await setup(); try { const first = growthOf(engine).spawnAnimal("chicken", 3, 3); expect(first).toEqual({ id: "animal:chicken:1", type: "animal", animalId: "chicken", x: 3, y: 3, stageIndex: 0, daysInStage: 0, fed: false, }); expect(engine.world.get("animal:chicken:1")).toBeDefined(); const second = growthOf(engine).spawnAnimal("chicken", 4, 4); expect(second.id).toBe("animal:chicken:2"); expect(engine.world.query({ type: "animal" })).toHaveLength(2); } finally { await close(); } }); it("spawnAnimal rejects unknown animal defs and bad coords", async () => { const { engine, close } = await setup(); try { const growth = growthOf(engine); expect(() => growth.spawnAnimal("cow", 1, 1)).toThrow(/unknown animal def: cow/); expect(() => growth.spawnAnimal("chicken", -1, 1)).toThrow(/non-negative integers/); expect(() => growth.spawnAnimal("chicken", 1.5, 1)).toThrow(/non-negative integers/); expect(engine.world.query({ type: "animal" })).toHaveLength(0); } finally { await close(); } }); it("a fed animal advances through stages on day boundaries and emits animal-grow", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { const growth = growthOf(engine); growth.spawnAnimal("chicken", 3, 3); growth.step(480); let animal = engine.world.get("animal:chicken:1") as AnimalEntity; expect(animal.stageIndex).toBe(0); expect(animal.daysInStage).toBe(1); expect(eventsOf(seen, "animal-grow")).toHaveLength(0); growth.step(480); animal = engine.world.get("animal:chicken:1") as AnimalEntity; expect(animal.stageIndex).toBe(1); expect(animal.daysInStage).toBe(0); expect(animal.fed).toBe(false); expect(eventsOf(seen, "animal-grow")).toEqual([ { animalId: "animal:chicken:1", x: 3, y: 3, animalDefId: "chicken", stageIndex: 1, stage: "grown" }, ]); growth.feed("animal:chicken:1"); growth.step(480); growth.feed("animal:chicken:1"); growth.step(480); animal = engine.world.get("animal:chicken:1") as AnimalEntity; expect(animal.stageIndex).toBe(2); expect(animal.daysInStage).toBe(0); expect(eventsOf(seen, "animal-grow").map((e) => e.stageIndex)).toEqual([1, 2]); } finally { await close(); } }); it("an un-fed animal on a feedRequired stage does not advance", async () => { const { engine, close } = await setup(); try { const growth = growthOf(engine); growth.spawnAnimal("chicken", 3, 3); growth.step(480); growth.step(480); expect((engine.world.get("animal:chicken:1") as AnimalEntity).stageIndex).toBe(1); for (let day = 0; day < 3; day++) { growth.step(480); } const animal = engine.world.get("animal:chicken:1") as AnimalEntity; expect(animal.stageIndex).toBe(1); expect(animal.daysInStage).toBe(0); } finally { await close(); } }); it("a mature animal produces per eligible day, resets fed, and skips without feeding", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { const growth = growthOf(engine); growth.spawnAnimal("chicken", 3, 3); growth.step(480); growth.step(480); growth.feed("animal:chicken:1"); growth.step(480); growth.feed("animal:chicken:1"); growth.step(480); expect((engine.world.get("animal:chicken:1") as AnimalEntity).stageIndex).toBe(2); growth.feed("animal:chicken:1"); growth.step(480); const produces = eventsOf(seen, "animal-produce"); expect(produces).toEqual([ { animalId: "animal:chicken:1", x: 3, y: 3, animalDefId: "chicken", stageIndex: 2, stage: "laying" }, ]); let animal = engine.world.get("animal:chicken:1") as AnimalEntity; expect(animal.fed).toBe(false); growth.step(480); expect(eventsOf(seen, "animal-produce")).toHaveLength(1); animal = engine.world.get("animal:chicken:1") as AnimalEntity; expect(animal.fed).toBe(false); growth.feed("animal:chicken:1"); growth.step(480); expect(eventsOf(seen, "animal-produce")).toHaveLength(2); } finally { await close(); } }); it("feed sets fed true and emits animal-fed; unknown entity throws", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { const growth = growthOf(engine); growth.spawnAnimal("chicken", 3, 3); const fed = growth.feed("animal:chicken:1"); expect(fed.fed).toBe(true); expect(eventsOf(seen, "animal-fed")).toEqual([{ animalId: "animal:chicken:1", animalDefId: "chicken" }]); expect(() => growth.feed("ghost")).toThrow(/unknown animal: ghost/); engine.world.create({ id: "plot:1:1", type: "plot", x: 1, y: 1, tilled: false, watered: false }); expect(() => growth.feed("plot:1:1")).toThrow(/unknown animal: plot:1:1/); } finally { await close(); } }); it("forceAnimalStage jumps an animal and emits animal-grow; rejects bad indices and entities", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { const growth = growthOf(engine); growth.spawnAnimal("chicken", 3, 3); const animal = growth.forceAnimalStage("animal:chicken:1", 2); expect(animal.stageIndex).toBe(2); expect(animal.daysInStage).toBe(0); expect(eventsOf(seen, "animal-grow")).toEqual([ { animalId: "animal:chicken:1", x: 3, y: 3, animalDefId: "chicken", stageIndex: 2, stage: "laying" }, ]); expect(() => growth.forceAnimalStage("animal:chicken:1", 5)).toThrow(/out of range/); expect(() => growth.forceAnimalStage("ghost", 0)).toThrow(/unknown animal: ghost/); } finally { await close(); } }); it("onAnimalGrow and onAnimalProduce hooks fire", async () => { const { engine, close } = await setup(); try { const grew: string[] = []; const produced: string[] = []; engine.hooks.on("onAnimalGrow", (ctx) => { const animal = ctx.world.query({ type: "animal" })[0] as AnimalEntity | undefined; grew.push(`${animal?.animalId}:${animal?.stageIndex ?? -1}`); }); engine.hooks.on("onAnimalProduce", (ctx) => { const animal = ctx.world.query({ type: "animal" })[0] as AnimalEntity | undefined; produced.push(`${animal?.animalId}:${animal?.stageIndex ?? -1}`); }); const growth = growthOf(engine); growth.spawnAnimal("chicken", 3, 3); growth.step(480); growth.step(480); growth.feed("animal:chicken:1"); growth.step(480); growth.feed("animal:chicken:1"); growth.step(480); growth.feed("animal:chicken:1"); growth.step(480); expect(grew).toEqual(["chicken:1", "chicken:2"]); expect(produced).toEqual(["chicken:2"]); } finally { await close(); } }); it("animal entities survive save snapshot/restore", async () => { const { engine, close } = await setup(); try { const growth = growthOf(engine); growth.spawnAnimal("chicken", 3, 3); growth.feed("animal:chicken:1"); growth.forceAnimalStage("animal:chicken:1", 1); const snapshot = saveOf(engine).snapshot(); expect( snapshot.world.some( (e) => e.id === "animal:chicken:1" && e.type === "animal" && e.animalId === "chicken" && e.stageIndex === 1 && e.fed === true, ), ).toBe(true); saveOf(engine).restore(snapshot); const animal = engine.world.get("animal:chicken:1") as AnimalEntity; expect(animal).toMatchObject({ type: "animal", animalId: "chicken", stageIndex: 1, fed: true }); } finally { await close(); } }); });