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
324 lines
9.8 KiB
Python
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
|