Equipment-Combat Integration: - Update Stats damage formula from STR//2 to int(STR*0.75) for better scaling - Add spell_power system for magical weapons (staves, wands) - Add spell_power_bonus field to Stats model with spell_power property - Add spell_power field to Item model with is_magical_weapon() method - Update Character.get_effective_stats() to populate spell_power_bonus Combatant Model Updates: - Add weapon property fields (crit_chance, crit_multiplier, damage_type) - Add elemental weapon support (elemental_damage_type, physical_ratio, elemental_ratio) - Update serialization to handle new weapon properties DamageCalculator Refactoring: - Remove weapon_damage parameter from calculate_physical_damage() - Use attacker_stats.damage directly (includes weapon bonus) - Use attacker_stats.spell_power for magical damage calculations Combat Service Updates: - Extract weapon properties in _create_combatant_from_character() - Use stats.damage_bonus for enemy combatants from templates - Remove hardcoded _get_weapon_damage() method - Handle elemental weapons with split damage in _execute_attack() Item Generation Updates: - Add base_spell_power to BaseItemTemplate dataclass - Add ARCANE damage type to DamageType enum - Add magical weapon templates (wizard_staff, arcane_staff, wand, crystal_wand) Test Updates: - Update test_stats.py for new damage formula (0.75 scaling) - Update test_character.py for equipment bonus calculations - Update test_damage_calculator.py for new API signatures - Update test_combat_service.py mock fixture for equipped attribute Tests: 174 passing
275 lines
8.3 KiB
Python
275 lines
8.3 KiB
Python
"""
|
|
Base Item Loader Service - YAML-based base item template loading.
|
|
|
|
This service loads base item templates (weapons, armor) from YAML files,
|
|
providing the foundation for procedural item generation.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
import random
|
|
import yaml
|
|
|
|
from app.models.affixes import BaseItemTemplate
|
|
from app.utils.logging import get_logger
|
|
|
|
logger = get_logger(__file__)
|
|
|
|
# Rarity order for comparison
|
|
RARITY_ORDER = {
|
|
"common": 0,
|
|
"uncommon": 1,
|
|
"rare": 2,
|
|
"epic": 3,
|
|
"legendary": 4
|
|
}
|
|
|
|
|
|
class BaseItemLoader:
|
|
"""
|
|
Loads and manages base item templates from YAML configuration files.
|
|
|
|
This allows game designers to define base items without touching code.
|
|
Templates are organized into weapons.yaml and armor.yaml files.
|
|
"""
|
|
|
|
def __init__(self, data_dir: Optional[str] = None):
|
|
"""
|
|
Initialize the base item loader.
|
|
|
|
Args:
|
|
data_dir: Path to directory containing base item YAML files
|
|
Defaults to /app/data/base_items/
|
|
"""
|
|
if data_dir is None:
|
|
# Default to app/data/base_items relative to this file
|
|
current_file = Path(__file__)
|
|
app_dir = current_file.parent.parent # Go up to /app
|
|
data_dir = str(app_dir / "data" / "base_items")
|
|
|
|
self.data_dir = Path(data_dir)
|
|
self._weapon_cache: Dict[str, BaseItemTemplate] = {}
|
|
self._armor_cache: Dict[str, BaseItemTemplate] = {}
|
|
self._loaded = False
|
|
|
|
logger.info("BaseItemLoader initialized", data_dir=str(self.data_dir))
|
|
|
|
def _ensure_loaded(self) -> None:
|
|
"""Ensure templates are loaded before any operation."""
|
|
if not self._loaded:
|
|
self.load_all()
|
|
|
|
def load_all(self) -> None:
|
|
"""Load all base item templates from YAML files."""
|
|
if not self.data_dir.exists():
|
|
logger.warning("Base item data directory not found", path=str(self.data_dir))
|
|
return
|
|
|
|
# Load weapons
|
|
weapons_file = self.data_dir / "weapons.yaml"
|
|
if weapons_file.exists():
|
|
self._load_templates_from_file(weapons_file, "weapons", self._weapon_cache)
|
|
|
|
# Load armor
|
|
armor_file = self.data_dir / "armor.yaml"
|
|
if armor_file.exists():
|
|
self._load_templates_from_file(armor_file, "armor", self._armor_cache)
|
|
|
|
self._loaded = True
|
|
logger.info(
|
|
"Base item templates loaded",
|
|
weapon_count=len(self._weapon_cache),
|
|
armor_count=len(self._armor_cache)
|
|
)
|
|
|
|
def _load_templates_from_file(
|
|
self,
|
|
yaml_file: Path,
|
|
key: str,
|
|
cache: Dict[str, BaseItemTemplate]
|
|
) -> None:
|
|
"""
|
|
Load templates from a YAML file into the cache.
|
|
|
|
Args:
|
|
yaml_file: Path to the YAML file
|
|
key: Top-level key in YAML (e.g., "weapons", "armor")
|
|
cache: Cache dictionary to populate
|
|
"""
|
|
try:
|
|
with open(yaml_file, 'r') as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
templates_data = data.get(key, {})
|
|
|
|
for template_id, template_data in templates_data.items():
|
|
# Ensure template_id is set
|
|
template_data["template_id"] = template_id
|
|
|
|
# Set defaults for missing optional fields
|
|
template_data.setdefault("description", "")
|
|
template_data.setdefault("base_damage", 0)
|
|
template_data.setdefault("base_spell_power", 0)
|
|
template_data.setdefault("base_defense", 0)
|
|
template_data.setdefault("base_resistance", 0)
|
|
template_data.setdefault("base_value", 10)
|
|
template_data.setdefault("damage_type", "physical")
|
|
template_data.setdefault("crit_chance", 0.05)
|
|
template_data.setdefault("crit_multiplier", 2.0)
|
|
template_data.setdefault("required_level", 1)
|
|
template_data.setdefault("drop_weight", 1.0)
|
|
template_data.setdefault("min_rarity", "common")
|
|
|
|
template = BaseItemTemplate.from_dict(template_data)
|
|
cache[template.template_id] = template
|
|
|
|
logger.debug(
|
|
"Templates loaded from file",
|
|
file=str(yaml_file),
|
|
count=len(templates_data)
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to load base item file",
|
|
file=str(yaml_file),
|
|
error=str(e)
|
|
)
|
|
|
|
def get_template(self, template_id: str) -> Optional[BaseItemTemplate]:
|
|
"""
|
|
Get a specific template by ID.
|
|
|
|
Args:
|
|
template_id: Unique template identifier
|
|
|
|
Returns:
|
|
BaseItemTemplate instance or None if not found
|
|
"""
|
|
self._ensure_loaded()
|
|
|
|
if template_id in self._weapon_cache:
|
|
return self._weapon_cache[template_id]
|
|
if template_id in self._armor_cache:
|
|
return self._armor_cache[template_id]
|
|
|
|
return None
|
|
|
|
def get_eligible_templates(
|
|
self,
|
|
item_type: str,
|
|
rarity: str,
|
|
character_level: int = 1
|
|
) -> List[BaseItemTemplate]:
|
|
"""
|
|
Get all templates eligible for generation.
|
|
|
|
Args:
|
|
item_type: Type of item ("weapon", "armor")
|
|
rarity: Target rarity
|
|
character_level: Player level for eligibility
|
|
|
|
Returns:
|
|
List of eligible BaseItemTemplate instances
|
|
"""
|
|
self._ensure_loaded()
|
|
|
|
# Select the appropriate cache
|
|
if item_type == "weapon":
|
|
cache = self._weapon_cache
|
|
elif item_type == "armor":
|
|
cache = self._armor_cache
|
|
else:
|
|
logger.warning("Unknown item type", item_type=item_type)
|
|
return []
|
|
|
|
eligible = []
|
|
for template in cache.values():
|
|
# Check level requirement
|
|
if not template.can_drop_for_level(character_level):
|
|
continue
|
|
|
|
# Check rarity requirement
|
|
if not template.can_generate_at_rarity(rarity):
|
|
continue
|
|
|
|
eligible.append(template)
|
|
|
|
return eligible
|
|
|
|
def get_random_template(
|
|
self,
|
|
item_type: str,
|
|
rarity: str,
|
|
character_level: int = 1
|
|
) -> Optional[BaseItemTemplate]:
|
|
"""
|
|
Get a random eligible template, weighted by drop_weight.
|
|
|
|
Args:
|
|
item_type: Type of item ("weapon", "armor")
|
|
rarity: Target rarity
|
|
character_level: Player level for eligibility
|
|
|
|
Returns:
|
|
Random eligible BaseItemTemplate or None if none available
|
|
"""
|
|
eligible = self.get_eligible_templates(item_type, rarity, character_level)
|
|
|
|
if not eligible:
|
|
logger.warning(
|
|
"No templates match criteria",
|
|
item_type=item_type,
|
|
rarity=rarity,
|
|
level=character_level
|
|
)
|
|
return None
|
|
|
|
# Weighted random selection based on drop_weight
|
|
weights = [t.drop_weight for t in eligible]
|
|
return random.choices(eligible, weights=weights, k=1)[0]
|
|
|
|
def get_all_weapons(self) -> Dict[str, BaseItemTemplate]:
|
|
"""
|
|
Get all cached weapon templates.
|
|
|
|
Returns:
|
|
Dictionary of weapon templates
|
|
"""
|
|
self._ensure_loaded()
|
|
return self._weapon_cache.copy()
|
|
|
|
def get_all_armor(self) -> Dict[str, BaseItemTemplate]:
|
|
"""
|
|
Get all cached armor templates.
|
|
|
|
Returns:
|
|
Dictionary of armor templates
|
|
"""
|
|
self._ensure_loaded()
|
|
return self._armor_cache.copy()
|
|
|
|
def clear_cache(self) -> None:
|
|
"""Clear the template cache, forcing reload on next access."""
|
|
self._weapon_cache.clear()
|
|
self._armor_cache.clear()
|
|
self._loaded = False
|
|
logger.debug("Base item template cache cleared")
|
|
|
|
|
|
# Global instance for convenience
|
|
_loader_instance: Optional[BaseItemLoader] = None
|
|
|
|
|
|
def get_base_item_loader() -> BaseItemLoader:
|
|
"""
|
|
Get the global BaseItemLoader instance.
|
|
|
|
Returns:
|
|
Singleton BaseItemLoader instance
|
|
"""
|
|
global _loader_instance
|
|
if _loader_instance is None:
|
|
_loader_instance = BaseItemLoader()
|
|
return _loader_instance
|