mud/tests/test_dialogue.py
Jared Miller 5d61008dc1
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.
2026-02-14 14:31:39 -05:00

269 lines
6.6 KiB
Python

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