dialogue.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. import { resolve } from "node:path";
  2. import { describe, expect, it } from "vitest";
  3. import {
  4. ACTIVE_DIALOGUE_ENTITY_ID,
  5. createEngine,
  6. dialogueSchema,
  7. type DialogueDef,
  8. type DialogueSystem,
  9. type InventorySystem,
  10. type SaveSystem,
  11. type System,
  12. type TimeSystem,
  13. } from "../src/index.js";
  14. const repoRoot = resolve(process.cwd(), "../..");
  15. const contentDir = resolve(repoRoot, "content");
  16. const modsDir = resolve(repoRoot, "mods");
  17. function engineSetup(spy?: System) {
  18. return createEngine({ contentDir, modsDir, systems: spy ? [spy] : [] });
  19. }
  20. function dialogueOf(engine: ReturnType<typeof createEngine>): DialogueSystem {
  21. const dialogue = engine.systems.get<DialogueSystem>("dialogue");
  22. if (!dialogue) throw new Error("dialogue system not booted");
  23. return dialogue;
  24. }
  25. function inventoryOf(engine: ReturnType<typeof createEngine>): InventorySystem {
  26. const inv = engine.systems.get<InventorySystem>("inventory");
  27. if (!inv) throw new Error("inventory system not booted");
  28. return inv;
  29. }
  30. function saveOf(engine: ReturnType<typeof createEngine>): SaveSystem {
  31. const save = engine.systems.get<SaveSystem>("save");
  32. if (!save) throw new Error("save system not booted");
  33. return save;
  34. }
  35. function timeOf(engine: ReturnType<typeof createEngine>): TimeSystem {
  36. const time = engine.systems.get<TimeSystem>("time");
  37. if (!time) throw new Error("time system not booted");
  38. return time;
  39. }
  40. function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } {
  41. const seen: { event: string; payload: unknown }[] = [];
  42. const system: System = {
  43. id: "spy",
  44. init() {},
  45. on(event, payload) {
  46. seen.push({ event, payload });
  47. },
  48. };
  49. return { system, seen };
  50. }
  51. const SIMPLE_DEF: DialogueDef = {
  52. id: "test-welcome",
  53. startNode: "hello",
  54. nodes: [
  55. {
  56. id: "hello",
  57. text: "Hello!",
  58. choices: [
  59. { text: "Give me a seed", to: "thanks", actions: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }] },
  60. { text: "No thanks", to: "bye" },
  61. ],
  62. },
  63. { id: "thanks", text: "Here you go." },
  64. { id: "bye", text: "Bye!" },
  65. ],
  66. };
  67. describe("dialogueSchema", () => {
  68. it("accepts a valid def and rejects unknown startNode / bad targets / mixed choices+to / empty choices / duplicate ids", () => {
  69. expect(dialogueSchema.safeParse(SIMPLE_DEF).success).toBe(true);
  70. const badStart = dialogueSchema.safeParse({ ...SIMPLE_DEF, startNode: "nope" });
  71. expect(badStart.success).toBe(false);
  72. const badChoiceTo = dialogueSchema.safeParse({
  73. ...SIMPLE_DEF,
  74. nodes: [
  75. {
  76. id: "hello",
  77. text: "Hello!",
  78. choices: [{ text: "Go", to: "ghost" }],
  79. },
  80. ],
  81. });
  82. expect(badChoiceTo.success).toBe(false);
  83. const badNodeTo = dialogueSchema.safeParse({
  84. ...SIMPLE_DEF,
  85. nodes: [
  86. { id: "hello", text: "Hello!", to: "ghost" },
  87. { id: "bye", text: "Bye" },
  88. ],
  89. });
  90. expect(badNodeTo.success).toBe(false);
  91. const mixed = dialogueSchema.safeParse({
  92. ...SIMPLE_DEF,
  93. nodes: [{ id: "hello", text: "Hello!", to: "bye", choices: [{ text: "Go", to: "bye" }] }],
  94. });
  95. expect(mixed.success).toBe(false);
  96. const emptyChoices = dialogueSchema.safeParse({
  97. ...SIMPLE_DEF,
  98. nodes: [{ id: "hello", text: "Hello!", choices: [] }],
  99. });
  100. expect(emptyChoices.success).toBe(false);
  101. const duplicateIds = dialogueSchema.safeParse({
  102. ...SIMPLE_DEF,
  103. nodes: [
  104. { id: "hello", text: "a" },
  105. { id: "hello", text: "b" },
  106. ],
  107. });
  108. expect(duplicateIds.success).toBe(false);
  109. const emptyNodes = dialogueSchema.safeParse({ ...SIMPLE_DEF, nodes: [] });
  110. expect(emptyNodes.success).toBe(false);
  111. });
  112. it("accepts a terminal node and a linear node chain", () => {
  113. const def = dialogueSchema.safeParse({
  114. id: "linear",
  115. startNode: "a",
  116. nodes: [
  117. { id: "a", text: "start", to: "b" },
  118. { id: "b", text: "end" },
  119. ],
  120. });
  121. expect(def.success).toBe(true);
  122. });
  123. });
  124. describe("DialogueSystem.start", () => {
  125. it("returns the start node state, creates the active entity, emits dialogue-start, and fires onDialogueStart", async () => {
  126. const { system: spy, seen } = eventSpy();
  127. const engine = engineSetup(spy);
  128. await engine.start();
  129. try {
  130. engine.registry.register("dialogue", SIMPLE_DEF);
  131. const hookFired: unknown[] = [];
  132. engine.hooks.on("onDialogueStart", (ctx) => {
  133. hookFired.push(ctx.world.get(ACTIVE_DIALOGUE_ENTITY_ID)?.nodeId);
  134. });
  135. const state = dialogueOf(engine).start("test-welcome");
  136. expect(state).toEqual({
  137. dialogueId: "test-welcome",
  138. nodeId: "hello",
  139. text: "Hello!",
  140. choices: [
  141. { text: "Give me a seed", to: "thanks" },
  142. { text: "No thanks", to: "bye" },
  143. ],
  144. });
  145. expect(engine.world.get(ACTIVE_DIALOGUE_ENTITY_ID)).toMatchObject({
  146. type: "dialogue",
  147. dialogueId: "test-welcome",
  148. nodeId: "hello",
  149. });
  150. expect(seen.some((s) => s.event === "dialogue-start")).toBe(true);
  151. expect(seen.find((s) => s.event === "dialogue-start")?.payload).toEqual({
  152. dialogueId: "test-welcome",
  153. nodeId: "hello",
  154. });
  155. expect(hookFired).toEqual(["hello"]);
  156. } finally {
  157. await engine.stop();
  158. }
  159. });
  160. it("runs the start node's actions on enter", async () => {
  161. const engine = engineSetup();
  162. await engine.start();
  163. try {
  164. engine.registry.register("dialogue", {
  165. id: "test-action",
  166. startNode: "gift",
  167. nodes: [
  168. { id: "gift", text: "Have a seed.", actions: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }] },
  169. ],
  170. });
  171. dialogueOf(engine).start("test-action");
  172. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  173. } finally {
  174. await engine.stop();
  175. }
  176. });
  177. it("throws on an unknown dialogue id", async () => {
  178. const engine = engineSetup();
  179. await engine.start();
  180. try {
  181. expect(() => dialogueOf(engine).start("nope")).toThrow(/unknown dialogue: nope/);
  182. } finally {
  183. await engine.stop();
  184. }
  185. });
  186. it("starting a second dialogue replaces the first without emitting dialogue-end", async () => {
  187. const { system: spy, seen } = eventSpy();
  188. const engine = engineSetup(spy);
  189. await engine.start();
  190. try {
  191. engine.registry.register("dialogue", SIMPLE_DEF);
  192. engine.registry.register("dialogue", {
  193. id: "other",
  194. startNode: "a",
  195. nodes: [
  196. { id: "a", text: "other", to: "b" },
  197. { id: "b", text: "end" },
  198. ],
  199. });
  200. const dialogue = dialogueOf(engine);
  201. dialogue.start("test-welcome");
  202. const replaced = dialogue.start("other");
  203. expect(replaced.dialogueId).toBe("other");
  204. expect(engine.world.get(ACTIVE_DIALOGUE_ENTITY_ID)).toMatchObject({ dialogueId: "other" });
  205. expect(seen.some((s) => s.event === "dialogue-end")).toBe(false);
  206. } finally {
  207. await engine.stop();
  208. }
  209. });
  210. });
  211. describe("DialogueSystem.choose", () => {
  212. it("advances through a choice, runs its actions, and updates the entity", async () => {
  213. const { system: spy, seen } = eventSpy();
  214. const engine = engineSetup(spy);
  215. await engine.start();
  216. try {
  217. engine.registry.register("dialogue", {
  218. ...SIMPLE_DEF,
  219. nodes: [
  220. {
  221. id: "hello",
  222. text: "Hello!",
  223. choices: [
  224. { text: "Give me a seed", to: "thanks", actions: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }] },
  225. { text: "No thanks", to: "bye" },
  226. ],
  227. },
  228. { id: "thanks", text: "Here you go.", to: "another" },
  229. { id: "another", text: "Anything else?" },
  230. { id: "bye", text: "Bye!" },
  231. ],
  232. });
  233. const dialogue = dialogueOf(engine);
  234. dialogue.start("test-welcome");
  235. const next = dialogue.choose(0);
  236. expect(next).toEqual({
  237. dialogueId: "test-welcome",
  238. nodeId: "thanks",
  239. text: "Here you go.",
  240. choices: [{ text: "Continue", to: "another" }],
  241. });
  242. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  243. expect(engine.world.get(ACTIVE_DIALOGUE_ENTITY_ID)?.nodeId).toBe("thanks");
  244. expect(seen.some((s) => s.event === "item-acquire")).toBe(true);
  245. } finally {
  246. await engine.stop();
  247. }
  248. });
  249. it("a choice with no actions advances without side effects", async () => {
  250. const engine = engineSetup();
  251. await engine.start();
  252. try {
  253. engine.registry.register("dialogue", SIMPLE_DEF);
  254. const dialogue = dialogueOf(engine);
  255. dialogue.start("test-welcome");
  256. const next = dialogue.choose(1);
  257. expect(next.nodeId).toBe("bye");
  258. expect(inventoryOf(engine).count("parsnip-seed")).toBe(0);
  259. } finally {
  260. await engine.stop();
  261. }
  262. });
  263. it("throws on an out-of-range index or when no dialogue is active", async () => {
  264. const engine = engineSetup();
  265. await engine.start();
  266. try {
  267. engine.registry.register("dialogue", SIMPLE_DEF);
  268. const dialogue = dialogueOf(engine);
  269. dialogue.start("test-welcome");
  270. expect(() => dialogue.choose(2)).toThrow(/choice index out of range: 2/);
  271. expect(() => dialogue.choose(-1)).toThrow(/choice index out of range: -1/);
  272. dialogue.end();
  273. expect(() => dialogue.choose(0)).toThrow(/no active dialogue/);
  274. } finally {
  275. await engine.stop();
  276. }
  277. });
  278. it("a linear to-node presents a synthesized Continue choice", async () => {
  279. const engine = engineSetup();
  280. await engine.start();
  281. try {
  282. engine.registry.register("dialogue", {
  283. id: "test-linear",
  284. startNode: "a",
  285. nodes: [
  286. { id: "a", text: "first", to: "b" },
  287. { id: "b", text: "last" },
  288. ],
  289. });
  290. const dialogue = dialogueOf(engine);
  291. const start = dialogue.start("test-linear");
  292. expect(start).toEqual({
  293. dialogueId: "test-linear",
  294. nodeId: "a",
  295. text: "first",
  296. choices: [{ text: "Continue", to: "b" }],
  297. });
  298. const next = dialogue.choose(0);
  299. expect(next.nodeId).toBe("b");
  300. } finally {
  301. await engine.stop();
  302. }
  303. });
  304. });
  305. describe("DialogueSystem lifecycle (terminal nodes)", () => {
  306. it("entering a terminal node ends the dialogue, emits dialogue-end, and fires onDialogueEnd", async () => {
  307. const { system: spy, seen } = eventSpy();
  308. const engine = engineSetup(spy);
  309. await engine.start();
  310. try {
  311. engine.registry.register("dialogue", SIMPLE_DEF);
  312. const hookFired: unknown[] = [];
  313. engine.hooks.on("onDialogueEnd", (ctx) => {
  314. hookFired.push(ctx.world.get(ACTIVE_DIALOGUE_ENTITY_ID) === undefined);
  315. });
  316. const dialogue = dialogueOf(engine);
  317. dialogue.start("test-welcome");
  318. const final = dialogue.choose(0);
  319. expect(final).toEqual({
  320. dialogueId: "test-welcome",
  321. nodeId: "thanks",
  322. text: "Here you go.",
  323. choices: [],
  324. });
  325. expect(engine.world.get(ACTIVE_DIALOGUE_ENTITY_ID)).toBeUndefined();
  326. expect(dialogue.current()).toBeNull();
  327. expect(seen.some((s) => s.event === "dialogue-end")).toBe(true);
  328. expect(seen.find((s) => s.event === "dialogue-end")?.payload).toEqual({ dialogueId: "test-welcome" });
  329. expect(hookFired).toEqual([true]);
  330. } finally {
  331. await engine.stop();
  332. }
  333. });
  334. it("a choices node whose conditions all fail is treated as terminal", async () => {
  335. const engine = engineSetup();
  336. await engine.start();
  337. try {
  338. engine.registry.register("dialogue", {
  339. id: "test-gated",
  340. startNode: "door",
  341. nodes: [
  342. {
  343. id: "door",
  344. text: "Knock knock.",
  345. choices: [
  346. { text: "Come in", to: "inside", when: { op: "exists", path: "player.flag" } },
  347. ],
  348. },
  349. { id: "inside", text: "Welcome." },
  350. ],
  351. });
  352. const dialogue = dialogueOf(engine);
  353. const state = dialogue.start("test-gated");
  354. expect(state.choices).toEqual([]);
  355. expect(engine.world.get(ACTIVE_DIALOGUE_ENTITY_ID)).toBeUndefined();
  356. expect(dialogue.current()).toBeNull();
  357. } finally {
  358. await engine.stop();
  359. }
  360. });
  361. });
  362. describe("DialogueSystem conditions", () => {
  363. it("gated choices are hidden from current() and choose indexes into the available list", async () => {
  364. const engine = engineSetup();
  365. await engine.start();
  366. try {
  367. engine.registry.register("dialogue", {
  368. id: "test-gate",
  369. startNode: "ask",
  370. nodes: [
  371. {
  372. id: "ask",
  373. text: "Ask me.",
  374. choices: [
  375. { text: "Always here", to: "open" },
  376. { text: "Only with the flag", to: "secret", when: { op: "exists", path: "player.flag" } },
  377. ],
  378. },
  379. { id: "open", text: "Open." },
  380. { id: "secret", text: "Secret." },
  381. ],
  382. });
  383. const dialogue = dialogueOf(engine);
  384. dialogue.start("test-gate");
  385. expect(dialogue.current()?.choices).toEqual([{ text: "Always here", to: "open" }]);
  386. const player = engine.world.get("player");
  387. if (!player) throw new Error("player entity not seeded");
  388. player.flag = "set";
  389. expect(dialogue.current()?.choices).toEqual([
  390. { text: "Always here", to: "open" },
  391. { text: "Only with the flag", to: "secret" },
  392. ]);
  393. expect(dialogue.choose(1).nodeId).toBe("secret");
  394. } finally {
  395. await engine.stop();
  396. }
  397. });
  398. it("when conditions resolve payload.* from the start ctx", async () => {
  399. const engine = engineSetup();
  400. await engine.start();
  401. try {
  402. engine.registry.register("dialogue", {
  403. id: "test-payload",
  404. startNode: "greet",
  405. nodes: [
  406. {
  407. id: "greet",
  408. text: "Hi.",
  409. choices: [
  410. { text: "Morning", to: "day", when: { op: "eq", path: "payload.partOfDay", value: "morning" } },
  411. { text: "Evening", to: "night" },
  412. ],
  413. },
  414. { id: "day", text: "Good morning." },
  415. { id: "night", text: "Good evening." },
  416. ],
  417. });
  418. const dialogue = dialogueOf(engine);
  419. dialogue.start("test-payload", { payload: { partOfDay: "morning" } });
  420. expect(dialogue.choose(0).nodeId).toBe("day");
  421. } finally {
  422. await engine.stop();
  423. }
  424. });
  425. });
  426. describe("DialogueSystem persistence", () => {
  427. it("the active dialogue entity survives save snapshot/restore", async () => {
  428. const engine = engineSetup();
  429. await engine.start();
  430. try {
  431. engine.registry.register("dialogue", SIMPLE_DEF);
  432. const dialogue = dialogueOf(engine);
  433. dialogue.start("test-welcome");
  434. const snapshot = saveOf(engine).snapshot();
  435. expect(snapshot.world.some((e) => e.id === ACTIVE_DIALOGUE_ENTITY_ID && e.nodeId === "hello")).toBe(true);
  436. saveOf(engine).restore(snapshot);
  437. expect(dialogue.current()).toEqual({
  438. dialogueId: "test-welcome",
  439. nodeId: "hello",
  440. text: "Hello!",
  441. choices: [
  442. { text: "Give me a seed", to: "thanks" },
  443. { text: "No thanks", to: "bye" },
  444. ],
  445. });
  446. } finally {
  447. await engine.stop();
  448. }
  449. });
  450. });
  451. describe("real content dialogue (welcome)", () => {
  452. it("drives the seeded welcome dialogue: branches, linear to, terminal end, and the flag-gated returning choice", async () => {
  453. const engine = engineSetup();
  454. await engine.start();
  455. try {
  456. const dialogue = dialogueOf(engine);
  457. const start = dialogue.start("welcome");
  458. expect(start.nodeId).toBe("hello");
  459. expect(start.choices).toEqual([
  460. { text: "Thanks! What should I plant first?", to: "seed-tip" },
  461. { text: "I am just exploring today.", to: "farewell" },
  462. ]);
  463. const planted = dialogue.choose(0);
  464. expect(planted).toMatchObject({ nodeId: "seed-tip", text: expect.stringContaining("Parsnip") });
  465. expect(planted.choices).toEqual([{ text: "Continue", to: "farewell" }]);
  466. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  467. const farewell = dialogue.choose(0);
  468. expect(farewell).toEqual({ dialogueId: "welcome", nodeId: "farewell", text: "See you around!", choices: [] });
  469. expect(dialogue.current()).toBeNull();
  470. const player = engine.world.get("player");
  471. if (!player) throw new Error("player entity not seeded");
  472. player.flag = "welcomed";
  473. const returning = dialogue.start("welcome");
  474. expect(returning.choices).toEqual([
  475. { text: "Thanks! What should I plant first?", to: "seed-tip" },
  476. { text: "You remember me from last season?", to: "returning" },
  477. { text: "I am just exploring today.", to: "farewell" },
  478. ]);
  479. expect(dialogue.choose(1).nodeId).toBe("returning");
  480. } finally {
  481. await engine.stop();
  482. }
  483. });
  484. it("a condition-gated welcome choice can gate on the time system (time.day path)", async () => {
  485. const engine = engineSetup();
  486. await engine.start();
  487. try {
  488. engine.registry.register("dialogue", {
  489. id: "test-time-gate",
  490. startNode: "ask",
  491. nodes: [
  492. {
  493. id: "ask",
  494. text: "What day is it?",
  495. choices: [
  496. { text: "Late spring", to: "late", when: { op: "gte", path: "time.day", value: 20 } },
  497. { text: "Early", to: "early" },
  498. ],
  499. },
  500. { id: "late", text: "The season is ending soon." },
  501. { id: "early", text: "Plenty of time." },
  502. ],
  503. });
  504. const dialogue = dialogueOf(engine);
  505. timeOf(engine).set(5, "spring");
  506. dialogue.start("test-time-gate");
  507. expect(dialogue.current()?.choices).toEqual([{ text: "Early", to: "early" }]);
  508. timeOf(engine).set(25, "spring");
  509. expect(dialogue.current()?.choices).toEqual([
  510. { text: "Late spring", to: "late" },
  511. { text: "Early", to: "early" },
  512. ]);
  513. } finally {
  514. await engine.stop();
  515. }
  516. });
  517. });