| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559 |
- 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<typeof createEngine>): DialogueSystem {
- const dialogue = engine.systems.get<DialogueSystem>("dialogue");
- if (!dialogue) throw new Error("dialogue system not booted");
- return dialogue;
- }
- 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 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 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();
- }
- });
- });
|