diff --git a/content/mobs/librarian.toml b/content/mobs/librarian.toml new file mode 100644 index 0000000..3e3061e --- /dev/null +++ b/content/mobs/librarian.toml @@ -0,0 +1,15 @@ +name = "the librarian" +description = "a studious figure in a worn cardigan, carefully organizing books on the shelves" +pl = 50.0 +stamina = 50.0 +max_stamina = 50.0 +moves = [] +npc_name = "librarian" + +[[schedule]] +hour = 7 +state = "working" + +[[schedule]] +hour = 21 +state = "idle" diff --git a/src/mudlib/mobs.py b/src/mudlib/mobs.py index 31d739e..04e72b5 100644 --- a/src/mudlib/mobs.py +++ b/src/mudlib/mobs.py @@ -25,6 +25,7 @@ class MobTemplate: moves: list[str] = field(default_factory=list) loot: list[LootEntry] = field(default_factory=list) schedule: NpcSchedule | None = None + npc_name: str | None = None # Module-level registries @@ -87,6 +88,7 @@ def load_mob_template(path: Path) -> MobTemplate: moves=data.get("moves", []), loot=loot_entries, schedule=schedule, + npc_name=data.get("npc_name"), ) @@ -125,6 +127,7 @@ def spawn_mob( description=template.description, moves=list(template.moves), schedule=template.schedule, + npc_name=template.npc_name, ) if home_region is not None: # Validate home_region structure diff --git a/src/mudlib/server.py b/src/mudlib/server.py index fa9e78d..472a8b4 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -29,6 +29,7 @@ import mudlib.commands.quit import mudlib.commands.reload import mudlib.commands.snapneck import mudlib.commands.spawn +import mudlib.commands.talk import mudlib.commands.things import mudlib.commands.use from mudlib.caps import parse_mtts @@ -36,6 +37,7 @@ from mudlib.combat.commands import register_combat_commands from mudlib.combat.engine import process_combat from mudlib.content import load_commands from mudlib.corpse import process_decomposing +from mudlib.dialogue import load_all_dialogues from mudlib.effects import clear_expired from mudlib.gametime import get_game_hour, init_game_time from mudlib.gmcp import ( @@ -579,6 +581,15 @@ async def run_server() -> None: thing_templates.update(loaded_things) log.info("loaded %d thing templates from %s", len(loaded_things), things_dir) + # Load dialogue trees for NPC conversations + dialogue_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "dialogue" + if dialogue_dir.exists(): + loaded_dialogues = load_all_dialogues(dialogue_dir) + mudlib.commands.talk.dialogue_trees.update(loaded_dialogues) + log.info( + "loaded %d dialogue trees from %s", len(loaded_dialogues), dialogue_dir + ) + # connect_maxwait: how long to wait for telnet option negotiation (CHARSET # etc) before starting the shell. default is 4.0s which is painful. # MUD clients like tintin++ reject CHARSET immediately via MTTS, but diff --git a/tests/test_npc_integration.py b/tests/test_npc_integration.py new file mode 100644 index 0000000..faf82a9 --- /dev/null +++ b/tests/test_npc_integration.py @@ -0,0 +1,324 @@ +"""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