| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505 |
- 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<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 saveOf(engine: ReturnType<typeof createEngine>): SaveSystem {
- const save = engine.systems.get<SaveSystem>("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<typeof createEngine>, 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<StateMachineDef>("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();
- }
- });
- });
|