From 67a0290ede67db43308bd7374cce10b893f6b796 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 12:56:56 -0500 Subject: [PATCH] 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. --- src/mudlib/commands/talk.py | 126 ++++++++++ src/mudlib/conversation.py | 111 +++++++++ tests/test_talk.py | 466 ++++++++++++++++++++++++++++++++++++ 3 files changed, 703 insertions(+) create mode 100644 src/mudlib/commands/talk.py create mode 100644 src/mudlib/conversation.py create mode 100644 tests/test_talk.py 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