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