mud/src/mudlib/combat/moves.py
Jared Miller a2efd16390
Add skill unlock system with TOML conditions and gating
UnlockCondition on CombatMove, parsed from [unlock] TOML section.
check_unlocks evaluates kill_count and mob_kills thresholds.
Locked moves rejected with "You haven't learned that yet." in
do_attack/do_defend. New unlocks announced after kills.
2026-02-14 11:40:46 -05:00

218 lines
7.6 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 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