import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { ACTIVE_DIALOGUE_ENTITY_ID, createEngine, dialogueSchema, type DialogueDef, type DialogueSystem, type InventorySystem, type SaveSystem, type System, type TimeSystem, } 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 dialogueOf(engine: ReturnType): DialogueSystem { const dialogue = engine.systems.get("dialogue"); if (!dialogue) throw new Error("dialogue system not booted"); return dialogue; } 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 timeOf(engine: ReturnType): TimeSystem { const time = engine.systems.get("time"); if (!time) throw new Error("time system not booted"); return time; } 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 }; } const SIMPLE_DEF: DialogueDef = { id: "test-welcome", startNode: "hello", nodes: [ { id: "hello", text: "Hello!", choices: [ { text: "Give me a seed", to: "thanks", actions: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }] }, { text: "No thanks", to: "bye" }, ], }, { id: "thanks", text: "Here you go." }, { id: "bye", text: "Bye!" }, ], }; describe("dialogueSchema", () => { it("accepts a valid def and rejects unknown startNode / bad targets / mixed choices+to / empty choices / duplicate ids", () => { expect(dialogueSchema.safeParse(SIMPLE_DEF).success).toBe(true); const badStart = dialogueSchema.safeParse({ ...SIMPLE_DEF, startNode: "nope" }); expect(badStart.success).toBe(false); const badChoiceTo = dialogueSchema.safeParse({ ...SIMPLE_DEF, nodes: [ { id: "hello", text: "Hello!", choices: [{ text: "Go", to: "ghost" }], }, ], }); expect(badChoiceTo.success).toBe(false); const badNodeTo = dialogueSchema.safeParse({ ...SIMPLE_DEF, nodes: [ { id: "hello", text: "Hello!", to: "ghost" }, { id: "bye", text: "Bye" }, ], }); expect(badNodeTo.success).toBe(false); const mixed = dialogueSchema.safeParse({ ...SIMPLE_DEF, nodes: [{ id: "hello", text: "Hello!", to: "bye", choices: [{ text: "Go", to: "bye" }] }], }); expect(mixed.success).toBe(false); const emptyChoices = dialogueSchema.safeParse({ ...SIMPLE_DEF, nodes: [{ id: "hello", text: "Hello!", choices: [] }], }); expect(emptyChoices.success).toBe(false); const duplicateIds = dialogueSchema.safeParse({ ...SIMPLE_DEF, nodes: [ { id: "hello", text: "a" }, { id: "hello", text: "b" }, ], }); expect(duplicateIds.success).toBe(false); const emptyNodes = dialogueSchema.safeParse({ ...SIMPLE_DEF, nodes: [] }); expect(emptyNodes.success).toBe(false); }); it("accepts a terminal node and a linear node chain", () => { const def = dialogueSchema.safeParse({ id: "linear", startNode: "a", nodes: [ { id: "a", text: "start", to: "b" }, { id: "b", text: "end" }, ], }); expect(def.success).toBe(true); }); }); describe("DialogueSystem.start", () => { it("returns the start node state, creates the active entity, emits dialogue-start, and fires onDialogueStart", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.registry.register("dialogue", SIMPLE_DEF); const hookFired: unknown[] = []; engine.hooks.on("onDialogueStart", (ctx) => { hookFired.push(ctx.world.get(ACTIVE_DIALOGUE_ENTITY_ID)?.nodeId); }); const state = dialogueOf(engine).start("test-welcome"); expect(state).toEqual({ dialogueId: "test-welcome", nodeId: "hello", text: "Hello!", choices: [ { text: "Give me a seed", to: "thanks" }, { text: "No thanks", to: "bye" }, ], }); expect(engine.world.get(ACTIVE_DIALOGUE_ENTITY_ID)).toMatchObject({ type: "dialogue", dialogueId: "test-welcome", nodeId: "hello", }); expect(seen.some((s) => s.event === "dialogue-start")).toBe(true); expect(seen.find((s) => s.event === "dialogue-start")?.payload).toEqual({ dialogueId: "test-welcome", nodeId: "hello", }); expect(hookFired).toEqual(["hello"]); } finally { await engine.stop(); } }); it("runs the start node's actions on enter", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("dialogue", { id: "test-action", startNode: "gift", nodes: [ { id: "gift", text: "Have a seed.", actions: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }] }, ], }); dialogueOf(engine).start("test-action"); expect(inventoryOf(engine).count("parsnip-seed")).toBe(1); } finally { await engine.stop(); } }); it("throws on an unknown dialogue id", async () => { const engine = engineSetup(); await engine.start(); try { expect(() => dialogueOf(engine).start("nope")).toThrow(/unknown dialogue: nope/); } finally { await engine.stop(); } }); it("starting a second dialogue replaces the first without emitting dialogue-end", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.registry.register("dialogue", SIMPLE_DEF); engine.registry.register("dialogue", { id: "other", startNode: "a", nodes: [ { id: "a", text: "other", to: "b" }, { id: "b", text: "end" }, ], }); const dialogue = dialogueOf(engine); dialogue.start("test-welcome"); const replaced = dialogue.start("other"); expect(replaced.dialogueId).toBe("other"); expect(engine.world.get(ACTIVE_DIALOGUE_ENTITY_ID)).toMatchObject({ dialogueId: "other" }); expect(seen.some((s) => s.event === "dialogue-end")).toBe(false); } finally { await engine.stop(); } }); }); describe("DialogueSystem.choose", () => { it("advances through a choice, runs its actions, and updates the entity", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.registry.register("dialogue", { ...SIMPLE_DEF, nodes: [ { id: "hello", text: "Hello!", choices: [ { text: "Give me a seed", to: "thanks", actions: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }] }, { text: "No thanks", to: "bye" }, ], }, { id: "thanks", text: "Here you go.", to: "another" }, { id: "another", text: "Anything else?" }, { id: "bye", text: "Bye!" }, ], }); const dialogue = dialogueOf(engine); dialogue.start("test-welcome"); const next = dialogue.choose(0); expect(next).toEqual({ dialogueId: "test-welcome", nodeId: "thanks", text: "Here you go.", choices: [{ text: "Continue", to: "another" }], }); expect(inventoryOf(engine).count("parsnip-seed")).toBe(1); expect(engine.world.get(ACTIVE_DIALOGUE_ENTITY_ID)?.nodeId).toBe("thanks"); expect(seen.some((s) => s.event === "item-acquire")).toBe(true); } finally { await engine.stop(); } }); it("a choice with no actions advances without side effects", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("dialogue", SIMPLE_DEF); const dialogue = dialogueOf(engine); dialogue.start("test-welcome"); const next = dialogue.choose(1); expect(next.nodeId).toBe("bye"); expect(inventoryOf(engine).count("parsnip-seed")).toBe(0); } finally { await engine.stop(); } }); it("throws on an out-of-range index or when no dialogue is active", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("dialogue", SIMPLE_DEF); const dialogue = dialogueOf(engine); dialogue.start("test-welcome"); expect(() => dialogue.choose(2)).toThrow(/choice index out of range: 2/); expect(() => dialogue.choose(-1)).toThrow(/choice index out of range: -1/); dialogue.end(); expect(() => dialogue.choose(0)).toThrow(/no active dialogue/); } finally { await engine.stop(); } }); it("a linear to-node presents a synthesized Continue choice", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("dialogue", { id: "test-linear", startNode: "a", nodes: [ { id: "a", text: "first", to: "b" }, { id: "b", text: "last" }, ], }); const dialogue = dialogueOf(engine); const start = dialogue.start("test-linear"); expect(start).toEqual({ dialogueId: "test-linear", nodeId: "a", text: "first", choices: [{ text: "Continue", to: "b" }], }); const next = dialogue.choose(0); expect(next.nodeId).toBe("b"); } finally { await engine.stop(); } }); }); describe("DialogueSystem lifecycle (terminal nodes)", () => { it("entering a terminal node ends the dialogue, emits dialogue-end, and fires onDialogueEnd", async () => { const { system: spy, seen } = eventSpy(); const engine = engineSetup(spy); await engine.start(); try { engine.registry.register("dialogue", SIMPLE_DEF); const hookFired: unknown[] = []; engine.hooks.on("onDialogueEnd", (ctx) => { hookFired.push(ctx.world.get(ACTIVE_DIALOGUE_ENTITY_ID) === undefined); }); const dialogue = dialogueOf(engine); dialogue.start("test-welcome"); const final = dialogue.choose(0); expect(final).toEqual({ dialogueId: "test-welcome", nodeId: "thanks", text: "Here you go.", choices: [], }); expect(engine.world.get(ACTIVE_DIALOGUE_ENTITY_ID)).toBeUndefined(); expect(dialogue.current()).toBeNull(); expect(seen.some((s) => s.event === "dialogue-end")).toBe(true); expect(seen.find((s) => s.event === "dialogue-end")?.payload).toEqual({ dialogueId: "test-welcome" }); expect(hookFired).toEqual([true]); } finally { await engine.stop(); } }); it("a choices node whose conditions all fail is treated as terminal", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("dialogue", { id: "test-gated", startNode: "door", nodes: [ { id: "door", text: "Knock knock.", choices: [ { text: "Come in", to: "inside", when: { op: "exists", path: "player.flag" } }, ], }, { id: "inside", text: "Welcome." }, ], }); const dialogue = dialogueOf(engine); const state = dialogue.start("test-gated"); expect(state.choices).toEqual([]); expect(engine.world.get(ACTIVE_DIALOGUE_ENTITY_ID)).toBeUndefined(); expect(dialogue.current()).toBeNull(); } finally { await engine.stop(); } }); }); describe("DialogueSystem conditions", () => { it("gated choices are hidden from current() and choose indexes into the available list", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("dialogue", { id: "test-gate", startNode: "ask", nodes: [ { id: "ask", text: "Ask me.", choices: [ { text: "Always here", to: "open" }, { text: "Only with the flag", to: "secret", when: { op: "exists", path: "player.flag" } }, ], }, { id: "open", text: "Open." }, { id: "secret", text: "Secret." }, ], }); const dialogue = dialogueOf(engine); dialogue.start("test-gate"); expect(dialogue.current()?.choices).toEqual([{ text: "Always here", to: "open" }]); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.flag = "set"; expect(dialogue.current()?.choices).toEqual([ { text: "Always here", to: "open" }, { text: "Only with the flag", to: "secret" }, ]); expect(dialogue.choose(1).nodeId).toBe("secret"); } finally { await engine.stop(); } }); it("when conditions resolve payload.* from the start ctx", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("dialogue", { id: "test-payload", startNode: "greet", nodes: [ { id: "greet", text: "Hi.", choices: [ { text: "Morning", to: "day", when: { op: "eq", path: "payload.partOfDay", value: "morning" } }, { text: "Evening", to: "night" }, ], }, { id: "day", text: "Good morning." }, { id: "night", text: "Good evening." }, ], }); const dialogue = dialogueOf(engine); dialogue.start("test-payload", { payload: { partOfDay: "morning" } }); expect(dialogue.choose(0).nodeId).toBe("day"); } finally { await engine.stop(); } }); }); describe("DialogueSystem persistence", () => { it("the active dialogue entity survives save snapshot/restore", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("dialogue", SIMPLE_DEF); const dialogue = dialogueOf(engine); dialogue.start("test-welcome"); const snapshot = saveOf(engine).snapshot(); expect(snapshot.world.some((e) => e.id === ACTIVE_DIALOGUE_ENTITY_ID && e.nodeId === "hello")).toBe(true); saveOf(engine).restore(snapshot); expect(dialogue.current()).toEqual({ dialogueId: "test-welcome", nodeId: "hello", text: "Hello!", choices: [ { text: "Give me a seed", to: "thanks" }, { text: "No thanks", to: "bye" }, ], }); } finally { await engine.stop(); } }); }); describe("real content dialogue (welcome)", () => { it("drives the seeded welcome dialogue: branches, linear to, terminal end, and the flag-gated returning choice", async () => { const engine = engineSetup(); await engine.start(); try { const dialogue = dialogueOf(engine); const start = dialogue.start("welcome"); expect(start.nodeId).toBe("hello"); expect(start.choices).toEqual([ { text: "Thanks! What should I plant first?", to: "seed-tip" }, { text: "I am just exploring today.", to: "farewell" }, ]); const planted = dialogue.choose(0); expect(planted).toMatchObject({ nodeId: "seed-tip", text: expect.stringContaining("Parsnip") }); expect(planted.choices).toEqual([{ text: "Continue", to: "farewell" }]); expect(inventoryOf(engine).count("parsnip-seed")).toBe(1); const farewell = dialogue.choose(0); expect(farewell).toEqual({ dialogueId: "welcome", nodeId: "farewell", text: "See you around!", choices: [] }); expect(dialogue.current()).toBeNull(); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.flag = "welcomed"; const returning = dialogue.start("welcome"); expect(returning.choices).toEqual([ { text: "Thanks! What should I plant first?", to: "seed-tip" }, { text: "You remember me from last season?", to: "returning" }, { text: "I am just exploring today.", to: "farewell" }, ]); expect(dialogue.choose(1).nodeId).toBe("returning"); } finally { await engine.stop(); } }); it("a condition-gated welcome choice can gate on the time system (time.day path)", async () => { const engine = engineSetup(); await engine.start(); try { engine.registry.register("dialogue", { id: "test-time-gate", startNode: "ask", nodes: [ { id: "ask", text: "What day is it?", choices: [ { text: "Late spring", to: "late", when: { op: "gte", path: "time.day", value: 20 } }, { text: "Early", to: "early" }, ], }, { id: "late", text: "The season is ending soon." }, { id: "early", text: "Plenty of time." }, ], }); const dialogue = dialogueOf(engine); timeOf(engine).set(5, "spring"); dialogue.start("test-time-gate"); expect(dialogue.current()?.choices).toEqual([{ text: "Early", to: "early" }]); timeOf(engine).set(25, "spring"); expect(dialogue.current()?.choices).toEqual([ { text: "Late spring", to: "late" }, { text: "Early", to: "early" }, ]); } finally { await engine.stop(); } }); });