mud/src/mudlib/mobs.py
Jared Miller 4d44c4aadd
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
2026-02-14 14:31:39 -05:00

205 lines
6.1 KiB
Python

"""Mob template loading, global registry, and spawn/despawn/query."""
import logging
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from mudlib.entity import Mob
from mudlib.loot import LootEntry
from mudlib.npc_schedule import NpcSchedule, ScheduleEntry
from mudlib.zone import Zone
logger = logging.getLogger(__name__)
@dataclass
class MobTemplate:
"""Definition loaded from TOML — used to spawn Mob instances."""
name: str
description: str
pl: float
stamina: float
max_stamina: float
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
mob_templates: dict[str, MobTemplate] = {}
mobs: list[Mob] = []
def load_mob_template(path: Path) -> MobTemplate:
"""Parse a mob TOML file into a MobTemplate."""
with open(path, "rb") as f:
data = tomllib.load(f)
loot_entries = []
for entry_data in data.get("loot", []):
loot_entries.append(
LootEntry(
name=entry_data["name"],
chance=entry_data["chance"],
min_count=entry_data.get("min_count", 1),
max_count=entry_data.get("max_count", 1),
description=entry_data.get("description", ""),
)
)
# Parse schedule if present
schedule = None
schedule_data = data.get("schedule", [])
if schedule_data:
entries = []
for entry_data in schedule_data:
# Extract location if present
location = None
if "location" in entry_data:
location = entry_data["location"]
# Extract all other fields as data (excluding hour, state, location)
data_fields = {
k: v
for k, v in entry_data.items()
if k not in ("hour", "state", "location")
}
entry_behavior_data = data_fields if data_fields else None
entries.append(
ScheduleEntry(
hour=entry_data["hour"],
state=entry_data["state"],
location=location,
data=entry_behavior_data,
)
)
schedule = NpcSchedule(entries=entries)
return MobTemplate(
name=data["name"],
description=data["description"],
pl=data["pl"],
stamina=data["stamina"],
max_stamina=data["max_stamina"],
moves=data.get("moves", []),
loot=loot_entries,
schedule=schedule,
npc_name=data.get("npc_name"),
)
def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
"""Load all .toml files in a directory into a dict keyed by name."""
templates: dict[str, MobTemplate] = {}
for path in sorted(directory.glob("*.toml")):
template = load_mob_template(path)
templates[template.name] = template
return templates
def spawn_mob(
template: MobTemplate, x: int, y: int, zone: Zone, home_region: dict | None = None
) -> Mob:
"""Create a Mob instance from a template at the given position.
Args:
template: The mob template to spawn from
x: X coordinate in the zone
y: Y coordinate in the zone
zone: The zone where the mob will be spawned
home_region: Optional home region dict with x and y bounds
Returns:
The spawned Mob instance
"""
mob = Mob(
name=template.name,
location=zone,
x=x,
y=y,
pl=template.pl,
stamina=template.stamina,
max_stamina=template.max_stamina,
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
if (
isinstance(home_region.get("x"), list)
and isinstance(home_region.get("y"), list)
and len(home_region["x"]) == 2
and len(home_region["y"]) == 2
and all(isinstance(v, int) for v in home_region["x"])
and all(isinstance(v, int) for v in home_region["y"])
):
mob.home_x_min = home_region["x"][0]
mob.home_x_max = home_region["x"][1]
mob.home_y_min = home_region["y"][0]
mob.home_y_max = home_region["y"][1]
else:
logger.warning(
"Malformed home_region for mob %s: %s. Expected "
"{x: [int, int], y: [int, int]}. Skipping home region.",
template.name,
home_region,
)
mobs.append(mob)
return mob
def despawn_mob(mob: Mob) -> None:
"""Remove a mob from the registry and mark it dead."""
mob.alive = False
if mob in mobs:
mobs.remove(mob)
def get_nearby_mob(
name: str, x: int, y: int, zone: Zone, range_: int = 10
) -> Mob | None:
"""Find the closest alive mob matching name within range.
Uses zone.contents_near() to find all nearby objects, then filters
for alive mobs matching the name and picks the closest.
Args:
name: Name of the mob to find
x: X coordinate of the search center
y: Y coordinate of the search center
zone: The zone to search in
range_: Maximum Manhattan distance (default 10)
Returns:
The closest matching mob, or None if none found
"""
best: Mob | None = None
best_dist = float("inf")
# Get all nearby objects from the zone
nearby = zone.contents_near(x, y, range_)
for obj in nearby:
# Filter for alive mobs matching the name
if not isinstance(obj, Mob) or not obj.alive or obj.name != name:
continue
# Calculate wrapping-aware distance to find closest
dx = abs(obj.x - x)
dy = abs(obj.y - y)
if zone.toroidal:
dx = min(dx, zone.width - dx)
dy = min(dy, zone.height - dy)
dist = dx + dy
if dist < best_dist:
best = obj
best_dist = dist
return best