# hero_catalog.py from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional, Literal, Any import yaml # PyYAML from app.models.enums import HeroClass from app.models.primitives import Attributes, Resources TargetType = Literal["self", "ally", "all_allies", "single_enemy", "all_enemies", "jumps_3_targets"] @dataclass class StatusSpec: chance: float potency: int duration_turns: int @dataclass class SkillRef: id: str min_level: int = 1 @dataclass class SpellDef: id: str element: Optional[str] = None cost_mp: int = 0 power: int = 0 scaling: Dict[str, float] = field(default_factory=dict) # e.g., {"int": 0.9, "luk": 0.03} variance: float = 0.0 target: TargetType = "single_enemy" tags: List[str] = field(default_factory=list) status_chance: Dict[str, StatusSpec] = field(default_factory=dict) # {"burn": StatusSpec(...)} crit_bonus: Optional[Dict[str, float]] = None # {"chance":0.05,"multiplier":1.5} @staticmethod def from_yaml(id_: str, raw: Dict[str, Any]) -> "SpellDef": sc = {} for k, v in (raw.get("status_chance") or {}).items(): sc[k] = StatusSpec(**v) return SpellDef( id=id_, element=raw.get("element"), cost_mp=int((raw.get("cost") or {}).get("mp", 0)), power=int(raw.get("power", 0)), scaling={k: float(v) for k, v in (raw.get("scaling") or {}).items()}, variance=float(raw.get("variance", 0.0)), target=(raw.get("target") or "single_enemy"), tags=list(raw.get("tags") or []), status_chance=sc, crit_bonus=raw.get("crit_bonus"), ) @dataclass class SkillDef: id: str type: Literal["active", "passive", "buff", "debuff", "utility"] = "active" cost_mp: int = 0 tags: List[str] = field(default_factory=list) # Free-form payloads your engine can interpret: passive_modifiers: Dict[str, Any] = field(default_factory=dict) # e.g., damage_multiplier_vs_tags.fire=1.10 effects: List[Dict[str, Any]] = field(default_factory=list) # e.g., [{"kind":"buff","stat":"int","amount":2,...}] @staticmethod def from_yaml(id_: str, raw: Dict[str, Any]) -> "SkillDef": return SkillDef( id=id_, type=(raw.get("type") or "active"), cost_mp=int((raw.get("cost") or {}).get("mp", 0)), tags=list(raw.get("tags") or []), passive_modifiers=dict(raw.get("passive_modifiers") or {}), effects=list(raw.get("effects") or []), ) @dataclass class HeroArchetype: key: HeroClass display_name: str background: str base_attributes: Attributes = field(default_factory=Attributes) starting_resources: Resources = field(default_factory=Resources) starting_skills: List[str] = field(default_factory=list) starting_spells: List[str] = field(default_factory=list) skill_trees: Dict[str, List[SkillRef]] = field(default_factory=dict) spells_by_level: Dict[int, List[str]] = field(default_factory=dict) bonus_abilities: List[str] = field(default_factory=list) # Local, per-class catalogs (optional—can be empty if you centralize elsewhere) skills: Dict[str, SkillDef] = field(default_factory=dict) spells: Dict[str, SpellDef] = field(default_factory=dict) class HeroDataRegistry: """ In-memory catalog of YAML-defined class sheets. Keyed by HeroClass (enum). """ def __init__(self) -> None: self._by_class: Dict[HeroClass, HeroArchetype] = {} self._skills: Dict[str, SkillDef] = {} self._spells: Dict[str, SpellDef] = {} def load_dir(self, directory: Path | str) -> None: directory = Path(directory) for path in sorted(directory.glob("*.y*ml")): with path.open("r", encoding="utf-8") as f: raw = yaml.safe_load(f) or {} # --- required --- key_raw = raw.get("key") if not key_raw: raise ValueError(f"{path.name}: missing required 'key' field") key = HeroClass(key_raw) # validate against Enum # --- optional / nested --- base_attributes = Attributes(**(raw.get("base_attributes") or {})) starting_resources = Resources(**(raw.get("starting_resources") or {})) # trees raw_trees = raw.get("skill_trees") or {} trees: Dict[str, List[SkillRef]] = {} for tree_name, nodes in raw_trees.items(): typed_nodes: List[SkillRef] = [] if nodes: for node in nodes: typed_nodes.append(SkillRef(**node)) trees[tree_name] = typed_nodes # spells by level (keys may be strings in YAML) sbl_in = raw.get("spells_by_level") or {} spells_by_level: Dict[int, List[str]] = {} for lvl_key, spell_list in sbl_in.items(): lvl = int(lvl_key) spells_by_level[lvl] = list(spell_list or []) arch = HeroArchetype( key=key, display_name=raw.get("display_name", key.value.title()), background=raw.get("background","A person with an unknown origin"), base_attributes=base_attributes, starting_resources=starting_resources, starting_skills=list(raw.get("starting_skills") or []), starting_spells=list(raw.get("starting_spells") or []), skill_trees=trees, spells_by_level=spells_by_level, bonus_abilities=list(raw.get("bonus_abilities") or []), ) # parse local catalogs if present: skills_raw = raw.get("skills") or {} for sid, sdef in skills_raw.items(): arch.skills[sid] = SkillDef.from_yaml(sid, sdef) self._skills[sid] = arch.skills[sid] # promote to global map spells_raw = raw.get("spells") or {} for spid, spdef in spells_raw.items(): arch.spells[spid] = SpellDef.from_yaml(spid, spdef) self._spells[spid] = arch.spells[spid] self._by_class[key] = arch # Lookups (prefer global catalogs for cross-class reuse) def get_spell(self, spell_id: str) -> SpellDef: try: return self._spells[spell_id] except KeyError: raise KeyError(f"Unknown spell id '{spell_id}'") def get_skill(self, skill_id: str) -> SkillDef: try: return self._skills[skill_id] except KeyError: raise KeyError(f"Unknown skill id '{skill_id}'") def for_class(self, hero_class: HeroClass) -> HeroArchetype: if hero_class not in self._by_class: raise KeyError(f"No archetype loaded for class {hero_class.value}") return self._by_class[hero_class]