"""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