import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, stateMachineSchema, type InventorySystem, type SaveSystem, type StateMachineDef, type System, type TimeSystem, type TriggerSystem, } 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 saveOf(engine: ReturnType): SaveSystem { const save = engine.systems.get("save"); if (!save) throw new Error("save system not booted"); return save; } 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 stateOf(engine: ReturnType, machineId: string): string | undefined { return engine.world.get(`sm:${machineId}`)?.state as string | undefined; } const SIMPLE_DEF: StateMachineDef = { id: "test-simple", initial: "sleeping", states: [ { id: "sleeping" }, { id: "working", onEnter: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }] }, { id: "resting", onExit: [{ action: "emit-event", event: "test:left-resting", payload: { state: "resting" } }] }, ], transitions: [{ from: "working", to: "resting", on: "item-acquire" }], onSchedule: [{ at: "day-start", to: "working" }], }; describe("stateMachineSchema", () => { it("accepts a valid def and rejects unknown initial / undeclared states / duplicate ids / empty transitions", () => { expect(stateMachineSchema.safeParse(SIMPLE_DEF).success).toBe(true); const badInitial = stateMachineSchema.safeParse({ ...SIMPLE_DEF, initial: "nope" }); expect(badInitial.success).toBe(false); const badTransitionTarget = stateMachineSchema.safeParse({ ...SIMPLE_DEF, transitions: [{ from: "working", to: "ghost", on: "kick" }], }); expect(badTransitionTarget.success).toBe(false); const badTransitionSource = stateMachineSchema.safeParse({ ...SIMPLE_DEF, transitions: [{ from: "ghost", to: "working", on: "kick" }], }); expect(badTransitionSource.success).toBe(false); const duplicateStates = stateMachineSchema.safeParse({ ...SIMPLE_DEF, states: [{ id: "sleeping" }, { id: "sleeping" }], }); expect(duplicateStates.success).toBe(false); const noTrigger = stateMachineSchema.safeParse({ ...SIMPLE_DEF, transitions: [{ from: "sleeping", to: "working" }], }); expect(noTrigger.success).toBe(false); const badScheduleTarget = stateMachineSchema.safeParse({ ...SIMPLE_DEF, onSchedule: [{ at: "day-start", to: "ghost" }], }); expect(badScheduleTarget.success).toBe(false); const emptyAt = stateMachineSchema.safeParse({ ...SIMPLE_DEF, onSchedule: [{ at: {}, to: "working" }], }); expect(emptyAt.success).toBe(false); }); }); describe("state machine driving (event + condition)", () => { it("creates a lazy instance on first transition, runs onEnter actions, and advances on events", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", SIMPLE_DEF); const trigger = triggerOf(engine); expect(engine.world.get("sm:test-simple")).toBeUndefined(); expect(trigger.machineStatus("test-simple")).toEqual({ machineId: "test-simple", state: "sleeping", started: false }); trigger.match("day-start", { day: 2, season: "spring" }); expect(stateOf(engine, "test-simple")).toBe("working"); expect(engine.world.get("sm:test-simple")).toMatchObject({ type: "statemachine", machineId: "test-simple" }); expect(inventoryOf(engine).count("parsnip-seed")).toBe(1); expect(trigger.machineStatus("test-simple")).toEqual({ machineId: "test-simple", state: "working", started: true }); trigger.match("item-acquire", { itemId: "parsnip", qty: 1, total: 1 }); expect(stateOf(engine, "test-simple")).toBe("resting"); } finally { await engine.stop(); } }); it("runs onExit actions when leaving a state", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.registry.register("statemachine", SIMPLE_DEF); const trigger = triggerOf(engine); trigger.match("day-start", { day: 2, season: "spring" }); trigger.match("item-acquire", { itemId: "parsnip", qty: 1, total: 1 }); expect(seen.some((s) => s.event === "test:left-resting")).toBe(false); trigger.match("day-start", { day: 3, season: "spring" }); expect(stateOf(engine, "test-simple")).toBe("working"); expect(seen.some((s) => s.event === "test:left-resting")).toBe(true); } finally { await engine.stop(); } }); it("a transition with when (no on) is evaluated on every bus event", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-cond", initial: "idle", states: [{ id: "idle" }, { id: "done" }], transitions: [{ from: "idle", to: "done", when: { op: "gte", path: "time.day", value: 5 } }], }); const trigger = triggerOf(engine); timeOf(engine).set(3, "spring"); trigger.match("behavior:any", {}); expect(stateOf(engine, "test-cond")).toBeUndefined(); timeOf(engine).set(5, "spring"); trigger.match("behavior:any", {}); expect(stateOf(engine, "test-cond")).toBe("done"); } finally { await engine.stop(); } }); it("a transition with on + when requires both to hold", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-both", initial: "inactive", states: [{ id: "inactive" }, { id: "active" }], transitions: [ { from: "inactive", to: "active", on: "day-start", when: { op: "gte", path: "time.day", value: 3 } }, ], }); const trigger = triggerOf(engine); trigger.match("day-start", { day: 2, season: "spring" }); expect(stateOf(engine, "test-both")).toBeUndefined(); timeOf(engine).set(3, "spring"); trigger.match("day-start", { day: 3, season: "spring" }); expect(stateOf(engine, "test-both")).toBe("active"); } finally { await engine.stop(); } }); it("first matching transition in declaration order wins; at most one per event", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-order", initial: "a", states: [{ id: "a" }, { id: "b" }, { id: "c" }], transitions: [ { from: "a", to: "b", on: "kick" }, { from: "a", to: "c", on: "kick" }, ], }); triggerOf(engine).match("kick", {}); expect(stateOf(engine, "test-order")).toBe("b"); } finally { await engine.stop(); } }); it("transitioning to the current state is a no-op (no state-change, no onEnter)", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.registry.register("statemachine", { id: "test-noop", initial: "a", states: [{ id: "a", onEnter: [{ action: "emit-event", event: "test:entered-a" }] }], transitions: [{ from: "a", to: "a", on: "kick" }], }); triggerOf(engine).match("kick", {}); expect(stateOf(engine, "test-noop")).toBe("a"); expect(seen.some((s) => s.event === "state-change")).toBe(false); expect(seen.some((s) => s.event === "test:entered-a")).toBe(false); } finally { await engine.stop(); } }); }); describe("state machine driving (onSchedule time primitive)", () => { it("time.advance drives a schedule-driven machine through the engine wiring (day-start)", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-day", initial: "sleeping", states: [{ id: "sleeping" }, { id: "awake" }], transitions: [], onSchedule: [{ at: "day-start", to: "awake" }], }); expect(engine.world.get("sm:test-day")).toBeUndefined(); timeOf(engine).advance(480); expect(stateOf(engine, "test-day")).toBe("awake"); } finally { await engine.stop(); } }); it("schedule entries with a when condition gate the transition", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-sched-when", initial: "idle", states: [{ id: "idle" }, { id: "resting" }], transitions: [], onSchedule: [ { at: "day-start", to: "resting", when: { op: "gte", path: "payload.day", value: 28 } }, ], }); const trigger = triggerOf(engine); trigger.match("day-start", { day: 5, season: "spring" }); expect(stateOf(engine, "test-sched-when")).toBeUndefined(); trigger.match("day-start", { day: 28, season: "spring" }); expect(stateOf(engine, "test-sched-when")).toBe("resting"); } finally { await engine.stop(); } }); it("the object at form matches the day-start/season-change payload", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-sched-object", initial: "idle", states: [{ id: "idle" }, { id: "summer" }], transitions: [], onSchedule: [{ at: { day: 1, season: "summer" }, to: "summer" }], }); const trigger = triggerOf(engine); trigger.match("day-start", { day: 1, season: "spring" }); expect(stateOf(engine, "test-sched-object")).toBeUndefined(); trigger.match("day-start", { day: 1, season: "summer" }); expect(stateOf(engine, "test-sched-object")).toBe("summer"); } finally { await engine.stop(); } }); it("schedule entries do not fire on non-time events", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-sched-event", initial: "idle", states: [{ id: "idle" }, { id: "awake" }], transitions: [], onSchedule: [{ at: "day-start", to: "awake" }], }); triggerOf(engine).match("item-acquire", { itemId: "parsnip", qty: 1, total: 1 }); expect(stateOf(engine, "test-sched-event")).toBeUndefined(); } finally { await engine.stop(); } }); it("engine forwards bus events to machines (item-acquire drives a transition)", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-fwd", initial: "idle", states: [{ id: "idle" }, { id: "busy" }], transitions: [{ from: "idle", to: "busy", on: "item-acquire" }], }); inventoryOf(engine).give("parsnip", 1); expect(stateOf(engine, "test-fwd")).toBe("busy"); } finally { await engine.stop(); } }); it("the real content machine daily-routine is driven by time advance and item-acquire", async () => { const engine = engineSetup(); await engine.start(); try { expect(engine.registry.get("statemachine", "daily-routine")).toBeDefined(); timeOf(engine).advance(480); expect(stateOf(engine, "daily-routine")).toBe("working"); timeOf(engine).set(27, "spring"); timeOf(engine).advance(480); expect(stateOf(engine, "daily-routine")).toBe("resting"); timeOf(engine).set(2, "spring"); timeOf(engine).advance(480); expect(stateOf(engine, "daily-routine")).toBe("working"); inventoryOf(engine).give("parsnip", 1); expect(stateOf(engine, "daily-routine")).toBe("resting"); } finally { await engine.stop(); } }); }); describe("state machine lifecycle + hooks", () => { it("machine state persists on the entity and survives save snapshot/restore", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-persist", initial: "idle", states: [{ id: "idle" }, { id: "active" }], transitions: [{ from: "idle", to: "active", on: "kick" }], }); triggerOf(engine).match("kick", {}); expect(stateOf(engine, "test-persist")).toBe("active"); const snapshot = saveOf(engine).snapshot(); expect(snapshot.world.some((e) => e.id === "sm:test-persist" && e.state === "active")).toBe(true); saveOf(engine).restore(snapshot); expect(stateOf(engine, "test-persist")).toBe("active"); } finally { await engine.stop(); } }); it("state-change events are not re-matched in the same pass (re-entrancy guard)", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-re-a", initial: "a", states: [{ id: "a" }, { id: "b" }], transitions: [{ from: "a", to: "b", on: "kick" }], }); engine.registry.register("statemachine", { id: "test-re-b", initial: "x", states: [{ id: "x" }, { id: "y" }], transitions: [{ from: "x", to: "y", on: "state-change" }], }); const trigger = triggerOf(engine); trigger.match("kick", {}); expect(stateOf(engine, "test-re-a")).toBe("b"); expect(engine.world.get("sm:test-re-b")).toBeUndefined(); trigger.match("state-change", {}); expect(stateOf(engine, "test-re-b")).toBe("y"); } finally { await engine.stop(); } }); it("the onStateChange hook fires on transitions (reads machine state via ctx.world)", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-hook", initial: "a", states: [{ id: "a" }, { id: "b" }], transitions: [{ from: "a", to: "b", on: "kick" }], }); const fired: unknown[] = []; engine.hooks.on("onStateChange", (ctx) => { fired.push(ctx.world.get("sm:test-hook")?.state); }); triggerOf(engine).match("kick", {}); expect(fired).toEqual(["b"]); } finally { await engine.stop(); } }); it("machineTransition forces a transition regardless of declared conditions", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("statemachine", { id: "test-force", initial: "idle", states: [ { id: "idle" }, { id: "complete", onEnter: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }] }, ], transitions: [], }); const trigger = triggerOf(engine); expect(trigger.machineTransition("test-force", "complete")).toEqual({ machineId: "test-force", from: "idle", to: "complete", transitioned: true, }); expect(stateOf(engine, "test-force")).toBe("complete"); expect(inventoryOf(engine).count("parsnip-seed")).toBe(1); expect(trigger.machineTransition("test-force", "complete")).toEqual({ machineId: "test-force", from: "complete", to: "complete", transitioned: false, }); } finally { await engine.stop(); } }); it("machineStatus and machineTransition throw on unknown machine or state", async () => { const engine = engineSetup(); await engine.start(); try { const trigger = triggerOf(engine); expect(() => trigger.machineStatus("nope")).toThrow(/unknown machine: nope/); expect(() => trigger.machineTransition("nope", "a")).toThrow(/unknown machine: nope/); engine.registry.register("statemachine", { id: "test-err", initial: "a", states: [{ id: "a" }], transitions: [], }); expect(() => trigger.machineTransition("test-err", "ghost")).toThrow(/unknown state: ghost/); } finally { await engine.stop(); } }); });