| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- 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<string, unknown>;
- 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<string, unknown>;
- 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<string, unknown>;
- 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<void> {
- const start = Date.now();
- while (!predicate()) {
- if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout");
- await new Promise((resolve) => setTimeout(resolve, 20));
- }
- }
|