mud/tests/test_npc_integration.py
Jared Miller 4d44c4aadd
Add librarian NPC with integration tests
Create librarian mob template as a non-combatant NPC with:
- dialogue tree linking (npc_name field)
- time-based schedule (working 7-21, idle otherwise)
- empty moves list (cannot fight)

Wire dialogue tree loading into server startup to load from content/dialogue/.

Add npc_name field to MobTemplate and spawn_mob to preserve dialogue tree links.

Integration tests verify:
- spawning from template preserves npc_name and schedule
- full conversation flow (start, advance, end)
- converse state blocks movement
- schedule transitions change behavior state
- working state blocks movement
- patrol behavior follows waypoints
2026-02-14 14:31:39 -05:00

324 lines
9.8 KiB
Python

"""End-to-end integration tests for NPC system (behavior + dialogue + schedule)."""
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, get_conversation
from mudlib.dialogue import load_dialogue
from mudlib.mob_ai import process_mob_movement
from mudlib.mobs import load_mob_template, mobs, spawn_mob
from mudlib.npc_behavior import transition_state
from mudlib.npc_schedule import process_schedules
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."""
mobs.clear()
players.clear()
active_conversations.clear()
dialogue_trees.clear()
yield
mobs.clear()
players.clear()
active_conversations.clear()
dialogue_trees.clear()
@pytest.fixture
def test_zone():
"""Create a test zone for testing."""
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_template(tmp_path):
"""Create librarian mob template TOML."""
toml_path = tmp_path / "librarian.toml"
toml_path.write_text(
'name = "the librarian"\n'
'description = "a studious figure in a worn cardigan"\n'
"pl = 50.0\n"
"stamina = 50.0\n"
"max_stamina = 50.0\n"
"moves = []\n"
'npc_name = "librarian"\n'
"\n"
"[[schedule]]\n"
"hour = 7\n"
'state = "working"\n'
"\n"
"[[schedule]]\n"
"hour = 21\n"
'state = "idle"\n'
)
return load_mob_template(toml_path)
@pytest.fixture
def librarian_dialogue(tmp_path):
"""Create librarian dialogue tree TOML."""
toml_path = tmp_path / "librarian.toml"
toml_path.write_text(
'npc_name = "librarian"\n'
'root_node = "greeting"\n'
"\n"
"[nodes.greeting]\n"
'text = "Welcome to the library. How can I help you today?"\n'
"\n"
"[[nodes.greeting.choices]]\n"
'text = "I\'m looking for a book."\n'
'next_node = "book_search"\n'
"\n"
"[[nodes.greeting.choices]]\n"
'text = "Tell me about this place."\n'
'next_node = "about_library"\n'
"\n"
"[[nodes.greeting.choices]]\n"
'text = "Goodbye."\n'
'next_node = "farewell"\n'
"\n"
"[nodes.book_search]\n"
'text = "We have quite the collection. Browse the shelves."\n'
"\n"
"[[nodes.book_search.choices]]\n"
'text = "Thanks, I\'ll look around."\n'
'next_node = "farewell"\n'
"\n"
"[nodes.about_library]\n"
'text = "This library was built to preserve the old stories."\n'
"\n"
"[[nodes.about_library.choices]]\n"
'text = "Thanks for the information."\n'
'next_node = "farewell"\n'
"\n"
"[nodes.farewell]\n"
'text = "Happy reading. Come back anytime."\n'
)
return load_dialogue(toml_path)
class TestLibrarianSpawn:
def test_spawn_from_template_has_npc_name(self, librarian_template, test_zone):
"""Librarian spawned from template has npc_name field set."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
assert mob.npc_name == "librarian"
def test_spawn_from_template_has_schedule(self, librarian_template, test_zone):
"""Librarian spawned from template has schedule loaded."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
assert mob.schedule is not None
assert len(mob.schedule.entries) == 2
assert mob.schedule.entries[0].hour == 7
assert mob.schedule.entries[0].state == "working"
assert mob.schedule.entries[1].hour == 21
assert mob.schedule.entries[1].state == "idle"
def test_spawn_from_template_no_combat_moves(self, librarian_template, test_zone):
"""Librarian has empty moves list (non-combatant)."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
assert mob.moves == []
class TestLibrarianConversation:
@pytest.mark.asyncio
async def test_talk_to_librarian_starts_dialogue(
self, player, librarian_template, librarian_dialogue, test_zone
):
"""Player can talk to librarian and start conversation."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
dialogue_trees["librarian"] = librarian_dialogue
await cmd_talk(player, "the librarian")
# Conversation started
conv = get_conversation(player)
assert conv is not None
assert conv.npc == mob
assert conv.current_node == "greeting"
# Mob transitioned to converse state
assert mob.behavior_state == "converse"
# Player received greeting
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "Welcome to the library" in output
@pytest.mark.asyncio
async def test_conversation_full_flow(
self, player, librarian_template, librarian_dialogue, test_zone
):
"""Player can walk through dialogue tree and end conversation."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
dialogue_trees["librarian"] = librarian_dialogue
# Store mob's initial state
transition_state(mob, "working")
# Start conversation
await cmd_talk(player, "the librarian")
assert mob.behavior_state == "converse"
# Reset mock to only capture reply
player.writer.write.reset_mock()
# Choose option 1: "I'm looking for a book."
await cmd_reply(player, "1")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "Browse the shelves" in output
# Reset mock
player.writer.write.reset_mock()
# Choose option 1: "Thanks, I'll look around."
await cmd_reply(player, "1")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "Happy reading" in output
# Conversation ended
assert get_conversation(player) is None
# Mob returned to previous state
assert mob.behavior_state == "working"
class TestConverseBehaviorBlocksMovement:
@pytest.mark.asyncio
async def test_librarian_in_conversation_doesnt_move(
self, player, librarian_template, librarian_dialogue, test_zone
):
"""Librarian in conversation stays put (doesn't wander)."""
mob = spawn_mob(
librarian_template,
10,
10,
test_zone,
home_region={"x": [0, 5], "y": [0, 5]},
)
dialogue_trees["librarian"] = librarian_dialogue
mob.next_action_at = 0.0
# Start conversation (transitions to converse state)
await cmd_talk(player, "the librarian")
original_x, original_y = mob.x, mob.y
# Process movement
await process_mob_movement()
# Mob didn't move
assert mob.x == original_x
assert mob.y == original_y
class TestScheduleTransitions:
@pytest.mark.asyncio
async def test_schedule_transitions_behavior_state(
self, librarian_template, test_zone
):
"""Librarian schedule transitions state at specified hours."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
# Process schedules at 7:00 (working state)
process_schedules([mob], 7)
assert mob.behavior_state == "working"
# Advance to 21:00 (idle state)
process_schedules([mob], 21)
assert mob.behavior_state == "idle"
@pytest.mark.asyncio
async def test_working_state_prevents_movement(self, librarian_template, test_zone):
"""Librarian in working state doesn't wander."""
mob = spawn_mob(
librarian_template,
50,
50,
test_zone,
home_region={"x": [0, 5], "y": [0, 5]},
)
mob.next_action_at = 0.0
# Set to working state (simulating schedule transition)
transition_state(mob, "working")
original_x, original_y = mob.x, mob.y
# Process movement
await process_mob_movement()
# Mob didn't move (working state blocks movement)
assert mob.x == original_x
assert mob.y == original_y
class TestPatrolBehavior:
@pytest.mark.asyncio
async def test_librarian_patrol_follows_waypoints(
self, librarian_template, test_zone
):
"""Librarian can patrol between waypoints."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
mob.next_action_at = 0.0
# Set patrol with waypoints
waypoints = [{"x": 20, "y": 10}, {"x": 20, "y": 20}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
# Process movement
await process_mob_movement()
# Mob moved toward first waypoint (east)
assert mob.x == 11
assert mob.y == 10
class TestNonCombatantNPC:
def test_librarian_has_no_combat_moves(self, librarian_template, test_zone):
"""Librarian has empty moves list (cannot fight)."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
assert mob.moves == []
assert len(mob.moves) == 0