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.
This commit is contained in:
Jared Miller 2026-02-14 12:50:44 -05:00
parent 369bc5efcb
commit 5d61008dc1
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 420 additions and 0 deletions

View file

@ -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."

102
src/mudlib/dialogue.py Normal file
View file

@ -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

269
tests/test_dialogue.py Normal file
View file

@ -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()