|
|
@@ -0,0 +1,326 @@
|
|
|
+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));
|
|
|
+ }
|
|
|
+}
|