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.
218 lines
7.6 KiB
Python
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
|