mud/tests/test_talk.py
Jared Miller 67a0290ede
Add talk and reply commands with conversation system
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.
2026-02-14 14:31:39 -05:00

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