import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, TriggerSystem, type ConditionExpr, type InventorySystem, type System, type TimeSystem, type TriggerDef, type TriggerEvalCtx, } from "../src/index.js"; const repoRoot = resolve(process.cwd(), "../.."); const contentDir = resolve(repoRoot, "content"); const modsDir = resolve(repoRoot, "mods"); function engineSetup(spy?: System) { return createEngine({ contentDir, modsDir, systems: spy ? [spy] : [] }); } function triggerOf(engine: ReturnType): TriggerSystem { const trigger = engine.systems.get("trigger"); if (!trigger) throw new Error("trigger system not booted"); return trigger; } function timeOf(engine: ReturnType): TimeSystem { const time = engine.systems.get("time"); if (!time) throw new Error("time system not booted"); return time; } function inventoryOf(engine: ReturnType): InventorySystem { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); return inv; } 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 ctxOf(values: Record): TriggerEvalCtx { return { get: (path) => values[path] }; } describe("TriggerSystem.evaluate", () => { it("evaluates eq/gte/lte/exists comparisons", () => { const system = new TriggerSystem(); const ctx = ctxOf({ "time.day": 5, "time.season": "summer", "player.flag": undefined }); const eq: ConditionExpr = { op: "eq", path: "time.season", value: "summer" }; expect(system.evaluate(eq, ctx)).toBe(true); expect(system.evaluate({ ...eq, value: "winter" }, ctx)).toBe(false); const gte: ConditionExpr = { op: "gte", path: "time.day", value: 5 }; expect(system.evaluate(gte, ctx)).toBe(true); expect(system.evaluate({ op: "gte", path: "time.day", value: 6 }, ctx)).toBe(false); const lte: ConditionExpr = { op: "lte", path: "time.day", value: 5 }; expect(system.evaluate(lte, ctx)).toBe(true); expect(system.evaluate({ op: "lte", path: "time.day", value: 4 }, ctx)).toBe(false); const exists: ConditionExpr = { op: "exists", path: "player.flag" }; expect(system.evaluate(exists, ctx)).toBe(false); expect(system.evaluate({ op: "exists", path: "time.day" }, ctx)).toBe(true); }); it("gte/lte are false when either operand is not a number", () => { const system = new TriggerSystem(); expect(system.evaluate({ op: "gte", path: "time.season", value: "summer" }, ctxOf({ "time.season": "summer" }))).toBe(false); expect(system.evaluate({ op: "gte", path: "time.day", value: "5" }, ctxOf({ "time.day": 5 }))).toBe(false); expect(system.evaluate({ op: "lte", path: "time.day", value: 5 }, ctxOf({ "time.day": undefined }))).toBe(false); }); it("evaluates and/or/not composition", () => { const system = new TriggerSystem(); const ctx = ctxOf({ "time.day": 1, "time.season": "summer", "player.flag": "welcomed" }); const andTrue: ConditionExpr = { and: [ { op: "eq", path: "time.day", value: 1 }, { op: "eq", path: "time.season", value: "summer" }, ], }; expect(system.evaluate(andTrue, ctx)).toBe(true); expect(system.evaluate({ and: [...andTrue.and, { op: "eq", path: "time.day", value: 2 }] }, ctx)).toBe(false); const orTrue: ConditionExpr = { or: [ { op: "eq", path: "time.day", value: 99 }, { op: "eq", path: "time.season", value: "summer" }, ], }; expect(system.evaluate(orTrue, ctx)).toBe(true); expect(system.evaluate({ or: [{ op: "eq", path: "time.day", value: 99 }, { op: "eq", path: "time.season", value: "winter" }] }, ctx)).toBe(false); const notTrue: ConditionExpr = { not: { op: "eq", path: "time.season", value: "winter" } }; expect(system.evaluate(notTrue, ctx)).toBe(true); expect(system.evaluate({ not: { op: "eq", path: "time.season", value: "summer" } }, ctx)).toBe(false); }); }); describe("TriggerSystem.fire", () => { it("condition true runs actions, emits trigger-fire, and fires the onTriggerFire hook", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { const hookFired: unknown[] = []; engine.hooks.on("onTriggerFire", (ctx) => { hookFired.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day }); }); timeOf(engine).set(5, "spring"); const result = triggerOf(engine).fire("spring-gift"); expect(result).toEqual({ triggerId: "spring-gift", fired: true }); expect(inventoryOf(engine).count("parsnip-seed")).toBe(1); expect(hookFired).toEqual([{ world: "player", time: 5 }]); expect(seen.some((s) => s.event === "trigger-fire")).toBe(true); const firedEvent = seen.find((s) => s.event === "trigger-fire")?.payload as { triggerId: string }; expect(firedEvent).toEqual({ triggerId: "spring-gift" }); } finally { await engine.stop(); } }); it("condition false runs no actions and emits no trigger-fire", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { timeOf(engine).set(1, "spring"); const result = triggerOf(engine).fire("spring-gift"); expect(result).toEqual({ triggerId: "spring-gift", fired: false }); expect(inventoryOf(engine).count("parsnip-seed")).toBe(0); expect(seen.some((s) => s.event === "trigger-fire")).toBe(false); } finally { await engine.stop(); } }); it("unknown trigger id throws", async () => { const engine = engineSetup(); await engine.start(); try { expect(() => triggerOf(engine).fire("nope")).toThrow(/unknown trigger: nope/); } finally { await engine.stop(); } }); it("set-state action mutates an entity field", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("behavior", { id: "mark-player", when: { op: "exists", path: "time.day" }, fire: [{ action: "set-state", path: "player.flag", value: "welcomed" }], }); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); expect(player.flag).toBeUndefined(); const result = triggerOf(engine).fire("mark-player"); expect(result).toEqual({ triggerId: "mark-player", fired: true }); expect(player.flag).toBe("welcomed"); } finally { await engine.stop(); } }); it("set-state on a missing entity throws", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("behavior", { id: "bad-state", when: { op: "exists", path: "time.day" }, fire: [{ action: "set-state", path: "world.ghost.state", value: 1 }], }); expect(() => triggerOf(engine).fire("bad-state")).toThrow(/no entity: ghost/); } finally { await engine.stop(); } }); it("emit-event action emits the event with payload", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.registry.register("behavior", { id: "hello", when: { op: "exists", path: "time.day" }, fire: [{ action: "emit-event", event: "behavior:hello", payload: { greeting: "hi" } }], }); const result = triggerOf(engine).fire("hello"); expect(result).toEqual({ triggerId: "hello", fired: true }); const emitted = seen.find((s) => s.event === "behavior:hello")?.payload as { greeting: string }; expect(emitted).toEqual({ greeting: "hi" }); } finally { await engine.stop(); } }); it("play-sound emits a sound-play event and dialogue actions are accepted no-ops in headless v1", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.registry.register("behavior", { id: "flavor", when: { op: "exists", path: "time.day" }, fire: [ { action: "play-sound", sound: "chime" }, { action: "dialogue", text: "hello" }, ], }); const result = triggerOf(engine).fire("flavor"); expect(result).toEqual({ triggerId: "flavor", fired: true }); expect(seen).toEqual([ { event: "sound-play", payload: { soundId: "chime" } }, { event: "trigger-fire", payload: { triggerId: "flavor" } }, ]); } finally { await engine.stop(); } }); }); describe("TriggerSystem add action (D33)", () => { it("add accumulates across multiple fires on a numeric field", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("behavior", { id: "gain-gold", when: { op: "exists", path: "time.day" }, fire: [{ action: "add", path: "player.gold", value: 5 }], }); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = 10; expect(triggerOf(engine).fire("gain-gold").fired).toBe(true); expect(triggerOf(engine).fire("gain-gold").fired).toBe(true); expect(player.gold).toBe(20); } finally { await engine.stop(); } }); it("negative add decrements a numeric field", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("behavior", { id: "spend-gold", when: { op: "exists", path: "time.day" }, fire: [{ action: "add", path: "player.gold", value: -3 }], }); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = 10; triggerOf(engine).fire("spend-gold"); expect(player.gold).toBe(7); } finally { await engine.stop(); } }); it("a missing field starts from 0 then adds", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("behavior", { id: "first-gold", when: { op: "exists", path: "time.day" }, fire: [{ action: "add", path: "player.gold", value: 100 }], }); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); delete player.gold; triggerOf(engine).fire("first-gold"); expect(player.gold).toBe(100); triggerOf(engine).fire("first-gold"); expect(player.gold).toBe(200); } finally { await engine.stop(); } }); it("add on a non-numeric field throws", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("behavior", { id: "bad-add", when: { op: "exists", path: "time.day" }, fire: [{ action: "add", path: "player.gold", value: 1 }], }); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = "rich"; expect(() => triggerOf(engine).fire("bad-add")).toThrow(/action add: field player\.gold is not a number/); } finally { await engine.stop(); } }); it("add on a missing entity throws like set-state", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("behavior", { id: "bad-add-entity", when: { op: "exists", path: "time.day" }, fire: [{ action: "add", path: "world.ghost.score", value: 1 }], }); expect(() => triggerOf(engine).fire("bad-add-entity")).toThrow(/no entity: ghost/); } finally { await engine.stop(); } }); it("add works in state machine onEnter actions", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "gold-machine", initial: "start", states: [ { id: "start" }, { id: "done", onEnter: [{ action: "add", path: "player.gold", value: 100 }] }, ], transitions: [{ from: "start", to: "done", when: { op: "exists", path: "time.day" } }], }); triggerOf(engine).machineTransition("gold-machine", "done"); expect(engine.world.get("player")?.gold).toBe(100); } finally { await engine.stop(); } }); it("set-state remains absolute after adds", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("behavior", { id: "reset-gold", when: { op: "exists", path: "time.day" }, fire: [{ action: "set-state", path: "player.gold", value: 42 }], }); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = 100; triggerOf(engine).fire("reset-gold"); expect(player.gold).toBe(42); } finally { await engine.stop(); } }); }); describe("TriggerSystem.match", () => { it("runs triggers whose condition matches the event payload (time.day style ctx)", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.registry.register("behavior", { id: "gift-on-parsnip", when: { op: "eq", path: "payload.itemId", value: "parsnip" }, fire: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }], }); timeOf(engine).set(1, "spring"); inventoryOf(engine).give("parsnip", 1); expect(inventoryOf(engine).count("parsnip-seed")).toBe(1); expect(seen.some((s) => s.event === "trigger-fire")).toBe(true); inventoryOf(engine).give("hoe", 1); expect(inventoryOf(engine).count("parsnip-seed")).toBe(1); } finally { await engine.stop(); } }); it("spring-gift fires on any bus event once time.day >= 5", async () => { const engine = engineSetup(); await engine.start(); try { timeOf(engine).set(1, "spring"); inventoryOf(engine).give("parsnip", 1); expect(inventoryOf(engine).count("parsnip-seed")).toBe(0); timeOf(engine).set(5, "spring"); inventoryOf(engine).give("parsnip", 1); expect(inventoryOf(engine).count("parsnip-seed")).toBe(1); inventoryOf(engine).give("parsnip", 1); expect(inventoryOf(engine).count("parsnip-seed")).toBe(2); } finally { await engine.stop(); } }); it("trigger-fire events are never re-matched (v1 loop guard)", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.registry.register("behavior", { id: "echo", when: { op: "exists", path: "time.day" }, fire: [{ action: "emit-event", event: "trigger-fire", payload: { triggerId: "echo" } }], }); expect(triggerOf(engine).match("trigger-fire", {})).toEqual({ fired: 0 }); timeOf(engine).set(1, "spring"); const result = triggerOf(engine).match("behavior:seed", {}); expect(result).toEqual({ fired: 1 }); const triggerFireCount = seen.filter((s) => s.event === "trigger-fire").length; expect(triggerFireCount).toBe(2); } finally { await engine.stop(); } }); it("engine forwards bus events to the trigger system via on()", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.registry.register("behavior", { id: "summer-day", when: { and: [ { op: "eq", path: "time.day", value: 1 }, { op: "eq", path: "time.season", value: "summer" }, ], }, fire: [{ action: "emit-event", event: "behavior:summer", payload: { day: 1 } }], }); timeOf(engine).set(1, "spring"); inventoryOf(engine).give("parsnip", 1); expect(seen.some((s) => s.event === "behavior:summer")).toBe(false); timeOf(engine).set(1, "summer"); inventoryOf(engine).give("parsnip", 1); expect(seen.some((s) => s.event === "behavior:summer")).toBe(true); } finally { await engine.stop(); } }); });