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.
This commit is contained in:
Jared Miller 2026-02-14 12:56:56 -05:00
parent 5d61008dc1
commit 67a0290ede
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 703 additions and 0 deletions

126
src/mudlib/commands/talk.py Normal file
View file

@ -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>
"""
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 <number>
"""
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"))

111
src/mudlib/conversation.py Normal file
View file

@ -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)

466
tests/test_talk.py Normal file
View file

@ -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 <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