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.
269 lines
6.6 KiB
Python
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()
|