first commit
This commit is contained in:
373
api/app/services/outcome_service.py
Normal file
373
api/app/services/outcome_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user