import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, expect, it } from "vitest"; import { ContentLoader, ContentRegistry, createEngine, EconomySystem, GrowthSystem, InventorySystem, PlantingSystem, SystemsHost, TriggerSystem, World, WorldEventBus, type Entity, type System, type TimeSystem, } from "../src/index.js"; /** * Regression tests for the 2026-08-14 full-repo bug-fix round: * - inventory.use with an empty inventory must not apply effects / emit * - use() on a durable item decrements (removes at 0), never the whole stack * - grant paths (economy/behavior/planting) route durable items into instances * - a throwing system event handler must not break the tick chain * - crop-harvest / animal-produce fire AFTER the world state is updated * - watch() reloads assets whose same-name file CONTENT changed */ function bareSetup() { const world = new World(); const registry = new ContentRegistry(); const events = new WorldEventBus(); const host = new SystemsHost(); world.create({ id: "player", type: "player", x: 0, y: 0, gold: 100, stamina: 50 }); registry.register("time", { id: "default", dayLengthTicks: 480, seasons: [{ name: "spring", dayCount: 14 }], start: { day: 1, season: "spring" }, }); const inv = new InventorySystem(); const plant = new PlantingSystem(); const growth = new GrowthSystem(); const eco = new EconomySystem(); const trigger = new TriggerSystem(); for (const system of [inv, plant, growth, eco, trigger]) host.register(system); const ctx = { world, registry, events, systems: host }; inv.init(ctx); plant.init(ctx); growth.init(ctx); eco.init(ctx); trigger.init(ctx); return { world, registry, events, host, inv, plant, growth, eco, trigger }; } describe("inventory.use (bug-fix round)", () => { it("throws on an empty inventory and applies NO effects / emits nothing", () => { const { registry, events, inv, world } = bareSetup(); registry.register("item", { id: "pumpkin", name: "Pumpkin", kind: "crop", effects: { consumable: { stamina: 15 } }, }); const consumed: unknown[] = []; events.on("item-consume", (p) => consumed.push(p)); const player = world.get("player") as Entity & Record; expect(() => inv.use("pumpkin")).toThrow(/no pumpkin in inventory/); expect(player["stamina"]).toBe(50); expect(consumed).toEqual([]); }); it("decrements a durable item's durability instead of removing the whole instance", () => { const { registry, inv, world } = bareSetup(); registry.register("item", { id: "hoe", name: "Hoe", kind: "tool", durability: { max: 100 }, effects: { consumable: { stamina: 10 } }, }); inv.give("hoe", 1); inv.use("hoe"); expect(inv.count("hoe")).toBe(1); // the instance is still there // "use() decrements, removes at 0" — one use = one durability point. expect(inv.durability("hoe")).toEqual({ max: 100, current: 99 }); const player = world.get("player") as Entity & Record; expect(player["stamina"]).toBe(60); }); it("removes the instance when durability reaches 0 and emits item-break", () => { const { registry, events, inv } = bareSetup(); registry.register("item", { id: "hoe", name: "Hoe", kind: "tool", durability: { max: 100 }, effects: { consumable: { stamina: 10 } }, }); const broken: unknown[] = []; events.on("item-break", (p) => broken.push(p)); inv.give("hoe", 1); inv.damage("hoe", 99); inv.use("hoe"); // 1 remaining durability consumed → instance breaks expect(inv.count("hoe")).toBe(0); expect(inv.durability("hoe")).toBeNull(); expect(broken).toEqual([{ itemId: "hoe" }]); }); it("damage returns whether an instance was actually damaged", () => { const { inv } = bareSetup(); expect(inv.damage("hoe", 1)).toBe(false); }); }); describe("durable grants route through inventory instances (bug-fix round)", () => { it("economy.buy of a durable item lands in inventoryInstances", () => { const { registry, inv, eco } = bareSetup(); registry.register("item", { id: "hoe", name: "Hoe", kind: "tool", durability: { max: 100 } }); registry.register("shop", { id: "tools", name: "Tools", sells: [{ itemId: "hoe", price: 50 }], buys: [], }); eco.buy("tools", "hoe", 1); expect(inv.count("hoe")).toBe(1); expect(inv.durability("hoe")).toEqual({ max: 100, current: 100 }); }); it("trigger give-item of a durable item lands in inventoryInstances", () => { const { registry, inv, trigger } = bareSetup(); registry.register("item", { id: "hoe", name: "Hoe", kind: "tool", durability: { max: 100 } }); registry.register("behavior", { id: "grant-hoe", when: { op: "exists", path: "player" }, fire: [{ action: "give-item", itemId: "hoe", qty: 1 }], }); trigger.fire("grant-hoe"); expect(inv.durability("hoe")).toEqual({ max: 100, current: 100 }); }); }); describe("event chain resilience (bug-fix round)", () => { it("a throwing system event handler does not break the rest of the chain", async () => { const seen: string[] = []; const thrower: System = { id: "thrower", init() {}, on() { throw new Error("boom"); }, }; const listener: System = { id: "listener", init() {}, on(event) { if (event === "day-start") seen.push("day-start"); }, }; const engine = createEngine({ contentDir: repoContent, modsDir: repoMods, systems: [thrower, listener] }); await engine.start(); try { expect(() => engine.tick(480)).not.toThrow(); expect(seen).toContain("day-start"); } finally { await engine.stop(); } }); it("a bad trigger action during day-start does not freeze the tick chain", async () => { const dir = await mkdtemp(join(tmpdir(), "nengine-badtrigger-")); try { await writeFile( join(dir, "time.yaml"), ["id: default", "dayLengthTicks: 480", "seasons:", " - name: spring", " dayCount: 14", "start:", " day: 1", " season: spring"].join("\n"), ); await writeFile( join(dir, "weather.yaml"), ["id: default", "seasons:", " - season: spring", " weathers:", " - id: sunny", " weight: 100"].join("\n"), ); await mkdir(join(dir, "maps"), { recursive: true }); await writeFile( join(dir, "maps", "farm.yaml"), ["id: farm", "width: 2", "height: 2", "tilesetRef: ts", "spawn:", " x: 0", " y: 0", "layers:", " - name: ground", " data:", " - [grass, grass]", " - [grass, grass]"].join("\n"), ); await mkdir(join(dir, "behaviors"), { recursive: true }); await writeFile( join(dir, "behaviors", "bad-trigger.yaml"), [ "id: bad-trigger", "when:", " op: gte", " path: time.day", " value: 1", "fire:", " - action: add", " path: player.stamina", " value: 1", ].join("\n"), ); const seen: string[] = []; const listener: System = { id: "listener", init() {}, on(event) { if (event === "day-start") seen.push("day-start"); }, }; const engine = createEngine({ contentDir: dir, systems: [listener] }); await engine.start(); try { // Make the add target a string so the action throws. const player = engine.world.get("player") as Entity & Record; player["stamina"] = "high"; // Previously the throw aborted time.step mid-emit, skipping every // later system step AND re-throwing out of engine.tick. expect(() => engine.tick(480)).not.toThrow(); expect(seen).toContain("day-start"); // The engine keeps ticking afterwards. expect(() => engine.tick(480)).not.toThrow(); } finally { await engine.stop(); } } finally { await rm(dir, { recursive: true, force: true }); } }); }); describe("state-updated-then-emit (bug-fix round)", () => { it("crop-harvest fires AFTER the plot is cleared", () => { const { registry, world, events, plant, inv } = bareSetup(); registry.register("crop", { id: "carrot", name: "Carrot", stages: [ { name: "sprout", days: 1, waterRequired: false }, { name: "grown", days: 1, waterRequired: false }, ], seasons: ["spring"], drops: [{ itemId: "carrot", qty: 1 }], }); registry.register("item", { id: "carrot", name: "Carrot", kind: "crop" }); world.create({ id: "plot:0:0", type: "plot", x: 0, y: 0, tilled: true, cropItemId: "carrot", stageIndex: 1, daysInStage: 1 }); let cropItemIdAtEvent: unknown = "unset"; events.on("crop-harvest", () => { const plot = world.get("plot:0:0"); cropItemIdAtEvent = plot?.["cropItemId"]; }); plant.harvest(0, 0); expect(cropItemIdAtEvent).toBeUndefined(); expect(inv.count("carrot")).toBe(1); }); it("animal-produce fires AFTER the fed state is reset", () => { const { registry, world, events, growth } = bareSetup(); registry.register("animal", { id: "cow", name: "Cow", stages: [ { name: "calf", days: 1, feedRequired: true }, { name: "grown", days: 1, feedRequired: true }, ], }); const animal = world.create({ id: "animal:cow:1", type: "animal", animalId: "cow", x: 1, y: 1, stageIndex: 1, fed: true, }); let fedAtEvent: unknown = "unset"; events.on("animal-produce", () => { fedAtEvent = world.get("animal:cow:1")?.["fed"]; }); growth.step(480); // a full day — produce only advances on day boundaries expect(fedAtEvent).toBe(false); expect(world.get("animal:cow:1")?.["fed"]).toBe(false); void animal; }); }); describe("watch() same-name content changes (bug-fix round)", () => { it("reloads an asset whose file content changed under the same name", async () => { const dir = await mkdtemp(join(tmpdir(), "nengine-watchsize-")); try { const registry = new ContentRegistry(); const loader = new ContentLoader(registry); const stop = loader.watch(dir); await mkdir(join(dir, "assets"), { recursive: true }); await mkdir(join(dir, "maps"), { recursive: true }); await writeFile( join(dir, "maps", "farm.yaml"), ["id: farm", "width: 2", "height: 2", "tilesetRef: ts", "spawn:", " x: 0", " y: 0", "layers:", " - name: ground", " data:", " - [grass, grass]", " - [grass, grass]"].join("\n"), ); const bytes = Buffer.from("AAAA", "utf8"); await writeFile(join(dir, "assets", "icon.png"), bytes); await loader.load(dir); const before = registry.assets.get("icon"); expect(before?.data).toBe(Buffer.from("AAAA", "utf8").toString("base64")); // Overwrite the same file with DIFFERENT bytes (same name, new size). await writeFile(join(dir, "assets", "icon.png"), Buffer.from("BBBBBBBB", "utf8")); await waitFor(() => registry.assets.get("icon")?.data === Buffer.from("BBBBBBBB", "utf8").toString("base64"), 3000); stop(); } finally { await rm(dir, { recursive: true, force: true }); } }); }); const repoContent = join(process.cwd(), "../..", "content"); const repoMods = join(process.cwd(), "../..", "mods"); async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { const start = Date.now(); while (!predicate()) { if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout"); await new Promise((resolve) => setTimeout(resolve, 20)); } }