mud/src/mudlib/combat/moves.py

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