From 5d61008dc1fe7ca01b7aea650f794566aa8703dc Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 12:50:44 -0500 Subject: [PATCH] Add dialogue tree data model with tests Implements a TOML-based dialogue tree system for NPCs with: - DialogueChoice: player response options with optional conditions - DialogueNode: NPC text with choices and optional actions - DialogueTree: complete tree with root node and node graph - Validation for root_node and next_node references - load_dialogue() for single files, load_all_dialogues() for directories Includes librarian dialogue example with nested conversation flow. --- content/dialogue/librarian.toml | 49 ++++++ src/mudlib/dialogue.py | 102 ++++++++++++ tests/test_dialogue.py | 269 ++++++++++++++++++++++++++++++++ 3 files changed, 420 insertions(+) create mode 100644 content/dialogue/librarian.toml create mode 100644 src/mudlib/dialogue.py create mode 100644 tests/test_dialogue.py diff --git a/content/dialogue/librarian.toml b/content/dialogue/librarian.toml new file mode 100644 index 0000000..12d013e --- /dev/null +++ b/content/dialogue/librarian.toml @@ -0,0 +1,49 @@ +npc_name = "librarian" +root_node = "greeting" + +[nodes.greeting] +text = "Welcome to the library. How can I help you today?" + +[[nodes.greeting.choices]] +text = "I'm looking for a book." +next_node = "book_search" + +[[nodes.greeting.choices]] +text = "Tell me about this place." +next_node = "about_library" + +[[nodes.greeting.choices]] +text = "Goodbye." +next_node = "farewell" + +[nodes.book_search] +text = "We have quite the collection. Fairy tales, mostly. Browse the shelves — you might find something that catches your eye." + +[[nodes.book_search.choices]] +text = "Any recommendations?" +next_node = "recommendations" + +[[nodes.book_search.choices]] +text = "Thanks, I'll look around." +next_node = "farewell" + +[nodes.recommendations] +text = "The Brothers Grimm collected some wonderful tales. 'The Golden Bird' is a personal favorite — a story about patience and trust." + +[[nodes.recommendations.choices]] +text = "I'll keep an eye out for it." +next_node = "farewell" + +[[nodes.recommendations.choices]] +text = "Tell me about this place instead." +next_node = "about_library" + +[nodes.about_library] +text = "This library was built to preserve the old stories. Every book here was carefully transcribed. Take your time — the stories aren't going anywhere." + +[[nodes.about_library.choices]] +text = "Thanks for the information." +next_node = "farewell" + +[nodes.farewell] +text = "Happy reading. Come back anytime." diff --git a/src/mudlib/dialogue.py b/src/mudlib/dialogue.py new file mode 100644 index 0000000..02a5a60 --- /dev/null +++ b/src/mudlib/dialogue.py @@ -0,0 +1,102 @@ +"""Dialogue tree data model and TOML loading.""" + +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class DialogueChoice: + """A player response option in a dialogue.""" + + text: str + next_node: str + condition: str | None = None + + +@dataclass +class DialogueNode: + """A single node in a dialogue tree.""" + + id: str + text: str + choices: list[DialogueChoice] = field(default_factory=list) + action: str | None = None + + +@dataclass +class DialogueTree: + """A complete dialogue tree for an NPC.""" + + npc_name: str + root_node: str + nodes: dict[str, DialogueNode] = field(default_factory=dict) + + +def load_dialogue(path: Path) -> DialogueTree: + """Load a dialogue tree from a TOML file. + + Args: + path: Path to the TOML file + + Returns: + DialogueTree instance + + Raises: + ValueError: If root_node or any next_node reference doesn't exist + """ + with open(path, "rb") as f: + data = tomllib.load(f) + + npc_name = data["npc_name"] + root_node = data["root_node"] + nodes: dict[str, DialogueNode] = {} + + # Parse all nodes + for node_id, node_data in data.get("nodes", {}).items(): + choices = [] + for choice_data in node_data.get("choices", []): + choices.append( + DialogueChoice( + text=choice_data["text"], + next_node=choice_data["next_node"], + condition=choice_data.get("condition"), + ) + ) + + nodes[node_id] = DialogueNode( + id=node_id, + text=node_data["text"], + choices=choices, + action=node_data.get("action"), + ) + + # Validate root_node exists + if root_node not in nodes: + msg = f"root_node '{root_node}' not found in nodes" + raise ValueError(msg) + + # Validate all next_node references exist + for node in nodes.values(): + for choice in node.choices: + if choice.next_node not in nodes: + msg = f"next_node '{choice.next_node}' not found in nodes" + raise ValueError(msg) + + return DialogueTree(npc_name=npc_name, root_node=root_node, nodes=nodes) + + +def load_all_dialogues(directory: Path) -> dict[str, DialogueTree]: + """Load all dialogue trees from TOML files in a directory. + + Args: + directory: Path to directory containing .toml files + + Returns: + Dict mapping npc_name to DialogueTree instances + """ + trees: dict[str, DialogueTree] = {} + for path in sorted(directory.glob("*.toml")): + tree = load_dialogue(path) + trees[tree.npc_name] = tree + return trees diff --git a/tests/test_dialogue.py b/tests/test_dialogue.py new file mode 100644 index 0000000..124c03e --- /dev/null +++ b/tests/test_dialogue.py @@ -0,0 +1,269 @@ +"""Tests for dialogue tree data model and TOML loading.""" + +import tempfile +from pathlib import Path + +import pytest + +from mudlib.dialogue import ( + load_all_dialogues, + load_dialogue, +) + + +def test_load_dialogue_from_toml(): + """Load a dialogue tree from TOML with correct structure.""" + toml_content = """ +npc_name = "guard" +root_node = "greeting" + +[nodes.greeting] +text = "Halt! State your business." + +[[nodes.greeting.choices]] +text = "I'm just passing through." +next_node = "passing" + +[[nodes.greeting.choices]] +text = "I have a delivery." +next_node = "delivery" + +[nodes.passing] +text = "Move along then, quickly." + +[nodes.delivery] +text = "Leave it by the gate." +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + f.flush() + path = Path(f.name) + + try: + tree = load_dialogue(path) + assert tree.npc_name == "guard" + assert tree.root_node == "greeting" + assert len(tree.nodes) == 3 + + # Check greeting node + greeting = tree.nodes["greeting"] + assert greeting.id == "greeting" + assert greeting.text == "Halt! State your business." + assert len(greeting.choices) == 2 + assert greeting.choices[0].text == "I'm just passing through." + assert greeting.choices[0].next_node == "passing" + assert greeting.choices[1].text == "I have a delivery." + assert greeting.choices[1].next_node == "delivery" + + # Check terminal nodes + passing = tree.nodes["passing"] + assert passing.id == "passing" + assert passing.text == "Move along then, quickly." + assert len(passing.choices) == 0 + + delivery = tree.nodes["delivery"] + assert delivery.id == "delivery" + assert delivery.text == "Leave it by the gate." + assert len(delivery.choices) == 0 + finally: + path.unlink() + + +def test_node_traversal(): + """Follow choices from root to next nodes.""" + toml_content = """ +npc_name = "merchant" +root_node = "greeting" + +[nodes.greeting] +text = "Welcome to my shop!" + +[[nodes.greeting.choices]] +text = "What do you sell?" +next_node = "wares" + +[nodes.wares] +text = "Potions and scrolls, finest in town." +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + f.flush() + path = Path(f.name) + + try: + tree = load_dialogue(path) + current = tree.nodes[tree.root_node] + assert current.text == "Welcome to my shop!" + + # Follow the first choice + next_id = current.choices[0].next_node + current = tree.nodes[next_id] + assert current.text == "Potions and scrolls, finest in town." + finally: + path.unlink() + + +def test_terminal_node_ends_conversation(): + """Nodes with no choices are terminal.""" + toml_content = """ +npc_name = "beggar" +root_node = "plea" + +[nodes.plea] +text = "Spare a coin?" + +[[nodes.plea.choices]] +text = "Here you go." +next_node = "thanks" + +[nodes.thanks] +text = "Bless you, kind soul." +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + f.flush() + path = Path(f.name) + + try: + tree = load_dialogue(path) + thanks = tree.nodes["thanks"] + assert len(thanks.choices) == 0 # Terminal node + finally: + path.unlink() + + +def test_choice_with_condition(): + """Choices can have optional conditions.""" + toml_content = """ +npc_name = "gatekeeper" +root_node = "greeting" + +[nodes.greeting] +text = "The gate is locked." + +[[nodes.greeting.choices]] +text = "I have the key." +next_node = "unlock" +condition = "has_item:gate_key" + +[nodes.unlock] +text = "Very well, pass through." +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + f.flush() + path = Path(f.name) + + try: + tree = load_dialogue(path) + greeting = tree.nodes["greeting"] + choice = greeting.choices[0] + assert choice.condition == "has_item:gate_key" + assert choice.next_node == "unlock" + finally: + path.unlink() + + +def test_node_with_action(): + """Nodes can have optional actions.""" + toml_content = """ +npc_name = "quest_giver" +root_node = "offer" + +[nodes.offer] +text = "Will you help me find my lost cat?" + +[[nodes.offer.choices]] +text = "I'll do it." +next_node = "accept" + +[nodes.accept] +text = "Thank you! Here's a map." +action = "give_item:cat_map" +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + f.flush() + path = Path(f.name) + + try: + tree = load_dialogue(path) + accept = tree.nodes["accept"] + assert accept.action == "give_item:cat_map" + finally: + path.unlink() + + +def test_load_all_dialogues(): + """Load multiple dialogue files from a directory.""" + guard_content = """ +npc_name = "guard" +root_node = "greeting" + +[nodes.greeting] +text = "Halt!" +""" + merchant_content = """ +npc_name = "merchant" +root_node = "greeting" + +[nodes.greeting] +text = "Welcome!" +""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + (tmppath / "guard.toml").write_text(guard_content) + (tmppath / "merchant.toml").write_text(merchant_content) + + trees = load_all_dialogues(tmppath) + assert len(trees) == 2 + assert "guard" in trees + assert "merchant" in trees + assert trees["guard"].npc_name == "guard" + assert trees["merchant"].npc_name == "merchant" + + +def test_missing_root_node(): + """Raise error if root_node doesn't exist in nodes.""" + toml_content = """ +npc_name = "broken" +root_node = "nonexistent" + +[nodes.greeting] +text = "Hello." +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + f.flush() + path = Path(f.name) + + try: + with pytest.raises(ValueError, match="root_node.*not found"): + load_dialogue(path) + finally: + path.unlink() + + +def test_missing_next_node(): + """Raise error if a choice references a non-existent node.""" + toml_content = """ +npc_name = "broken" +root_node = "greeting" + +[nodes.greeting] +text = "Hello." + +[[nodes.greeting.choices]] +text = "Goodbye." +next_node = "nonexistent" +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + f.flush() + path = Path(f.name) + + try: + with pytest.raises(ValueError, match="next_node.*not found"): + load_dialogue(path) + finally: + path.unlink()