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
This commit is contained in:
parent
f0238d9e49
commit
4d44c4aadd
4 changed files with 353 additions and 0 deletions
15
content/mobs/librarian.toml
Normal file
15
content/mobs/librarian.toml
Normal file
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
324
tests/test_npc_integration.py
Normal file
324
tests/test_npc_integration.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue