diff --git a/src/mudlib/commands/talk.py b/src/mudlib/commands/talk.py new file mode 100644 index 0000000..c5f3e66 --- /dev/null +++ b/src/mudlib/commands/talk.py @@ -0,0 +1,126 @@ +"""Talk and reply commands for NPC conversations.""" + +from mudlib.commands import CommandDefinition, register +from mudlib.conversation import ( + advance_conversation, + end_conversation, + get_conversation, + start_conversation, +) +from mudlib.dialogue import DialogueNode, DialogueTree +from mudlib.entity import Mob +from mudlib.player import Player +from mudlib.targeting import find_entity_on_tile + +# Global registry of dialogue trees (keyed by npc_name) +dialogue_trees: dict[str, DialogueTree] = {} + + +def _format_dialogue_node(npc: Mob, node: DialogueNode) -> str: + """Format a dialogue node for display. + + Args: + npc: The NPC speaking + node: The dialogue node to format + + Returns: + Formatted text with NPC name, text, and numbered choices + """ + lines = [] + + # NPC says line + lines.append(f'{npc.name.capitalize()} says, "{node.text}"') + lines.append("") + + # Numbered choices (if any) + if node.choices: + for i, choice in enumerate(node.choices, 1): + lines.append(f" {i}. {choice.text}") + + return "\r\n".join(lines) + "\r\n" + + +async def cmd_talk(player: Player, args: str) -> None: + """Start or continue a conversation with an NPC. + + Usage: talk + """ + npc_name = args.strip().lower() + if not npc_name: + await player.send("Talk to whom?\r\n") + return + + # Check if already in conversation + conv = get_conversation(player) + if conv: + # Show current node + current_node = conv.tree.nodes[conv.current_node] + output = _format_dialogue_node(conv.npc, current_node) + await player.send(output) + return + + # Find the NPC in the same room + target = find_entity_on_tile(npc_name, player, z_filter=False) + if target is None: + await player.send("You don't see that person here.\r\n") + return + + # Check it's a Mob with npc_name set + if not isinstance(target, Mob) or target.npc_name is None: + await player.send("You can't talk to that.\r\n") + return + + # Check if dialogue tree exists for this NPC + tree = dialogue_trees.get(target.npc_name) + if tree is None: + await player.send("They don't have anything to say right now.\r\n") + return + + # Start the conversation + root_node = start_conversation(player, target, tree) + + # Display the root node + output = _format_dialogue_node(target, root_node) + await player.send(output) + + +async def cmd_reply(player: Player, args: str) -> None: + """Reply to an NPC in an active conversation. + + Usage: reply + """ + choice_str = args.strip() + if not choice_str: + await player.send("Reply with which choice number?\r\n") + return + + # Check if in a conversation + conv = get_conversation(player) + if conv is None: + await player.send("You're not in a conversation.\r\n") + return + + # Parse choice number + try: + choice_num = int(choice_str) + except ValueError: + await player.send("Please enter a number.\r\n") + return + + # Advance conversation + next_node = advance_conversation(player, choice_num) + if next_node is None: + await player.send("Invalid choice.\r\n") + return + + # Display the next node + output = _format_dialogue_node(conv.npc, next_node) + await player.send(output) + + # End conversation if this is a terminal node + if len(next_node.choices) == 0: + end_conversation(player) + + +register(CommandDefinition("talk", cmd_talk, help="Start conversation with an NPC")) +register(CommandDefinition("reply", cmd_reply, help="Reply in a conversation")) diff --git a/src/mudlib/conversation.py b/src/mudlib/conversation.py new file mode 100644 index 0000000..965cca5 --- /dev/null +++ b/src/mudlib/conversation.py @@ -0,0 +1,111 @@ +"""Conversation state tracking for player-NPC dialogues.""" + +from dataclasses import dataclass + +from mudlib.dialogue import DialogueNode, DialogueTree +from mudlib.entity import Mob +from mudlib.npc_behavior import transition_state +from mudlib.player import Player + + +@dataclass +class ConversationState: + """Tracks an active conversation between a player and NPC.""" + + tree: DialogueTree + current_node: str + npc: Mob + previous_state: str = "idle" + + +# Global registry of active conversations (keyed by player name) +active_conversations: dict[str, ConversationState] = {} + + +def start_conversation(player: Player, mob: Mob, tree: DialogueTree) -> DialogueNode: + """Start a conversation between player and NPC. + + Args: + player: The player starting the conversation + mob: The NPC mob to talk to + tree: The dialogue tree to use + + Returns: + The root DialogueNode + """ + # Store mob's previous state for restoration + previous_state = mob.behavior_state + + # Transition mob to converse state + transition_state(mob, "converse") + + # Create conversation state + state = ConversationState( + tree=tree, + current_node=tree.root_node, + npc=mob, + previous_state=previous_state, + ) + active_conversations[player.name] = state + + return tree.nodes[tree.root_node] + + +def advance_conversation(player: Player, choice_index: int) -> DialogueNode | None: + """Advance conversation based on player's choice. + + Args: + player: The player making the choice + choice_index: 1-indexed choice number + + Returns: + Next DialogueNode (conversation state updated but not ended yet), + or None if invalid choice + """ + conv = active_conversations.get(player.name) + if conv is None: + return None + + current = conv.tree.nodes[conv.current_node] + + # Validate choice index + if choice_index < 1 or choice_index > len(current.choices): + return None + + # Get the next node + choice = current.choices[choice_index - 1] + next_node = conv.tree.nodes[choice.next_node] + + # Update conversation state to new node + conv.current_node = next_node.id + + return next_node + + +def end_conversation(player: Player) -> None: + """End the active conversation and clean up state. + + Args: + player: The player whose conversation to end + """ + conv = active_conversations.get(player.name) + if conv is None: + return + + # Transition mob back to previous state + transition_state(conv.npc, conv.previous_state) + + # Remove conversation state + del active_conversations[player.name] + + +def get_conversation(player: Player) -> ConversationState | None: + """Get the active conversation for a player. + + Args: + player: The player to look up + + Returns: + ConversationState if active, None otherwise + """ + return active_conversations.get(player.name) diff --git a/tests/test_talk.py b/tests/test_talk.py new file mode 100644 index 0000000..a199a09 --- /dev/null +++ b/tests/test_talk.py @@ -0,0 +1,466 @@ +"""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 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 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