| | """ |
| | DungeonMaster AI - Adventure Loader |
| | |
| | Loads and manages adventure JSON files for pre-made scenarios. |
| | """ |
| |
|
| | from __future__ import annotations |
| |
|
| | import json |
| | import logging |
| | from pathlib import Path |
| | from typing import TYPE_CHECKING |
| |
|
| | from .models import ( |
| | AdventureData, |
| | EncounterData, |
| | NPCInfo, |
| | SceneInfo, |
| | ) |
| |
|
| | if TYPE_CHECKING: |
| | from .game_state_manager import GameStateManager |
| |
|
| | logger = logging.getLogger(__name__) |
| |
|
| | |
| | DEFAULT_ADVENTURES_DIR = Path(__file__).parent.parent.parent / "adventures" |
| |
|
| |
|
| | class AdventureLoader: |
| | """ |
| | Loads and manages adventure JSON files. |
| | |
| | Adventures are pre-made scenarios with scenes, NPCs, encounters, |
| | and victory conditions. This class handles loading, parsing, |
| | and initializing games from adventure files. |
| | """ |
| |
|
| | def __init__( |
| | self, |
| | adventures_dir: Path | str | None = None, |
| | ) -> None: |
| | """ |
| | Initialize the adventure loader. |
| | |
| | Args: |
| | adventures_dir: Directory containing adventure JSON files. |
| | Defaults to 'adventures/' in project root. |
| | """ |
| | if adventures_dir is None: |
| | self._adventures_dir = DEFAULT_ADVENTURES_DIR |
| | else: |
| | self._adventures_dir = Path(adventures_dir) |
| |
|
| | |
| | self._cache: dict[str, AdventureData] = {} |
| |
|
| | logger.debug(f"AdventureLoader initialized with dir: {self._adventures_dir}") |
| |
|
| | @property |
| | def adventures_dir(self) -> Path: |
| | """Get the adventures directory path.""" |
| | return self._adventures_dir |
| |
|
| | |
| | |
| | |
| |
|
| | def list_adventures(self) -> list[dict[str, str]]: |
| | """ |
| | List all available adventures. |
| | |
| | Returns: |
| | List of adventure info dicts with keys: |
| | - name: Adventure name |
| | - file: Filename |
| | - description: Adventure description |
| | - difficulty: Difficulty level |
| | - estimated_time: Estimated play time |
| | """ |
| | adventures: list[dict[str, str]] = [] |
| |
|
| | if not self._adventures_dir.exists(): |
| | logger.warning(f"Adventures directory not found: {self._adventures_dir}") |
| | return adventures |
| |
|
| | for json_file in self._adventures_dir.glob("*.json"): |
| | |
| | if json_file.is_dir(): |
| | continue |
| |
|
| | try: |
| | with open(json_file, encoding="utf-8") as f: |
| | data = json.load(f) |
| |
|
| | metadata = data.get("metadata", {}) |
| | adventures.append( |
| | { |
| | "name": metadata.get("name", json_file.stem), |
| | "file": json_file.name, |
| | "description": metadata.get("description", ""), |
| | "difficulty": metadata.get("difficulty", "medium"), |
| | "estimated_time": metadata.get("estimated_time", "Unknown"), |
| | "recommended_level": str( |
| | metadata.get("recommended_level", 1) |
| | ), |
| | } |
| | ) |
| | except (json.JSONDecodeError, OSError) as e: |
| | logger.warning(f"Failed to load adventure {json_file}: {e}") |
| | continue |
| |
|
| | return adventures |
| |
|
| | |
| | |
| | |
| |
|
| | def load(self, adventure_name: str) -> AdventureData | None: |
| | """ |
| | Load an adventure by name. |
| | |
| | Checks cache first, then loads from file. |
| | |
| | Args: |
| | adventure_name: Name of adventure or filename (with/without .json) |
| | |
| | Returns: |
| | AdventureData if found, None otherwise |
| | """ |
| | |
| | if adventure_name in self._cache: |
| | return self._cache[adventure_name] |
| |
|
| | |
| | json_file = self._find_adventure_file(adventure_name) |
| | if json_file is None: |
| | logger.warning(f"Adventure not found: {adventure_name}") |
| | return None |
| |
|
| | |
| | try: |
| | with open(json_file, encoding="utf-8") as f: |
| | data = json.load(f) |
| |
|
| | adventure = AdventureData.from_json(data) |
| |
|
| | |
| | self._cache[adventure_name] = adventure |
| | self._cache[adventure.metadata.name] = adventure |
| | self._cache[json_file.stem] = adventure |
| |
|
| | logger.info(f"Loaded adventure: {adventure.metadata.name}") |
| | return adventure |
| |
|
| | except (json.JSONDecodeError, OSError) as e: |
| | logger.error(f"Failed to load adventure {json_file}: {e}") |
| | return None |
| | except Exception as e: |
| | logger.error(f"Failed to parse adventure {json_file}: {e}") |
| | return None |
| |
|
| | def _find_adventure_file(self, adventure_name: str) -> Path | None: |
| | """ |
| | Find an adventure file by name. |
| | |
| | Args: |
| | adventure_name: Name or filename to search for |
| | |
| | Returns: |
| | Path to file if found, None otherwise |
| | """ |
| | if not self._adventures_dir.exists(): |
| | return None |
| |
|
| | |
| | exact_path = self._adventures_dir / adventure_name |
| | if exact_path.exists(): |
| | return exact_path |
| |
|
| | |
| | json_path = self._adventures_dir / f"{adventure_name}.json" |
| | if json_path.exists(): |
| | return json_path |
| |
|
| | |
| | for json_file in self._adventures_dir.glob("*.json"): |
| | try: |
| | with open(json_file, encoding="utf-8") as f: |
| | data = json.load(f) |
| | metadata = data.get("metadata", {}) |
| | if metadata.get("name", "").lower() == adventure_name.lower(): |
| | return json_file |
| | except (json.JSONDecodeError, OSError): |
| | continue |
| |
|
| | return None |
| |
|
| | |
| | |
| | |
| |
|
| | async def initialize_game( |
| | self, |
| | manager: GameStateManager, |
| | adventure_name: str, |
| | ) -> bool: |
| | """ |
| | Initialize a game session with an adventure. |
| | |
| | Args: |
| | manager: GameStateManager to initialize |
| | adventure_name: Name of adventure to load |
| | |
| | Returns: |
| | True if successful, False otherwise |
| | """ |
| | adventure = self.load(adventure_name) |
| | if adventure is None: |
| | return False |
| |
|
| | try: |
| | |
| | await manager.new_game(adventure=adventure.metadata.name) |
| |
|
| | |
| | starting_scene = adventure.starting_scene |
| | scene_id = str(starting_scene.get("scene_id", "")) |
| | scene_info = self.get_scene(adventure_name, scene_id) |
| |
|
| | if scene_info: |
| | manager.set_location(scene_info.name, scene_info) |
| | else: |
| | |
| | manager.set_location( |
| | str(starting_scene.get("scene_id", "Unknown")), |
| | None, |
| | ) |
| |
|
| | |
| | for npc_data in adventure.npcs: |
| | if isinstance(npc_data, dict): |
| | npc = self._parse_npc(npc_data) |
| | if npc: |
| | manager.add_known_npc(npc) |
| |
|
| | |
| | manager.set_story_flag("adventure_started", True) |
| | manager.set_story_flag("adventure_name", adventure.metadata.name) |
| |
|
| | logger.info( |
| | f"Initialized game with adventure: {adventure.metadata.name}" |
| | ) |
| | return True |
| |
|
| | except Exception as e: |
| | logger.error(f"Failed to initialize game with adventure: {e}") |
| | return False |
| |
|
| | |
| | |
| | |
| |
|
| | def get_starting_narrative(self, adventure_name: str) -> str: |
| | """ |
| | Get the opening narrative for an adventure. |
| | |
| | Args: |
| | adventure_name: Name of adventure |
| | |
| | Returns: |
| | Opening narrative text, or empty string if not found |
| | """ |
| | adventure = self.load(adventure_name) |
| | if adventure is None: |
| | return "" |
| |
|
| | starting_scene = adventure.starting_scene |
| | return str(starting_scene.get("narrative", "")) |
| |
|
| | def get_scene( |
| | self, |
| | adventure_name: str, |
| | scene_id: str, |
| | ) -> SceneInfo | None: |
| | """ |
| | Get a scene from an adventure. |
| | |
| | Args: |
| | adventure_name: Name of adventure |
| | scene_id: Scene ID to find |
| | |
| | Returns: |
| | SceneInfo if found, None otherwise |
| | """ |
| | adventure = self.load(adventure_name) |
| | if adventure is None: |
| | return None |
| |
|
| | scene_data = adventure.get_scene(scene_id) |
| | if scene_data is None: |
| | return None |
| |
|
| | return self._parse_scene(scene_data) |
| |
|
| | def get_encounter( |
| | self, |
| | adventure_name: str, |
| | encounter_id: str, |
| | ) -> EncounterData | None: |
| | """ |
| | Get an encounter from an adventure. |
| | |
| | Args: |
| | adventure_name: Name of adventure |
| | encounter_id: Encounter ID to find |
| | |
| | Returns: |
| | EncounterData if found, None otherwise |
| | """ |
| | adventure = self.load(adventure_name) |
| | if adventure is None: |
| | return None |
| |
|
| | return adventure.get_encounter(encounter_id) |
| |
|
| | def get_npc( |
| | self, |
| | adventure_name: str, |
| | npc_id: str, |
| | ) -> NPCInfo | None: |
| | """ |
| | Get an NPC from an adventure. |
| | |
| | Args: |
| | adventure_name: Name of adventure |
| | npc_id: NPC ID to find |
| | |
| | Returns: |
| | NPCInfo if found, None otherwise |
| | """ |
| | adventure = self.load(adventure_name) |
| | if adventure is None: |
| | return None |
| |
|
| | npc_data = adventure.get_npc(npc_id) |
| | if npc_data is None: |
| | return None |
| |
|
| | return self._parse_npc(npc_data) |
| |
|
| | def get_all_scenes(self, adventure_name: str) -> list[SceneInfo]: |
| | """ |
| | Get all scenes from an adventure. |
| | |
| | Args: |
| | adventure_name: Name of adventure |
| | |
| | Returns: |
| | List of all SceneInfo objects |
| | """ |
| | adventure = self.load(adventure_name) |
| | if adventure is None: |
| | return [] |
| |
|
| | scenes: list[SceneInfo] = [] |
| | for scene_data in adventure.scenes: |
| | if isinstance(scene_data, dict): |
| | scene = self._parse_scene(scene_data) |
| | if scene: |
| | scenes.append(scene) |
| |
|
| | return scenes |
| |
|
| | def get_all_npcs(self, adventure_name: str) -> list[NPCInfo]: |
| | """ |
| | Get all NPCs from an adventure. |
| | |
| | Args: |
| | adventure_name: Name of adventure |
| | |
| | Returns: |
| | List of all NPCInfo objects |
| | """ |
| | adventure = self.load(adventure_name) |
| | if adventure is None: |
| | return [] |
| |
|
| | npcs: list[NPCInfo] = [] |
| | for npc_data in adventure.npcs: |
| | if isinstance(npc_data, dict): |
| | npc = self._parse_npc(npc_data) |
| | if npc: |
| | npcs.append(npc) |
| |
|
| | return npcs |
| |
|
| | |
| | |
| | |
| |
|
| | def _parse_scene(self, data: dict[str, object]) -> SceneInfo | None: |
| | """ |
| | Parse a scene dict into SceneInfo. |
| | |
| | Args: |
| | data: Raw scene data |
| | |
| | Returns: |
| | SceneInfo if valid, None otherwise |
| | """ |
| | try: |
| | scene_id = str(data.get("scene_id", "")) |
| | if not scene_id: |
| | return None |
| |
|
| | |
| | details = data.get("details", {}) |
| | sensory: dict[str, str] = {} |
| | if isinstance(details, dict): |
| | sensory_raw = details.get("sensory", {}) |
| | if isinstance(sensory_raw, dict): |
| | for key, value in sensory_raw.items(): |
| | sensory[str(key)] = str(value) |
| |
|
| | |
| | searchable: list[dict[str, object]] = [] |
| | if isinstance(details, dict): |
| | searchable_raw = details.get("searchable", []) |
| | if isinstance(searchable_raw, list): |
| | searchable = [ |
| | dict(obj) for obj in searchable_raw if isinstance(obj, dict) |
| | ] |
| |
|
| | |
| | encounter = data.get("encounter") |
| | encounter_id: str | None = None |
| | if isinstance(encounter, dict): |
| | encounter_id = str(encounter.get("encounter_id", "")) |
| | elif isinstance(encounter, str): |
| | encounter_id = encounter |
| |
|
| | return SceneInfo( |
| | scene_id=scene_id, |
| | name=str(data.get("name", scene_id)), |
| | description=str(data.get("description", "")), |
| | sensory_details=sensory, |
| | exits=dict(data.get("exits", {})), |
| | npcs_present=list(data.get("npcs_present", [])), |
| | items=list(data.get("items", [])), |
| | encounter_id=encounter_id if encounter_id else None, |
| | searchable_objects=searchable, |
| | ) |
| |
|
| | except Exception as e: |
| | logger.warning(f"Failed to parse scene: {e}") |
| | return None |
| |
|
| | def _parse_npc(self, data: dict[str, object]) -> NPCInfo | None: |
| | """ |
| | Parse an NPC dict into NPCInfo. |
| | |
| | Args: |
| | data: Raw NPC data |
| | |
| | Returns: |
| | NPCInfo if valid, None otherwise |
| | """ |
| | try: |
| | npc_id = str(data.get("npc_id", "")) |
| | if not npc_id: |
| | return None |
| |
|
| | return NPCInfo( |
| | npc_id=npc_id, |
| | name=str(data.get("name", "Unknown")), |
| | description=str(data.get("description", "")), |
| | personality=str(data.get("personality", "")), |
| | voice_profile=str(data.get("voice_profile", "dm")), |
| | dialogue_hooks=list(data.get("dialogue_hooks", [])), |
| | monster_stat_block=data.get("monster_stat_block"), |
| | relationship="hostile" |
| | if data.get("monster_stat_block") |
| | else "neutral", |
| | ) |
| |
|
| | except Exception as e: |
| | logger.warning(f"Failed to parse NPC: {e}") |
| | return None |
| |
|
| | |
| | |
| | |
| |
|
| | def clear_cache(self) -> None: |
| | """Clear the adventure cache.""" |
| | self._cache.clear() |
| | logger.debug("Adventure cache cleared") |
| |
|
| | def is_cached(self, adventure_name: str) -> bool: |
| | """ |
| | Check if an adventure is cached. |
| | |
| | Args: |
| | adventure_name: Name of adventure |
| | |
| | Returns: |
| | True if cached, False otherwise |
| | """ |
| | return adventure_name in self._cache |
| |
|
| | def preload(self, adventure_names: list[str]) -> int: |
| | """ |
| | Preload multiple adventures into cache. |
| | |
| | Args: |
| | adventure_names: List of adventure names to load |
| | |
| | Returns: |
| | Number of successfully loaded adventures |
| | """ |
| | loaded = 0 |
| | for name in adventure_names: |
| | if self.load(name) is not None: |
| | loaded += 1 |
| | return loaded |
| |
|
| | def __len__(self) -> int: |
| | """Return number of cached adventures.""" |
| | return len(self._cache) |
| |
|