167 lines
5.3 KiB
Python
167 lines
5.3 KiB
Python
"""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 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 = ""
|
|
|
|
|
|
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")
|
|
|
|
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", ""),
|
|
)
|
|
)
|
|
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", ""),
|
|
)
|
|
]
|
|
|
|
|
|
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
|