first commit

This commit is contained in:
2025-11-24 23:10:55 -06:00
commit 8315fa51c9
279 changed files with 74600 additions and 0 deletions

View File

@@ -0,0 +1,373 @@
"""
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()