Implements player-NPC dialogue using the dialogue tree data model. Conversation state tracking manages active conversations and transitions NPCs to "converse" behavior state during dialogue. Commands support terminal node cleanup and display formatting with numbered choices.
466 lines
13 KiB
Python
466 lines
13 KiB
Python
"""Tests for talk command and conversation system."""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from mudlib.commands.talk import cmd_reply, cmd_talk, dialogue_trees
|
|
from mudlib.conversation import (
|
|
active_conversations,
|
|
advance_conversation,
|
|
end_conversation,
|
|
get_conversation,
|
|
start_conversation,
|
|
)
|
|
from mudlib.dialogue import load_dialogue
|
|
from mudlib.entity import Mob
|
|
from mudlib.player import Player, players
|
|
from mudlib.zone import Zone
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_state():
|
|
"""Clear global state before and after each test."""
|
|
players.clear()
|
|
active_conversations.clear()
|
|
dialogue_trees.clear()
|
|
yield
|
|
players.clear()
|
|
active_conversations.clear()
|
|
dialogue_trees.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def test_zone():
|
|
"""Create a test zone for players and mobs."""
|
|
terrain = [["." for _ in range(256)] for _ in range(256)]
|
|
zone = Zone(
|
|
name="testzone",
|
|
width=256,
|
|
height=256,
|
|
toroidal=True,
|
|
terrain=terrain,
|
|
impassable=set(),
|
|
)
|
|
return zone
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_writer():
|
|
writer = MagicMock()
|
|
writer.write = MagicMock()
|
|
writer.drain = AsyncMock()
|
|
return writer
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_reader():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def player(mock_reader, mock_writer, test_zone):
|
|
p = Player(name="Hero", x=10, y=10, reader=mock_reader, writer=mock_writer)
|
|
p.location = test_zone
|
|
test_zone._contents.append(p)
|
|
players[p.name] = p
|
|
return p
|
|
|
|
|
|
@pytest.fixture
|
|
def librarian_tree():
|
|
"""Create a simple dialogue tree for testing."""
|
|
toml_content = """
|
|
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 = "books"
|
|
|
|
[[nodes.greeting.choices]]
|
|
text = "Tell me about this place."
|
|
next_node = "about"
|
|
|
|
[[nodes.greeting.choices]]
|
|
text = "Goodbye."
|
|
next_node = "farewell"
|
|
|
|
[nodes.books]
|
|
text = "We have many books. What genre interests you?"
|
|
|
|
[[nodes.books.choices]]
|
|
text = "History."
|
|
next_node = "history"
|
|
|
|
[[nodes.books.choices]]
|
|
text = "Never mind."
|
|
next_node = "farewell"
|
|
|
|
[nodes.history]
|
|
text = "The history section is on the second floor."
|
|
|
|
[nodes.about]
|
|
text = "This library has been here for 200 years."
|
|
|
|
[nodes.farewell]
|
|
text = "Happy reading. Come back anytime."
|
|
"""
|
|
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)
|
|
return tree
|
|
finally:
|
|
path.unlink()
|
|
|
|
|
|
@pytest.fixture
|
|
def librarian_mob(test_zone, librarian_tree):
|
|
"""Create a librarian NPC with dialogue."""
|
|
mob = Mob(name="librarian", x=10, y=10, npc_name="librarian")
|
|
mob.location = test_zone
|
|
test_zone._contents.append(mob)
|
|
return mob
|
|
|
|
|
|
# Conversation state management tests
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_conversation_creates_state(player, librarian_mob, librarian_tree):
|
|
"""start_conversation creates ConversationState and transitions mob."""
|
|
from mudlib.conversation import active_conversations
|
|
|
|
node = start_conversation(player, librarian_mob, librarian_tree)
|
|
|
|
assert node.id == "greeting"
|
|
assert player.name in active_conversations
|
|
conv = active_conversations[player.name]
|
|
assert conv.tree == librarian_tree
|
|
assert conv.current_node == "greeting"
|
|
assert conv.npc == librarian_mob
|
|
assert librarian_mob.behavior_state == "converse"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_advance_conversation_follows_choice(
|
|
player, librarian_mob, librarian_tree
|
|
):
|
|
"""advance_conversation moves to next node based on choice."""
|
|
|
|
start_conversation(player, librarian_mob, librarian_tree)
|
|
|
|
# Choose option 1: "I'm looking for a book."
|
|
node = advance_conversation(player, 1)
|
|
|
|
assert node is not None
|
|
assert node.id == "books"
|
|
assert node.text == "We have many books. What genre interests you?"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_advance_conversation_terminal_node_ends(
|
|
player, librarian_mob, librarian_tree
|
|
):
|
|
"""advance_conversation returns node but doesn't auto-end conversation."""
|
|
from mudlib.conversation import active_conversations
|
|
|
|
start_conversation(player, librarian_mob, librarian_tree)
|
|
|
|
# Choose "Goodbye." (option 3)
|
|
node = advance_conversation(player, 3)
|
|
|
|
# Should return the terminal node
|
|
assert node is not None
|
|
assert len(node.choices) == 0
|
|
|
|
# Conversation state is updated but still active
|
|
# (command layer is responsible for ending it)
|
|
assert player.name in active_conversations
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_conversation_cleans_up(player, librarian_mob, librarian_tree):
|
|
"""end_conversation removes state and transitions mob back."""
|
|
from mudlib.conversation import active_conversations
|
|
from mudlib.npc_behavior import transition_state
|
|
|
|
# Set mob to idle first
|
|
transition_state(librarian_mob, "idle")
|
|
|
|
start_conversation(player, librarian_mob, librarian_tree)
|
|
|
|
end_conversation(player)
|
|
|
|
assert player.name not in active_conversations
|
|
assert librarian_mob.behavior_state == "idle"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_conversation_returns_state(player, librarian_mob, librarian_tree):
|
|
"""get_conversation returns active conversation or None."""
|
|
|
|
assert get_conversation(player) is None
|
|
|
|
start_conversation(player, librarian_mob, librarian_tree)
|
|
|
|
conv = get_conversation(player)
|
|
assert conv is not None
|
|
assert conv.tree == librarian_tree
|
|
assert conv.npc == librarian_mob
|
|
|
|
|
|
# Talk command tests
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_talk_starts_conversation(player, librarian_mob, librarian_tree):
|
|
"""talk <npc> starts conversation and shows root node."""
|
|
from mudlib.commands.talk import dialogue_trees
|
|
|
|
dialogue_trees["librarian"] = librarian_tree
|
|
|
|
await cmd_talk(player, "librarian")
|
|
|
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
|
full_output = "".join(messages)
|
|
|
|
assert "Welcome to the library" in full_output
|
|
assert "1." in full_output
|
|
assert "I'm looking for a book" in full_output
|
|
assert "2." in full_output
|
|
assert "Tell me about this place" in full_output
|
|
assert "3." in full_output
|
|
assert "Goodbye" in full_output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_talk_no_npc_nearby(player):
|
|
"""talk with no matching NPC shows error."""
|
|
|
|
await cmd_talk(player, "librarian")
|
|
|
|
player.writer.write.assert_called_once()
|
|
output = player.writer.write.call_args[0][0]
|
|
assert "don't see" in output.lower() or "not here" in output.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_talk_non_npc_mob(player, test_zone, mock_reader, mock_writer):
|
|
"""talk with non-NPC mob (no npc_name) shows error."""
|
|
|
|
# Create a mob without npc_name
|
|
mob = Mob(name="rat", x=10, y=10)
|
|
mob.location = test_zone
|
|
test_zone._contents.append(mob)
|
|
|
|
await cmd_talk(player, "rat")
|
|
|
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
assert "can't talk" in output.lower() or "no dialogue" in output.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_talk_no_dialogue_tree(player, test_zone):
|
|
"""talk with NPC that has no dialogue tree shows error."""
|
|
from mudlib.commands.talk import dialogue_trees
|
|
|
|
dialogue_trees.clear()
|
|
|
|
mob = Mob(name="guard", x=10, y=10, npc_name="guard")
|
|
mob.location = test_zone
|
|
test_zone._contents.append(mob)
|
|
|
|
await cmd_talk(player, "guard")
|
|
|
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
assert "nothing to say" in output.lower() or "don't have anything" in output.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_talk_already_in_conversation_shows_current(
|
|
player, librarian_mob, librarian_tree
|
|
):
|
|
"""talk when already in conversation shows current node."""
|
|
from mudlib.commands.talk import dialogue_trees
|
|
from mudlib.conversation import advance_conversation, start_conversation
|
|
|
|
dialogue_trees["librarian"] = librarian_tree
|
|
|
|
# Start conversation
|
|
start_conversation(player, librarian_mob, librarian_tree)
|
|
|
|
# Advance to a different node
|
|
advance_conversation(player, 1) # books node
|
|
|
|
# Clear writer calls
|
|
player.writer.write.reset_mock()
|
|
|
|
# Talk again
|
|
await cmd_talk(player, "librarian")
|
|
|
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
assert "We have many books" in output
|
|
|
|
|
|
# Reply command tests
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_advances_conversation(player, librarian_mob, librarian_tree):
|
|
"""reply <number> advances to next node."""
|
|
from mudlib.commands.talk import dialogue_trees
|
|
|
|
dialogue_trees["librarian"] = librarian_tree
|
|
|
|
# Start conversation
|
|
await cmd_talk(player, "librarian")
|
|
|
|
player.writer.write.reset_mock()
|
|
|
|
# Reply with choice 1
|
|
await cmd_reply(player, "1")
|
|
|
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
assert "We have many books" in output
|
|
assert "1." in output
|
|
assert "History" in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_terminal_node_ends_conversation(
|
|
player, librarian_mob, librarian_tree
|
|
):
|
|
"""reply that reaches terminal node ends conversation."""
|
|
from mudlib.commands.talk import dialogue_trees
|
|
from mudlib.conversation import active_conversations
|
|
|
|
dialogue_trees["librarian"] = librarian_tree
|
|
|
|
# Start conversation
|
|
await cmd_talk(player, "librarian")
|
|
|
|
# Reset mock to only capture reply output
|
|
player.writer.write.reset_mock()
|
|
|
|
# Choose "Goodbye." (option 3)
|
|
await cmd_reply(player, "3")
|
|
|
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
assert "Happy reading" in output
|
|
|
|
# No numbered choices should be shown for terminal node
|
|
lines = output.split("\n")
|
|
choice_lines = [line for line in lines if line.strip().startswith("1.")]
|
|
assert len(choice_lines) == 0
|
|
|
|
# Conversation should be ended
|
|
assert player.name not in active_conversations
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_no_conversation(player):
|
|
"""reply when not in conversation shows error."""
|
|
|
|
await cmd_reply(player, "1")
|
|
|
|
output = player.writer.write.call_args[0][0]
|
|
assert (
|
|
"not in a conversation" in output.lower() or "no conversation" in output.lower()
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_invalid_choice_number(player, librarian_mob, librarian_tree):
|
|
"""reply with invalid choice number shows error."""
|
|
from mudlib.commands.talk import dialogue_trees
|
|
|
|
dialogue_trees["librarian"] = librarian_tree
|
|
|
|
await cmd_talk(player, "librarian")
|
|
|
|
player.writer.write.reset_mock()
|
|
|
|
# Reply with out-of-range choice
|
|
await cmd_reply(player, "99")
|
|
|
|
output = player.writer.write.call_args[0][0]
|
|
assert "invalid" in output.lower() or "choose" in output.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_non_numeric(player, librarian_mob, librarian_tree):
|
|
"""reply with non-numeric argument shows error."""
|
|
from mudlib.commands.talk import dialogue_trees
|
|
|
|
dialogue_trees["librarian"] = librarian_tree
|
|
|
|
await cmd_talk(player, "librarian")
|
|
|
|
player.writer.write.reset_mock()
|
|
|
|
# Reply with non-number
|
|
await cmd_reply(player, "abc")
|
|
|
|
output = player.writer.write.call_args[0][0]
|
|
assert "number" in output.lower() or "invalid" in output.lower()
|
|
|
|
|
|
# Display format tests
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dialogue_display_format(player, librarian_mob, librarian_tree):
|
|
"""Dialogue is displayed with correct format."""
|
|
from mudlib.commands.talk import dialogue_trees
|
|
|
|
dialogue_trees["librarian"] = librarian_tree
|
|
|
|
await cmd_talk(player, "librarian")
|
|
|
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
|
|
# Check for NPC name and "says"
|
|
assert "librarian says" in output.lower()
|
|
|
|
# Check for numbered list
|
|
assert "1." in output
|
|
assert "2." in output
|
|
assert "3." in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_node_no_choices(player, librarian_mob, librarian_tree):
|
|
"""Terminal nodes show text without choices."""
|
|
from mudlib.commands.talk import dialogue_trees
|
|
|
|
dialogue_trees["librarian"] = librarian_tree
|
|
|
|
await cmd_talk(player, "librarian")
|
|
|
|
# Reset mock to only capture reply output
|
|
player.writer.write.reset_mock()
|
|
|
|
# Advance to farewell (terminal node)
|
|
await cmd_reply(player, "3")
|
|
|
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
|
|
assert "Happy reading" in output
|
|
# Should not have numbered choices
|
|
lines = output.split("\n")
|
|
numbered = [
|
|
line
|
|
for line in lines
|
|
if line.strip() and line.strip()[0].isdigit() and "." in line.split()[0]
|
|
]
|
|
assert len(numbered) == 0
|