| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544 |
- 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<typeof createEngine>): TriggerSystem {
- const trigger = engine.systems.get<TriggerSystem>("trigger");
- if (!trigger) throw new Error("trigger system not booted");
- return trigger;
- }
- function timeOf(engine: ReturnType<typeof createEngine>): TimeSystem {
- const time = engine.systems.get<TimeSystem>("time");
- if (!time) throw new Error("time system not booted");
- return time;
- }
- function inventoryOf(engine: ReturnType<typeof createEngine>): InventorySystem {
- const inv = engine.systems.get<InventorySystem>("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<string, unknown>): 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();
- }
- });
- it("play-bgm emits bgm-play with soundId and optional fadeMs (TR-12.1)", async () => {
- const { system: spy, seen } = eventSpy();
- const engine = engineSetup(spy);
- await engine.start();
- try {
- engine.registry.register("behavior", {
- id: "bgm-start",
- when: { op: "exists", path: "time.day" },
- fire: [{ action: "play-bgm", soundId: "bgm-x", fadeMs: 300 }],
- });
- const result = triggerOf(engine).fire("bgm-start");
- expect(result).toEqual({ triggerId: "bgm-start", fired: true });
- const bgmPlay = seen.find((s) => s.event === "bgm-play")?.payload as { soundId: string; fadeMs?: number };
- expect(bgmPlay).toEqual({ soundId: "bgm-x", fadeMs: 300 });
- } finally {
- await engine.stop();
- }
- });
- it("play-bgm without fadeMs emits bgm-play with only soundId", async () => {
- const { system: spy, seen } = eventSpy();
- const engine = engineSetup(spy);
- await engine.start();
- try {
- engine.registry.register("behavior", {
- id: "bgm-start-simple",
- when: { op: "exists", path: "time.day" },
- fire: [{ action: "play-bgm", soundId: "bgm-y" }],
- });
- const result = triggerOf(engine).fire("bgm-start-simple");
- expect(result).toEqual({ triggerId: "bgm-start-simple", fired: true });
- const bgmPlay = seen.find((s) => s.event === "bgm-play")?.payload as { soundId: string; fadeMs?: number };
- expect(bgmPlay).toEqual({ soundId: "bgm-y", fadeMs: undefined });
- } finally {
- await engine.stop();
- }
- });
- it("stop-bgm emits bgm-stop with optional fadeMs", async () => {
- const { system: spy, seen } = eventSpy();
- const engine = engineSetup(spy);
- await engine.start();
- try {
- engine.registry.register("behavior", {
- id: "bgm-end",
- when: { op: "exists", path: "time.day" },
- fire: [{ action: "stop-bgm", fadeMs: 500 }],
- });
- const result = triggerOf(engine).fire("bgm-end");
- expect(result).toEqual({ triggerId: "bgm-end", fired: true });
- const bgmStop = seen.find((s) => s.event === "bgm-stop")?.payload as { fadeMs?: number };
- expect(bgmStop).toEqual({ fadeMs: 500 });
- } finally {
- await engine.stop();
- }
- });
- it("stop-bgm without fadeMs emits bgm-stop with undefined fadeMs", async () => {
- const { system: spy, seen } = eventSpy();
- const engine = engineSetup(spy);
- await engine.start();
- try {
- engine.registry.register("behavior", {
- id: "bgm-end-simple",
- when: { op: "exists", path: "time.day" },
- fire: [{ action: "stop-bgm" }],
- });
- const result = triggerOf(engine).fire("bgm-end-simple");
- expect(result).toEqual({ triggerId: "bgm-end-simple", fired: true });
- const bgmStop = seen.find((s) => s.event === "bgm-stop")?.payload as { fadeMs?: number };
- expect(bgmStop).toEqual({ fadeMs: undefined });
- } 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();
- }
- });
- });
|