Files
Code_of_Conquest/api/app/services/npc_loader.py
2025-11-24 23:10:55 -06:00

386 lines
11 KiB
Python

"""
NPCLoader service for loading NPC definitions from YAML files.
This service reads NPC configuration files and converts them into NPC
dataclass instances, providing caching for performance. NPCs are organized
by region subdirectories.
"""
import yaml
from pathlib import Path
from typing import Dict, List, Optional
import structlog
from app.models.npc import (
NPC,
NPCPersonality,
NPCAppearance,
NPCKnowledge,
NPCKnowledgeCondition,
NPCRelationship,
NPCInventoryItem,
NPCDialogueHooks,
)
logger = structlog.get_logger(__name__)
class NPCLoader:
"""
Loads NPC definitions from YAML configuration files.
NPCs are organized in region subdirectories:
/app/data/npcs/
crossville/
npc_grom_001.yaml
npc_mira_001.yaml
This allows game designers to define NPCs without touching code.
"""
def __init__(self, data_dir: Optional[str] = None):
"""
Initialize the NPC loader.
Args:
data_dir: Path to directory containing NPC YAML files.
Defaults to /app/data/npcs/
"""
if data_dir is None:
# Default to app/data/npcs relative to this file
current_file = Path(__file__)
app_dir = current_file.parent.parent # Go up to /app
data_dir = str(app_dir / "data" / "npcs")
self.data_dir = Path(data_dir)
self._npc_cache: Dict[str, NPC] = {}
self._location_npc_cache: Dict[str, List[str]] = {}
logger.info("NPCLoader initialized", data_dir=str(self.data_dir))
def load_npc(self, npc_id: str) -> Optional[NPC]:
"""
Load a single NPC by ID.
Searches all region subdirectories for the NPC file.
Args:
npc_id: Unique NPC identifier (e.g., "npc_grom_001")
Returns:
NPC instance or None if not found
"""
# Check cache first
if npc_id in self._npc_cache:
logger.debug("NPC loaded from cache", npc_id=npc_id)
return self._npc_cache[npc_id]
# Search in region subdirectories
if not self.data_dir.exists():
logger.error("NPC data directory does not exist", data_dir=str(self.data_dir))
return None
for region_dir in self.data_dir.iterdir():
if not region_dir.is_dir():
continue
file_path = region_dir / f"{npc_id}.yaml"
if file_path.exists():
return self._load_npc_file(file_path)
logger.warning("NPC not found", npc_id=npc_id)
return None
def _load_npc_file(self, file_path: Path) -> Optional[NPC]:
"""
Load an NPC from a specific file.
Args:
file_path: Path to the YAML file
Returns:
NPC instance or None if loading fails
"""
try:
with open(file_path, 'r') as f:
data = yaml.safe_load(f)
npc = self._parse_npc_data(data)
self._npc_cache[npc.npc_id] = npc
# Update location cache
if npc.location_id not in self._location_npc_cache:
self._location_npc_cache[npc.location_id] = []
if npc.npc_id not in self._location_npc_cache[npc.location_id]:
self._location_npc_cache[npc.location_id].append(npc.npc_id)
logger.info("NPC loaded successfully", npc_id=npc.npc_id)
return npc
except Exception as e:
logger.error("Failed to load NPC", file=str(file_path), error=str(e))
return None
def _parse_npc_data(self, data: Dict) -> NPC:
"""
Parse YAML data into an NPC dataclass.
Args:
data: Dictionary loaded from YAML file
Returns:
NPC instance
Raises:
ValueError: If data is invalid or missing required fields
"""
# Validate required fields
required_fields = ["npc_id", "name", "role", "location_id"]
for field in required_fields:
if field not in data:
raise ValueError(f"Missing required field: {field}")
# Parse personality
personality_data = data.get("personality", {})
personality = NPCPersonality(
traits=personality_data.get("traits", []),
speech_style=personality_data.get("speech_style", ""),
quirks=personality_data.get("quirks", []),
)
# Parse appearance
appearance_data = data.get("appearance", {})
if isinstance(appearance_data, str):
appearance = NPCAppearance(brief=appearance_data)
else:
appearance = NPCAppearance(
brief=appearance_data.get("brief", ""),
detailed=appearance_data.get("detailed"),
)
# Parse knowledge (optional)
knowledge = None
if data.get("knowledge"):
knowledge_data = data["knowledge"]
conditions = [
NPCKnowledgeCondition(
condition=c.get("condition", ""),
reveals=c.get("reveals", ""),
)
for c in knowledge_data.get("will_share_if", [])
]
knowledge = NPCKnowledge(
public=knowledge_data.get("public", []),
secret=knowledge_data.get("secret", []),
will_share_if=conditions,
)
# Parse relationships
relationships = [
NPCRelationship(
npc_id=r["npc_id"],
attitude=r["attitude"],
reason=r.get("reason"),
)
for r in data.get("relationships", [])
]
# Parse inventory
inventory = []
for item_data in data.get("inventory_for_sale", []):
# Handle shorthand format: { item: "ale", price: 2 }
item_id = item_data.get("item_id") or item_data.get("item", "")
inventory.append(NPCInventoryItem(
item_id=item_id,
price=item_data.get("price", 0),
quantity=item_data.get("quantity"),
))
# Parse dialogue hooks (optional)
dialogue_hooks = None
if data.get("dialogue_hooks"):
hooks_data = data["dialogue_hooks"]
dialogue_hooks = NPCDialogueHooks(
greeting=hooks_data.get("greeting"),
farewell=hooks_data.get("farewell"),
busy=hooks_data.get("busy"),
quest_complete=hooks_data.get("quest_complete"),
)
return NPC(
npc_id=data["npc_id"],
name=data["name"],
role=data["role"],
location_id=data["location_id"],
personality=personality,
appearance=appearance,
knowledge=knowledge,
relationships=relationships,
inventory_for_sale=inventory,
dialogue_hooks=dialogue_hooks,
quest_giver_for=data.get("quest_giver_for", []),
reveals_locations=data.get("reveals_locations", []),
tags=data.get("tags", []),
)
def load_all_npcs(self) -> List[NPC]:
"""
Load all NPCs from all region directories.
Returns:
List of NPC instances
"""
npcs = []
if not self.data_dir.exists():
logger.error("NPC data directory does not exist", data_dir=str(self.data_dir))
return npcs
for region_dir in self.data_dir.iterdir():
if not region_dir.is_dir():
continue
for file_path in region_dir.glob("*.yaml"):
npc = self._load_npc_file(file_path)
if npc:
npcs.append(npc)
logger.info("All NPCs loaded", count=len(npcs))
return npcs
def get_npcs_at_location(self, location_id: str) -> List[NPC]:
"""
Get all NPCs at a specific location.
Args:
location_id: Location identifier
Returns:
List of NPC instances at this location
"""
# Ensure all NPCs are loaded
if not self._npc_cache:
self.load_all_npcs()
npc_ids = self._location_npc_cache.get(location_id, [])
return [
self._npc_cache[npc_id]
for npc_id in npc_ids
if npc_id in self._npc_cache
]
def get_npc_ids_at_location(self, location_id: str) -> List[str]:
"""
Get NPC IDs at a specific location.
Args:
location_id: Location identifier
Returns:
List of NPC IDs at this location
"""
# Ensure all NPCs are loaded
if not self._npc_cache:
self.load_all_npcs()
return self._location_npc_cache.get(location_id, [])
def get_npcs_by_tag(self, tag: str) -> List[NPC]:
"""
Get all NPCs with a specific tag.
Args:
tag: Tag to filter by (e.g., "merchant", "quest_giver")
Returns:
List of NPC instances with this tag
"""
# Ensure all NPCs are loaded
if not self._npc_cache:
self.load_all_npcs()
return [
npc for npc in self._npc_cache.values()
if tag in npc.tags
]
def get_quest_givers(self, quest_id: str) -> List[NPC]:
"""
Get all NPCs that can give a specific quest.
Args:
quest_id: Quest identifier
Returns:
List of NPC instances that give this quest
"""
# Ensure all NPCs are loaded
if not self._npc_cache:
self.load_all_npcs()
return [
npc for npc in self._npc_cache.values()
if quest_id in npc.quest_giver_for
]
def get_all_npc_ids(self) -> List[str]:
"""
Get a list of all available NPC IDs.
Returns:
List of NPC IDs
"""
# Ensure all NPCs are loaded
if not self._npc_cache:
self.load_all_npcs()
return list(self._npc_cache.keys())
def reload_npc(self, npc_id: str) -> Optional[NPC]:
"""
Force reload an NPC from disk, bypassing cache.
Useful for development/testing when NPC definitions change.
Args:
npc_id: Unique NPC identifier
Returns:
NPC instance or None if not found
"""
# Remove from caches if present
if npc_id in self._npc_cache:
old_npc = self._npc_cache[npc_id]
# Remove from location cache
if old_npc.location_id in self._location_npc_cache:
self._location_npc_cache[old_npc.location_id] = [
n for n in self._location_npc_cache[old_npc.location_id]
if n != npc_id
]
del self._npc_cache[npc_id]
return self.load_npc(npc_id)
def clear_cache(self) -> None:
"""Clear all cached data. Useful for testing."""
self._npc_cache.clear()
self._location_npc_cache.clear()
logger.info("NPC cache cleared")
# Global singleton instance
_loader_instance: Optional[NPCLoader] = None
def get_npc_loader() -> NPCLoader:
"""
Get the global NPCLoader instance.
Returns:
Singleton NPCLoader instance
"""
global _loader_instance
if _loader_instance is None:
_loader_instance = NPCLoader()
return _loader_instance