"""Combat move definitions and TOML loading.""" import logging import tomllib from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path from typing import Any log = logging.getLogger(__name__) CommandHandler = Callable[..., Any] @dataclass class UnlockCondition: """Condition that must be met to unlock a combat move.""" type: str # "kill_count" or "mob_kills" threshold: int = 0 mob_name: str = "" # for mob_kills type @dataclass class CombatMove: """Defines a combat move with its properties and counters.""" name: str move_type: str # "attack" or "defense" stamina_cost: float timing_window_ms: int aliases: list[str] = field(default_factory=list) telegraph: str = "" damage_pct: float = 0.0 countered_by: list[str] = field(default_factory=list) handler: CommandHandler | None = None # base command name ("punch" for "punch left", same as name for simple moves) command: str = "" # variant key ("left", "right", "" for simple moves) variant: str = "" description: str = "" # Three-beat output templates announce: str = "" # POV template for announce beat resolve_hit: str = "" # POV template for hit (wrong/no defense) resolve_miss: str = "" # POV template for successful counter telegraph_color: str = "dim" # color tag for telegraph announce_color: str = "" # color tag for announce (default/none) resolve_color: str = "bold" # color tag for resolve unlock_condition: UnlockCondition | None = None def load_move(path: Path) -> list[CombatMove]: """Load combat move(s) from a TOML file. If the file has a [variants] section, each variant produces a separate CombatMove with a qualified name (e.g. "punch left"). Shared properties come from the top level, variant-specific properties override them. Args: path: Path to TOML file Returns: List of CombatMove instances (one per variant, or one for simple moves) Raises: ValueError: If required fields are missing """ with open(path, "rb") as f: data = tomllib.load(f) # Required fields required_fields = ["name", "move_type", "stamina_cost", "timing_window_ms"] for field_name in required_fields: if field_name not in data: msg = f"missing required field: {field_name}" raise ValueError(msg) base_name = data["name"] variants = data.get("variants") # Parse optional unlock condition unlock_data = data.get("unlock") unlock_condition = None if unlock_data: unlock_condition = UnlockCondition( type=unlock_data["type"], threshold=unlock_data.get("threshold", 0), mob_name=unlock_data.get("mob_name", ""), ) if variants: moves = [] for variant_key, variant_data in variants.items(): qualified_name = f"{base_name} {variant_key}" moves.append( CombatMove( name=qualified_name, move_type=data["move_type"], stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]), timing_window_ms=variant_data.get( "timing_window_ms", data["timing_window_ms"] ), aliases=variant_data.get("aliases", []), telegraph=variant_data.get("telegraph", data.get("telegraph", "")), damage_pct=variant_data.get( "damage_pct", data.get("damage_pct", 0.0) ), countered_by=variant_data.get( "countered_by", data.get("countered_by", []) ), handler=None, command=base_name, variant=variant_key, description=data.get("description", ""), announce=variant_data.get("announce", data.get("announce", "")), resolve_hit=variant_data.get( "resolve_hit", data.get("resolve_hit", "") ), resolve_miss=variant_data.get( "resolve_miss", data.get("resolve_miss", "") ), telegraph_color=variant_data.get( "telegraph_color", data.get("telegraph_color", "dim") ), announce_color=variant_data.get( "announce_color", data.get("announce_color", "") ), resolve_color=variant_data.get( "resolve_color", data.get("resolve_color", "bold") ), unlock_condition=unlock_condition, ) ) return moves # Simple move (no variants) return [ CombatMove( name=base_name, move_type=data["move_type"], stamina_cost=data["stamina_cost"], timing_window_ms=data["timing_window_ms"], aliases=data.get("aliases", []), telegraph=data.get("telegraph", ""), damage_pct=data.get("damage_pct", 0.0), countered_by=data.get("countered_by", []), handler=None, command=base_name, variant="", description=data.get("description", ""), announce=data.get("announce", ""), resolve_hit=data.get("resolve_hit", ""), resolve_miss=data.get("resolve_miss", ""), telegraph_color=data.get("telegraph_color", "dim"), announce_color=data.get("announce_color", ""), resolve_color=data.get("resolve_color", "bold"), unlock_condition=unlock_condition, ) ] def load_moves(directory: Path) -> dict[str, CombatMove]: """Load all combat moves from TOML files in a directory. Creates a lookup dict keyed by both move names and their aliases. All aliases point to the same CombatMove object. Args: directory: Path to directory containing .toml files Returns: Dict mapping move names and aliases to CombatMove instances Raises: ValueError: If duplicate names or aliases are found """ moves: dict[str, CombatMove] = {} seen_names: set[str] = set() seen_aliases: set[str] = set() all_moves: list[CombatMove] = [] for toml_file in sorted(directory.glob("*.toml")): file_moves = load_move(toml_file) for move in file_moves: # Check for name collisions if move.name in seen_names: msg = f"duplicate move name: {move.name}" raise ValueError(msg) seen_names.add(move.name) # Check for alias collisions for alias in move.aliases: if alias in seen_aliases or alias in seen_names: msg = f"duplicate move alias: {alias}" raise ValueError(msg) seen_aliases.add(alias) # Add to dict by name moves[move.name] = move # Add to dict by all aliases (pointing to same object) for alias in move.aliases: moves[alias] = move all_moves.append(move) # Validate countered_by references for move in all_moves: for counter_name in move.countered_by: if counter_name not in moves: log.warning( "Move '%s' references non-existent counter '%s' in countered_by", move.name, counter_name, ) return moves