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:
parent
369bc5efcb
commit
5d61008dc1
3 changed files with 420 additions and 0 deletions
49
content/dialogue/librarian.toml
Normal file
49
content/dialogue/librarian.toml
Normal 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
102
src/mudlib/dialogue.py
Normal 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
269
tests/test_dialogue.py
Normal 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()
|
||||
Loading…
Reference in a new issue