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)
|
moves: list[str] = field(default_factory=list)
|
||||||
loot: list[LootEntry] = field(default_factory=list)
|
loot: list[LootEntry] = field(default_factory=list)
|
||||||
schedule: NpcSchedule | None = None
|
schedule: NpcSchedule | None = None
|
||||||
|
npc_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# Module-level registries
|
# Module-level registries
|
||||||
|
|
@ -87,6 +88,7 @@ def load_mob_template(path: Path) -> MobTemplate:
|
||||||
moves=data.get("moves", []),
|
moves=data.get("moves", []),
|
||||||
loot=loot_entries,
|
loot=loot_entries,
|
||||||
schedule=schedule,
|
schedule=schedule,
|
||||||
|
npc_name=data.get("npc_name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -125,6 +127,7 @@ def spawn_mob(
|
||||||
description=template.description,
|
description=template.description,
|
||||||
moves=list(template.moves),
|
moves=list(template.moves),
|
||||||
schedule=template.schedule,
|
schedule=template.schedule,
|
||||||
|
npc_name=template.npc_name,
|
||||||
)
|
)
|
||||||
if home_region is not None:
|
if home_region is not None:
|
||||||
# Validate home_region structure
|
# Validate home_region structure
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import mudlib.commands.quit
|
||||||
import mudlib.commands.reload
|
import mudlib.commands.reload
|
||||||
import mudlib.commands.snapneck
|
import mudlib.commands.snapneck
|
||||||
import mudlib.commands.spawn
|
import mudlib.commands.spawn
|
||||||
|
import mudlib.commands.talk
|
||||||
import mudlib.commands.things
|
import mudlib.commands.things
|
||||||
import mudlib.commands.use
|
import mudlib.commands.use
|
||||||
from mudlib.caps import parse_mtts
|
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.combat.engine import process_combat
|
||||||
from mudlib.content import load_commands
|
from mudlib.content import load_commands
|
||||||
from mudlib.corpse import process_decomposing
|
from mudlib.corpse import process_decomposing
|
||||||
|
from mudlib.dialogue import load_all_dialogues
|
||||||
from mudlib.effects import clear_expired
|
from mudlib.effects import clear_expired
|
||||||
from mudlib.gametime import get_game_hour, init_game_time
|
from mudlib.gametime import get_game_hour, init_game_time
|
||||||
from mudlib.gmcp import (
|
from mudlib.gmcp import (
|
||||||
|
|
@ -579,6 +581,15 @@ async def run_server() -> None:
|
||||||
thing_templates.update(loaded_things)
|
thing_templates.update(loaded_things)
|
||||||
log.info("loaded %d thing templates from %s", len(loaded_things), things_dir)
|
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
|
# connect_maxwait: how long to wait for telnet option negotiation (CHARSET
|
||||||
# etc) before starting the shell. default is 4.0s which is painful.
|
# etc) before starting the shell. default is 4.0s which is painful.
|
||||||
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
# 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