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
205 lines
6.1 KiB
Python
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
|