374 lines
11 KiB
Python
374 lines
11 KiB
Python
"""
|
|
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()
|