""" Outcome determination service for Code of Conquest. This service handles all code-determined game outcomes before they're passed to AI for narration. It uses the dice mechanics system to determine success/failure and selects appropriate rewards from loot tables. """ import random import yaml import structlog from pathlib import Path from dataclasses import dataclass from typing import Optional, List, Dict, Any from app.models.character import Character from app.game_logic.dice import ( CheckResult, SkillType, Difficulty, skill_check, get_stat_for_skill, perception_check ) logger = structlog.get_logger(__name__) @dataclass class ItemFound: """ Represents an item found during a search. Uses template key from generic_items.yaml. """ template_key: str name: str description: str value: int def to_dict(self) -> dict: """Serialize for API response.""" return { "template_key": self.template_key, "name": self.name, "description": self.description, "value": self.value, } @dataclass class SearchOutcome: """ Complete result of a search action. Includes the dice check result and any items/gold found. """ check_result: CheckResult items_found: List[ItemFound] gold_found: int def to_dict(self) -> dict: """Serialize for API response.""" return { "check_result": self.check_result.to_dict(), "items_found": [item.to_dict() for item in self.items_found], "gold_found": self.gold_found, } @dataclass class SkillCheckOutcome: """ Result of a generic skill check. Used for persuasion, lockpicking, stealth, etc. """ check_result: CheckResult context: Dict[str, Any] # Additional context for AI narration def to_dict(self) -> dict: """Serialize for API response.""" return { "check_result": self.check_result.to_dict(), "context": self.context, } class OutcomeService: """ Service for determining game action outcomes. Handles all dice rolls and loot selection before passing to AI. """ def __init__(self): """Initialize the outcome service with loot tables and item templates.""" self._loot_tables: Dict[str, Any] = {} self._item_templates: Dict[str, Any] = {} self._load_data() def _load_data(self) -> None: """Load loot tables and item templates from YAML files.""" data_dir = Path(__file__).parent.parent / "data" # Load loot tables loot_path = data_dir / "loot_tables.yaml" if loot_path.exists(): with open(loot_path, "r") as f: self._loot_tables = yaml.safe_load(f) logger.info("loaded_loot_tables", count=len(self._loot_tables)) else: logger.warning("loot_tables_not_found", path=str(loot_path)) # Load generic item templates items_path = data_dir / "generic_items.yaml" if items_path.exists(): with open(items_path, "r") as f: data = yaml.safe_load(f) self._item_templates = data.get("templates", {}) logger.info("loaded_item_templates", count=len(self._item_templates)) else: logger.warning("item_templates_not_found", path=str(items_path)) def determine_search_outcome( self, character: Character, location_type: str, dc: int = 12, bonus: int = 0 ) -> SearchOutcome: """ Determine the outcome of a search action. Uses a perception check to determine success, then selects items from the appropriate loot table based on the roll margin. Args: character: The character performing the search location_type: Type of location (forest, cave, town, etc.) dc: Difficulty class (default 12 = easy-medium) bonus: Additional bonus to the check Returns: SearchOutcome with check result, items found, and gold found """ # Get character's effective wisdom for perception effective_stats = character.get_effective_stats() wisdom = effective_stats.wisdom # Perform the perception check check_result = perception_check(wisdom, dc, bonus) # Determine loot based on result items_found: List[ItemFound] = [] gold_found: int = 0 if check_result.success: # Get loot table for this location (fall back to default) loot_table = self._loot_tables.get( location_type.lower(), self._loot_tables.get("default", {}) ) # Select item rarity based on margin if check_result.margin >= 10: rarity = "rare" elif check_result.margin >= 5: rarity = "uncommon" else: rarity = "common" # Get items for this rarity item_keys = loot_table.get(rarity, []) if item_keys: # Select 1-2 items based on margin num_items = 1 if check_result.margin < 8 else 2 selected_keys = random.sample( item_keys, min(num_items, len(item_keys)) ) for key in selected_keys: template = self._item_templates.get(key) if template: items_found.append(ItemFound( template_key=key, name=template.get("name", key.title()), description=template.get("description", ""), value=template.get("value", 1), )) # Calculate gold found gold_config = loot_table.get("gold", {}) if gold_config: min_gold = gold_config.get("min", 0) max_gold = gold_config.get("max", 10) bonus_per_margin = gold_config.get("bonus_per_margin", 0) base_gold = random.randint(min_gold, max_gold) margin_bonus = check_result.margin * bonus_per_margin gold_found = base_gold + margin_bonus logger.info( "search_outcome_determined", character_id=character.character_id, location_type=location_type, dc=dc, success=check_result.success, margin=check_result.margin, items_count=len(items_found), gold_found=gold_found ) return SearchOutcome( check_result=check_result, items_found=items_found, gold_found=gold_found ) def determine_skill_check_outcome( self, character: Character, skill_type: SkillType, dc: int, bonus: int = 0, context: Optional[Dict[str, Any]] = None ) -> SkillCheckOutcome: """ Determine the outcome of a generic skill check. Args: character: The character performing the check skill_type: The type of skill check (PERSUASION, STEALTH, etc.) dc: Difficulty class to beat bonus: Additional bonus to the check context: Optional context for AI narration (e.g., NPC name, door type) Returns: SkillCheckOutcome with check result and context """ # Get the appropriate stat for this skill stat_name = get_stat_for_skill(skill_type) effective_stats = character.get_effective_stats() stat_value = getattr(effective_stats, stat_name, 10) # Perform the check check_result = skill_check(stat_value, dc, skill_type, bonus) # Build outcome context outcome_context = context or {} outcome_context["skill_used"] = skill_type.name.lower() outcome_context["stat_used"] = stat_name logger.info( "skill_check_outcome_determined", character_id=character.character_id, skill=skill_type.name, stat=stat_name, dc=dc, success=check_result.success, margin=check_result.margin ) return SkillCheckOutcome( check_result=check_result, context=outcome_context ) def determine_persuasion_outcome( self, character: Character, dc: int, npc_name: Optional[str] = None, bonus: int = 0 ) -> SkillCheckOutcome: """ Convenience method for persuasion checks. Args: character: The character attempting persuasion dc: Difficulty class based on NPC disposition npc_name: Name of the NPC being persuaded bonus: Additional bonus Returns: SkillCheckOutcome """ context = {"npc_name": npc_name} if npc_name else {} return self.determine_skill_check_outcome( character, SkillType.PERSUASION, dc, bonus, context ) def determine_stealth_outcome( self, character: Character, dc: int, situation: Optional[str] = None, bonus: int = 0 ) -> SkillCheckOutcome: """ Convenience method for stealth checks. Args: character: The character attempting stealth dc: Difficulty class based on environment/observers situation: Description of what they're sneaking past bonus: Additional bonus Returns: SkillCheckOutcome """ context = {"situation": situation} if situation else {} return self.determine_skill_check_outcome( character, SkillType.STEALTH, dc, bonus, context ) def determine_lockpicking_outcome( self, character: Character, dc: int, lock_description: Optional[str] = None, bonus: int = 0 ) -> SkillCheckOutcome: """ Convenience method for lockpicking checks. Args: character: The character attempting to pick the lock dc: Difficulty class based on lock quality lock_description: Description of the lock/door bonus: Additional bonus (e.g., from thieves' tools) Returns: SkillCheckOutcome """ context = {"lock_description": lock_description} if lock_description else {} return self.determine_skill_check_outcome( character, SkillType.LOCKPICKING, dc, bonus, context ) def get_dc_for_difficulty(self, difficulty: str) -> int: """ Get the DC value for a named difficulty. Args: difficulty: Difficulty name (trivial, easy, medium, hard, very_hard) Returns: DC value """ difficulty_map = { "trivial": Difficulty.TRIVIAL.value, "easy": Difficulty.EASY.value, "medium": Difficulty.MEDIUM.value, "hard": Difficulty.HARD.value, "very_hard": Difficulty.VERY_HARD.value, "nearly_impossible": Difficulty.NEARLY_IMPOSSIBLE.value, } return difficulty_map.get(difficulty.lower(), Difficulty.MEDIUM.value) # Global instance for use in API endpoints outcome_service = OutcomeService()