Restructure combat moves: single-word commands with variant args

The DREAMBOOK always described "punch right/left [target]" as one command
with a direction argument, but the implementation had separate TOML files
and multi-word command names that the dispatcher couldn't reach (it only
matches the first word). Aliases like "pr" also couldn't pass targets
because the shared handler tried to re-derive the move from args.

Changes:
- Merge punch_left/right, dodge_left/right, parry_high/low into single
  TOML files with [variants] sections
- Add command/variant fields to CombatMove for tracking move families
- load_move() now returns list[CombatMove], expanding variants
- Handlers bound to moves via closures at registration time:
  variant handler for base commands (punch → parses direction from args),
  direct handler for aliases and simple moves (pr → move already known)
- Core logic in do_attack/do_defend takes a resolved move
- Combat doc rewritten as rst with architecture details
- Simplify mud.tin aliases (pr/pl/etc are built-in MUD commands now)
This commit is contained in:
Jared Miller 2026-02-08 00:20:52 -05:00
parent 91c57d89e1
commit 6344c09275
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
14 changed files with 837 additions and 513 deletions

View file

@ -24,7 +24,7 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv.
## Docs ## Docs
Three categories in `docs/`. Plain text, not markdown. Three categories in `docs/`. Plain text or rst, not markdown.
- `docs/how/` - how things work. write one when you build something non-obvious. - `docs/how/` - how things work. write one when you build something non-obvious.
terrain generation, command system, etc. aimed at someone reading the code terrain generation, command system, etc. aimed at someone reading the code
@ -50,7 +50,9 @@ Update docs when:
- world definitions live in data files, runtime state lives in memory - world definitions live in data files, runtime state lives in memory
- SQLite for persistence (player accounts, progress) - SQLite for persistence (player accounts, progress)
- session mode stack filters what events reach the player (normal/combat/editor) - session mode stack filters what events reach the player (normal/combat/editor)
- combat system: state machine with TOML-defined moves (attacks, defends, counters) - combat system: state machine with TOML-defined moves (attacks, defends, counters). see `docs/how/combat.rst`
- moves with directional variants (punch left/right) use a single TOML with `[variants]` sections
- commands are always single words; direction is an argument, not part of the command name
- content loading: TOML definitions for commands and combat moves, loaded at startup - content loading: TOML definitions for commands and combat moves, loaded at startup
- entity model: Entity base class, Player and Mob subclasses sharing common interface - entity model: Entity base class, Player and Mob subclasses sharing common interface
- editor mode: in-world text editor with syntax highlighting and search/replace - editor mode: in-world text editor with syntax highlighting and search/replace

View file

@ -1,8 +1,10 @@
name = "dodge left" name = "dodge"
aliases = ["dl"]
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
telegraph = ""
timing_window_ms = 800 timing_window_ms = 800
damage_pct = 0.0
countered_by = [] [variants.left]
aliases = ["dl"]
[variants.right]
aliases = ["dr"]

View file

@ -1,8 +0,0 @@
name = "dodge right"
aliases = ["dr"]
move_type = "defense"
stamina_cost = 3.0
telegraph = ""
timing_window_ms = 800
damage_pct = 0.0
countered_by = []

View file

@ -1,8 +1,10 @@
name = "parry low" name = "parry"
aliases = ["v"]
move_type = "defense" move_type = "defense"
stamina_cost = 4.0 stamina_cost = 4.0
telegraph = ""
timing_window_ms = 500 timing_window_ms = 500
damage_pct = 0.0
countered_by = [] [variants.high]
aliases = ["f"]
[variants.low]
aliases = ["v"]

View file

@ -1,8 +0,0 @@
name = "parry high"
aliases = ["f"]
move_type = "defense"
stamina_cost = 4.0
telegraph = ""
timing_window_ms = 500
damage_pct = 0.0
countered_by = []

View file

@ -1,8 +1,15 @@
name = "punch left" name = "punch"
aliases = ["pl"]
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
telegraph = "{attacker} winds up a left hook!"
timing_window_ms = 800 timing_window_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.left]
aliases = ["pl"]
telegraph = "{attacker} winds up a left hook!"
countered_by = ["dodge right", "parry high"] countered_by = ["dodge right", "parry high"]
[variants.right]
aliases = ["pr"]
telegraph = "{attacker} winds up a right hook!"
countered_by = ["dodge left", "parry high"]

View file

@ -1,8 +0,0 @@
name = "punch right"
aliases = ["pr"]
move_type = "attack"
stamina_cost = 5.0
telegraph = "{attacker} winds up a right hook!"
timing_window_ms = 800
damage_pct = 0.15
countered_by = ["dodge left", "parry high"]

294
docs/how/combat.rst Normal file
View file

@ -0,0 +1,294 @@
==============================
combat system — moves as data
==============================
combat in this MUD is designed around the principle that moves are content,
not engine code. the engine provides a state machine and timing system. moves
are data files that define telegraphs, windows, and counters. this keeps the
combat system extensible without touching python.
core design principles
======================
1. combat moves are CONTENT (TOML files in content/combat/)
2. the engine is a STATE MACHINE that processes timing and resolution
3. direction matters — punch right is countered by dodge left
4. no "fight" command — first attack initiates combat
5. after combat starts, target is implicit until encounter ends
command structure
=================
commands are always a single word. the dispatcher splits input on the first
space: first word is the command, the rest is args.
some moves have directional variants. the direction is an argument to the
command, not part of the command name::
punch right frank → command "punch", args "right frank"
punch left → command "punch", args "left"
roundhouse frank → command "roundhouse", args "frank"
sweep → command "sweep", args ""
variant moves (punch, dodge, parry) require a direction. simple moves
(roundhouse, sweep, duck, jump) don't.
each variant has its own short alias registered as a standalone command::
pr frank → "punch right frank" (pr is a direct command)
dl → "dodge left"
f → "parry high"
aliases are defined in the TOML file and registered at startup. they bypass
the variant parsing entirely — the handler already knows which move it is.
the state machine
=================
::
IDLE → TELEGRAPH → WINDOW → RESOLVE → IDLE
IDLE:
no active move. attacker can initiate attack. defender can do nothing
combat-specific.
TELEGRAPH:
attacker has declared a move. defender sees the telegraph message.
"goku winds up a right hook!"
defender can queue a defense during this phase.
duration: brief (implementation decides, not move-defined).
WINDOW:
the timing window opens. defender can still queue defense.
if defender queued correct counter during TELEGRAPH or WINDOW, they succeed.
duration: move.timing_window_ms (defined in TOML).
RESOLVE:
timing window closes. check if defense counters attack.
calculate damage based on attacker PL, damage_pct, and defense success.
apply stamina costs.
check for knockouts (PL = 0) or exhaustion (stamina = 0).
return to IDLE.
entity stats
============
each Entity (player, mob, NPC) has::
pl: float = 100.0
power level. acts as both health AND damage multiplier.
when PL hits 0, entity is knocked out.
stamina: float = 100.0
current stamina. each move costs stamina.
when stamina hits 0, entity passes out.
max_stamina: float = 100.0
stamina ceiling. can be affected by training, items, etc (future).
the move set
============
ATTACKS:
punch right/left (pr/pl)
basic hooks. fast, low cost.
roundhouse (rh)
big spinning kick. higher damage, tighter parry window.
sweep (sw)
leg sweep. knocks down if not countered.
DEFENSES:
dodge right/left (dr/dl)
sidestep. counters opposite-side attacks.
parry high/low (f/v)
block attacks. tighter timing window than dodge.
duck
crouch under high attacks. counters roundhouse.
jump
leap over low attacks. counters sweep.
counter relationships
=====================
defined in each move's TOML file via the "countered_by" field.
::
punch right → countered_by: [dodge left, parry high]
punch left → countered_by: [dodge right, parry high]
roundhouse → countered_by: [duck, parry high, parry low]
sweep → countered_by: [jump, parry low]
the engine doesn't know these relationships. it just checks:
"is defender's move in attacker's move.countered_by list?"
damage formula
==============
if defense succeeds (is in countered_by list)::
damage = 0
defender gets "you countered the attack!" message
if defense fails (wrong move or no move)::
damage = attacker.pl * move.damage_pct
defender.pl -= damage
if no defense was attempted::
damage = attacker.pl * move.damage_pct * 1.5
defender.pl -= damage
defender gets "you took the hit full force!" message
stamina costs are always applied, regardless of outcome::
attacker.stamina -= move.stamina_cost
if defender attempted defense:
defender.stamina -= defense_move.stamina_cost
starting combat
===============
NO explicit "fight" command. instead::
punch right goku
if player is not in combat: starts encounter with goku, pushes "combat" mode
if player is already in combat: error, already fighting someone else
after combat starts, target is implicit::
punch left
dodge right
roundhouse
all commands assume the current opponent.
exiting combat
==============
combat ends when:
- one combatant's PL reaches 0 (knockout)
- one combatant's stamina reaches 0 (exhaustion)
- one combatant flees (future: flee command)
- one combatant is killed (future: lethal combat toggle)
when combat ends, both entities' mode stacks pop back to "normal".
TOML move definitions
=====================
moves live in content/combat/. two formats: simple and variant.
**simple move** (one file, one command)::
# content/combat/roundhouse.toml
name = "roundhouse"
aliases = ["rh"]
move_type = "attack"
stamina_cost = 8.0
telegraph = "{attacker} spins into a roundhouse kick!"
timing_window_ms = 600
damage_pct = 0.25
countered_by = ["duck", "parry high", "parry low"]
**variant move** (one file, directional variants)::
# content/combat/punch.toml
name = "punch"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
damage_pct = 0.15
[variants.left]
aliases = ["pl"]
telegraph = "{attacker} winds up a left hook!"
countered_by = ["dodge right", "parry high"]
[variants.right]
aliases = ["pr"]
telegraph = "{attacker} winds up a right hook!"
countered_by = ["dodge left", "parry high"]
shared properties (stamina_cost, timing_window_ms, damage_pct) are defined at
the top level. variants inherit these and can override them. variant-specific
properties (telegraph, countered_by, aliases) live under [variants.<key>].
each variant produces a CombatMove with a qualified name like "punch left".
the ``command`` field tracks the base command ("punch") and ``variant`` tracks
the key ("left"). simple moves have command=name and variant="".
TOML field reference:
- name: the command name (simple) or base command (variant)
- move_type: "attack" or "defense"
- stamina_cost: stamina consumed when using this move
- timing_window_ms: how long defender has to respond
- telegraph: shown to defender during TELEGRAPH phase. {attacker} replaced
- damage_pct: fraction of attacker's PL dealt as damage
- countered_by: list of qualified move names that counter this move
- aliases: short command aliases registered as standalone commands
handler architecture
====================
three types of command handlers, created at registration time:
**direct handler** — for simple moves (roundhouse, sweep) and variant aliases
(pl, pr). the move is bound at registration via closure. args = target name.
**variant handler** — for base variant commands (punch, dodge, parry). parses
first arg as direction, looks up the variant, passes remaining args as target.
**core functions** — do_attack() and do_defend() contain the actual combat
logic. they take (player, target_args, move) and handle encounter creation,
stamina checks, telegraph sending, etc. handlers call into these.
this means adding a new move = add a TOML file. the registration code groups
variants automatically, creates the right handler type, and registers aliases.
future features (NOT YET IMPLEMENTED)
======================================
stuns:
successful parry stuns opponent for 1 tick.
during stun, engine shows combo sequence in shorthand:
"you can follow up with: pr/pl/sw or pr/pl/rh"
player has narrow window to input the sequence.
combos:
if player executes shown sequence during stun window, bonus damage applied.
after successful combo, chance to chain (diminishing probability).
longer chains = exponentially harder timing.
special moves:
moves that require conditions (opponent stunned, player at high PL, etc).
defined via "requires" field in TOML.
lethal combat:
toggle that makes PL=0 mean death, not knockout.
affects NPC encounters in dangerous zones.
multi-combatant:
extend encounter to support more than 1v1.
each attacker/defender pair still follows same state machine.

View file

@ -1,235 +0,0 @@
combat system — fighting as content
===================================
combat in this MUD is designed around the principle that moves are content,
not engine code. the engine provides a state machine and timing system. moves
are data files that define telegraphs, windows, and counters. this keeps the
combat system extensible without touching python.
core design principles
======================
1. combat moves are CONTENT (TOML files in content/combat/)
2. the engine is a STATE MACHINE that processes timing and resolution
3. direction matters — punch right is countered by dodge left
4. no "fight" command — first attack initiates combat
5. after combat starts, target is implicit until encounter ends
the state machine
=================
IDLE → TELEGRAPH → WINDOW → RESOLVE → IDLE
IDLE:
no active move. attacker can initiate attack. defender can do nothing
combat-specific.
TELEGRAPH:
attacker has declared a move. defender sees the telegraph message.
"goku winds up a right hook!"
defender can queue a defense during this phase.
duration: brief (implementation decides, not move-defined).
WINDOW:
the timing window opens. defender can still queue defense.
if defender queued correct counter during TELEGRAPH or WINDOW, they succeed.
duration: move.timing_window_ms (defined in TOML).
RESOLVE:
timing window closes. check if defense counters attack.
calculate damage based on attacker PL, damage_pct, and defense success.
apply stamina costs.
check for knockouts (PL = 0) or exhaustion (stamina = 0).
return to IDLE.
entity stats
============
each Entity (player, mob, NPC) has:
pl: float = 100.0
power level. acts as both health AND damage multiplier.
when PL hits 0, entity is knocked out.
stamina: float = 100.0
current stamina. each move costs stamina.
when stamina hits 0, entity passes out.
max_stamina: float = 100.0
stamina ceiling. can be affected by training, items, etc (future).
the move set
============
ATTACKS:
punch right (pr)
basic right hook. fast, low cost.
punch left (pl)
basic left hook. fast, low cost.
roundhouse (rh)
big spinning kick. higher damage, tighter parry window.
sweep (sw)
leg sweep. knocks down if not countered.
DEFENSES:
dodge right (dr)
sidestep to the right. counters left-side attacks.
dodge left (dl)
sidestep to the left. counters right-side attacks.
parry high (f)
block high attacks. tighter timing window than dodge.
parry low (v)
block low attacks. tighter timing window than dodge.
duck
crouch under high attacks. counters roundhouse.
jump
leap over low attacks. counters sweep.
counter relationships
=====================
defined in each move's TOML file via the "countered_by" field.
punch right → countered_by: [dodge left, parry high]
punch left → countered_by: [dodge right, parry high]
roundhouse → countered_by: [duck, parry high, parry low]
sweep → countered_by: [jump, parry low]
the engine doesn't know these relationships. it just checks:
"is defender's move in attacker's move.countered_by list?"
damage formula
==============
if defense succeeds (is in countered_by list):
damage = 0
defender gets "you countered the attack!" message
if defense fails (wrong move or no move):
damage = attacker.pl * move.damage_pct
defender.pl -= damage
if no defense was attempted:
damage = attacker.pl * move.damage_pct * 1.5
defender.pl -= damage
defender gets "you took the hit full force!" message
stamina costs are always applied, regardless of outcome:
attacker.stamina -= move.stamina_cost
if defender attempted defense:
defender.stamina -= defense_move.stamina_cost
starting combat
===============
NO explicit "fight" command. instead:
punch right goku
if player is not in combat: starts encounter with goku, pushes "combat" mode
if player is already in combat: error, already fighting someone else
after combat starts, target is implicit:
punch left
dodge right
roundhouse
all commands assume the current opponent.
exiting combat
==============
combat ends when:
- one combatant's PL reaches 0 (knockout)
- one combatant's stamina reaches 0 (exhaustion)
- one combatant flees (future: flee command)
- one combatant is killed (future: lethal combat toggle)
when combat ends, both entities' mode stacks pop back to "normal".
TOML move definition
=====================
example: content/combat/punch_right.toml
name = "punch right"
aliases = ["pr"]
move_type = "attack"
stamina_cost = 5.0
telegraph = "{attacker} winds up a right hook!"
timing_window_ms = 800
damage_pct = 0.15
countered_by = ["dodge left", "parry high"]
move_type: "attack" or "defense"
telegraph: shown to defender during TELEGRAPH phase. {attacker} replaced with name
timing_window_ms: how long defender has to respond
damage_pct: fraction of attacker's PL dealt as damage
countered_by: list of move names that counter this move
future features (NOT YET IMPLEMENTED)
======================================
stuns:
successful parry stuns opponent for 1 tick.
during stun, engine shows combo sequence in shorthand:
"you can follow up with: pr/pl/sw or pr/pl/rh"
player has narrow window to input the sequence.
combos:
if player executes shown sequence during stun window, bonus damage applied.
after successful combo, chance to chain (diminishing probability).
longer chains = exponentially harder timing.
special moves:
moves that require conditions (opponent stunned, player at high PL, etc).
defined via "requires" field in TOML.
lethal combat:
toggle that makes PL=0 mean death, not knockout.
affects NPC encounters in dangerous zones.
multi-combatant:
extend encounter to support more than 1v1.
each attacker/defender pair still follows same state machine.
implementation notes
====================
the engine provides:
- CombatMove dataclass + TOML loader
- CombatEncounter state machine (attack/defend/tick/resolve)
- global encounter list + management functions
- process_combat() called each game loop tick
- command handlers that look up moves and call encounter methods
content provides:
- TOML files for each move in content/combat/
- balance tuning (stamina costs, damage_pct, timing windows)
the command registry makes moves available as commands automatically.
"punch right" is both a move name AND a command name. the command handler
looks up the move definition and feeds it to the encounter.
this keeps the python side generic. adding a new move = add a TOML file,
no code changes. balance changes = edit TOML values, no restart needed
(future: hot-reload content).

View file

@ -12,12 +12,7 @@
#alias {fse} {fly southeast} #alias {fse} {fly southeast}
#alias {fsw} {fly southwest} #alias {fsw} {fly southwest}
#NOP combat aliases #NOP combat aliases (pr/pl/dr/dl/f/v are built into the MUD)
#alias {pr} {punch right} #NOP these are extras for single-key convenience
#alias {pl} {punch left}
#alias {o} {sweep} #alias {o} {sweep}
#alias {r} {roundhouse} #alias {r} {roundhouse}
#alias {f} {parry high}
#alias {v} {parry low}
#alias {dr} {dodge right}
#alias {dl} {dodge left}

View file

@ -1,5 +1,6 @@
"""Combat command handlers.""" """Combat command handlers."""
from collections import defaultdict
from pathlib import Path from pathlib import Path
from mudlib.combat.engine import get_encounter, start_encounter from mudlib.combat.engine import get_encounter, start_encounter
@ -11,46 +12,22 @@ from mudlib.player import Player, players
combat_moves: dict[str, CombatMove] = {} combat_moves: dict[str, CombatMove] = {}
async def cmd_attack(player: Player, args: str) -> None: async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
"""Handle attack commands. """Core attack logic with a resolved move.
Args: Args:
player: The attacking player player: The attacking player
args: Command arguments (move name and optional target name) target_args: Remaining args after move resolution (just the target name)
move: The resolved combat move
""" """
# Get or create encounter
encounter = get_encounter(player) encounter = get_encounter(player)
# Parse arguments: move name (possibly multi-word) and optional target name # Parse target from args
parts = args.strip().split()
if not parts:
await player.send("Attack with what move?\r\n")
return
# If not in combat, last word might be target name
target = None target = None
move_name = args.strip() target_name = target_args.strip()
if encounter is None and target_name:
if encounter is None and len(parts) > 1:
# Try to extract target from last word
target_name = parts[-1]
target = players.get(target_name) target = players.get(target_name)
if target is not None:
# Remove target name from move_name
move_name = " ".join(parts[:-1])
# Look up the move
move = combat_moves.get(move_name.lower())
if move is None:
await player.send(f"Unknown move: {move_name}\r\n")
return
# Check if it's an attack move
if move.move_type != "attack":
await player.send(f"{move.name} is not an attack move.\r\n")
return
# Check stamina # Check stamina
if player.stamina < move.stamina_cost: if player.stamina < move.stamina_cost:
await player.send("You don't have enough stamina for that move.\r\n") await player.send("You don't have enough stamina for that move.\r\n")
@ -91,12 +68,13 @@ async def cmd_attack(player: Player, args: str) -> None:
await player.send(f"You use {move.name}!\r\n") await player.send(f"You use {move.name}!\r\n")
async def cmd_defend(player: Player, args: str) -> None: async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
"""Handle defense commands. """Core defense logic with a resolved move.
Args: Args:
player: The defending player player: The defending player
args: Command arguments (move name) _args: Unused (defense moves don't take a target)
move: The resolved combat move
""" """
# Check if in combat # Check if in combat
encounter = get_encounter(player) encounter = get_encounter(player)
@ -104,23 +82,6 @@ async def cmd_defend(player: Player, args: str) -> None:
await player.send("You're not in combat.\r\n") await player.send("You're not in combat.\r\n")
return return
# Parse move name
move_name = args.strip()
if not move_name:
await player.send("Defend with what move?\r\n")
return
# Look up the move
move = combat_moves.get(move_name.lower())
if move is None:
await player.send(f"Unknown move: {move_name}\r\n")
return
# Check if it's a defense move
if move.move_type != "defense":
await player.send(f"{move.name} is not a defense move.\r\n")
return
# Check stamina # Check stamina
if player.stamina < move.stamina_cost: if player.stamina < move.stamina_cost:
await player.send("You don't have enough stamina for that move.\r\n") await player.send("You don't have enough stamina for that move.\r\n")
@ -131,6 +92,49 @@ async def cmd_defend(player: Player, args: str) -> None:
await player.send(f"You attempt to {move.name}!\r\n") await player.send(f"You attempt to {move.name}!\r\n")
def _make_direct_handler(move: CombatMove, handler_fn):
"""Create a handler bound to a specific move.
Used for simple moves (roundhouse) and variant aliases (pl, pr).
Args are just the target name.
"""
async def handler(player: Player, args: str) -> None:
await handler_fn(player, args, move)
return handler
def _make_variant_handler(
base_name: str, variant_moves: dict[str, CombatMove], handler_fn
):
"""Create a handler for a move with directional variants.
Used for base commands like "punch" where the first arg is the direction.
"""
async def handler(player: Player, args: str) -> None:
parts = args.strip().split(maxsplit=1)
if not parts:
variants = "/".join(variant_moves.keys())
await player.send(f"{base_name.capitalize()} which way? ({variants})\r\n")
return
variant_key = parts[0].lower()
move = variant_moves.get(variant_key)
if move is None:
variants = "/".join(variant_moves.keys())
await player.send(
f"Unknown {base_name} direction: {variant_key}. Try: {variants}\r\n"
)
return
target_args = parts[1] if len(parts) > 1 else ""
await handler_fn(player, target_args, move)
return handler
def register_combat_commands(content_dir: Path) -> None: def register_combat_commands(content_dir: Path) -> None:
"""Load and register all combat moves as commands. """Load and register all combat moves as commands.
@ -142,35 +146,70 @@ def register_combat_commands(content_dir: Path) -> None:
# Load all moves from content directory # Load all moves from content directory
combat_moves = load_moves(content_dir) combat_moves = load_moves(content_dir)
# Track which moves we've registered (don't register aliases separately) # Group variant moves by their base command
registered_moves: set[str] = set() variant_groups: dict[str, dict[str, CombatMove]] = defaultdict(dict)
simple_moves: list[CombatMove] = []
registered_names: set[str] = set()
for _move_name, move in combat_moves.items(): for move in combat_moves.values():
# Only register each move once (by its canonical name) if move.name in registered_names:
if move.name in registered_moves:
continue continue
registered_names.add(move.name)
registered_moves.add(move.name) if move.variant:
variant_groups[move.command][move.variant] = move
else:
simple_moves.append(move)
if move.move_type == "attack": # Register simple moves (roundhouse, sweep, duck, jump)
# Attack moves work from any mode (can initiate combat) for move in simple_moves:
handler_fn = do_attack if move.move_type == "attack" else do_defend
mode = "*" if move.move_type == "attack" else "combat"
action = "Attack" if move.move_type == "attack" else "Defend"
register( register(
CommandDefinition( CommandDefinition(
name=move.name, name=move.name,
handler=cmd_attack, handler=_make_direct_handler(move, handler_fn),
aliases=move.aliases, aliases=move.aliases,
mode="*", mode=mode,
help=f"Attack with {move.name}", help=f"{action} with {move.name}",
) )
) )
elif move.move_type == "defense":
# Defense moves only work in combat mode # Register variant moves (punch, dodge, parry)
for base_name, variants in variant_groups.items():
# Determine type from first variant
first_variant = next(iter(variants.values()))
handler_fn = do_attack if first_variant.move_type == "attack" else do_defend
mode = "*" if first_variant.move_type == "attack" else "combat"
# Collect all variant aliases for the base command
all_aliases = []
for move in variants.values():
all_aliases.extend(move.aliases)
# Register base command with variant handler (e.g. "punch")
action = "Attack" if first_variant.move_type == "attack" else "Defend"
register( register(
CommandDefinition( CommandDefinition(
name=move.name, name=base_name,
handler=cmd_defend, handler=_make_variant_handler(base_name, variants, handler_fn),
aliases=move.aliases, aliases=[],
mode="combat", mode=mode,
help=f"Defend with {move.name}", help=f"{action} with {base_name}",
)
)
# Register each variant's aliases as direct commands (e.g. "pl" → punch left)
for move in variants.values():
for alias in move.aliases:
action = "Attack" if move.move_type == "attack" else "Defend"
register(
CommandDefinition(
name=alias,
handler=_make_direct_handler(move, handler_fn),
aliases=[],
mode=mode,
help=f"{action} with {move.name}",
) )
) )

View file

@ -25,16 +25,24 @@ class CombatMove:
damage_pct: float = 0.0 damage_pct: float = 0.0
countered_by: list[str] = field(default_factory=list) countered_by: list[str] = field(default_factory=list)
handler: CommandHandler | None = None 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 = ""
def load_move(path: Path) -> CombatMove: def load_move(path: Path) -> list[CombatMove]:
"""Load a combat move from a TOML file. """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: Args:
path: Path to TOML file path: Path to TOML file
Returns: Returns:
CombatMove instance List of CombatMove instances (one per variant, or one for simple moves)
Raises: Raises:
ValueError: If required fields are missing ValueError: If required fields are missing
@ -49,9 +57,40 @@ def load_move(path: Path) -> CombatMove:
msg = f"missing required field: {field_name}" msg = f"missing required field: {field_name}"
raise ValueError(msg) raise ValueError(msg)
# Build move with defaults for optional fields base_name = data["name"]
return CombatMove( variants = data.get("variants")
name=data["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,
)
)
return moves
# Simple move (no variants)
return [
CombatMove(
name=base_name,
move_type=data["move_type"], move_type=data["move_type"],
stamina_cost=data["stamina_cost"], stamina_cost=data["stamina_cost"],
timing_window_ms=data["timing_window_ms"], timing_window_ms=data["timing_window_ms"],
@ -59,8 +98,11 @@ def load_move(path: Path) -> CombatMove:
telegraph=data.get("telegraph", ""), telegraph=data.get("telegraph", ""),
damage_pct=data.get("damage_pct", 0.0), damage_pct=data.get("damage_pct", 0.0),
countered_by=data.get("countered_by", []), countered_by=data.get("countered_by", []),
handler=None, # Future: parse handler reference handler=None,
command=base_name,
variant="",
) )
]
def load_moves(directory: Path) -> dict[str, CombatMove]: def load_moves(directory: Path) -> dict[str, CombatMove]:
@ -84,8 +126,9 @@ def load_moves(directory: Path) -> dict[str, CombatMove]:
all_moves: list[CombatMove] = [] all_moves: list[CombatMove] = []
for toml_file in sorted(directory.glob("*.toml")): for toml_file in sorted(directory.glob("*.toml")):
move = load_move(toml_file) file_moves = load_move(toml_file)
for move in file_moves:
# Check for name collisions # Check for name collisions
if move.name in seen_names: if move.name in seen_names:
msg = f"duplicate move name: {move.name}" msg = f"duplicate move name: {move.name}"

View file

@ -63,10 +63,31 @@ def inject_moves(moves):
combat_commands.combat_moves = {} combat_commands.combat_moves = {}
@pytest.fixture
def punch_right(moves):
"""Get the punch right move."""
return moves["punch right"]
@pytest.fixture
def punch_left(moves):
"""Get the punch left move."""
return moves["punch left"]
@pytest.fixture
def dodge_left(moves):
"""Get the dodge left move."""
return moves["dodge left"]
# --- do_attack tests ---
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_attack_starts_combat_with_target(player, target): async def test_attack_starts_combat_with_target(player, target, punch_right):
"""Test attack command with target starts combat encounter.""" """Test do_attack with target starts combat encounter."""
await combat_commands.cmd_attack(player, "punch right Vegeta") await combat_commands.do_attack(player, "Vegeta", punch_right)
encounter = get_encounter(player) encounter = get_encounter(player)
assert encounter is not None assert encounter is not None
@ -76,26 +97,28 @@ async def test_attack_starts_combat_with_target(player, target):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_attack_without_target_when_not_in_combat(player): async def test_attack_without_target_when_not_in_combat(player, punch_right):
"""Test attack without target when not in combat gives error.""" """Test do_attack without target when not in combat gives error."""
await combat_commands.cmd_attack(player, "punch right") await combat_commands.do_attack(player, "", punch_right)
player.writer.write.assert_called() player.writer.write.assert_called()
message = player.writer.write.call_args[0][0] message = player.writer.write.call_args[0][0]
assert "not in combat" in message.lower() or "need a target" in message.lower() assert "need a target" in message.lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_attack_without_target_when_in_combat(player, target): async def test_attack_without_target_when_in_combat(
"""Test attack without target when in combat uses implicit target.""" player, target, punch_right, punch_left
):
"""Test do_attack without target when in combat uses implicit target."""
# Start combat first # Start combat first
await combat_commands.cmd_attack(player, "punch right Vegeta") await combat_commands.do_attack(player, "Vegeta", punch_right)
# Reset mock to track new calls # Reset mock to track new calls
player.writer.write.reset_mock() player.writer.write.reset_mock()
# Attack without target should work # Attack without target should work
await combat_commands.cmd_attack(player, "punch left") await combat_commands.do_attack(player, "", punch_left)
encounter = get_encounter(player) encounter = get_encounter(player)
assert encounter is not None assert encounter is not None
@ -104,31 +127,21 @@ async def test_attack_without_target_when_in_combat(player, target):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_attack_unknown_move(player, target): async def test_attack_insufficient_stamina(player, target, punch_right):
"""Test attack with unknown move name gives error.""" """Test do_attack with insufficient stamina gives error."""
await combat_commands.cmd_attack(player, "kamehameha Vegeta")
player.writer.write.assert_called()
message = player.writer.write.call_args[0][0]
assert "unknown" in message.lower() or "don't know" in message.lower()
@pytest.mark.asyncio
async def test_attack_insufficient_stamina(player, target):
"""Test attack with insufficient stamina gives error."""
player.stamina = 1.0 # Not enough for punch (costs 5) player.stamina = 1.0 # Not enough for punch (costs 5)
await combat_commands.cmd_attack(player, "punch right Vegeta") await combat_commands.do_attack(player, "Vegeta", punch_right)
player.writer.write.assert_called() player.writer.write.assert_called()
message = player.writer.write.call_args[0][0] message = player.writer.write.call_args[0][0]
assert "stamina" in message.lower() or "exhausted" in message.lower() assert "stamina" in message.lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_attack_sends_telegraph_to_defender(player, target): async def test_attack_sends_telegraph_to_defender(player, target, punch_right):
"""Test attack sends telegraph message to defender.""" """Test do_attack sends telegraph message to defender."""
await combat_commands.cmd_attack(player, "punch right Vegeta") await combat_commands.do_attack(player, "Vegeta", punch_right)
# Check that encounter has the move # Check that encounter has the move
encounter = get_encounter(player) encounter = get_encounter(player)
@ -137,10 +150,13 @@ async def test_attack_sends_telegraph_to_defender(player, target):
assert encounter.current_move.name == "punch right" assert encounter.current_move.name == "punch right"
# --- do_defend tests ---
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_defense_only_in_combat(player): async def test_defense_only_in_combat(player, dodge_left):
"""Test defense command only works in combat mode.""" """Test do_defend only works in combat mode."""
await combat_commands.cmd_defend(player, "dodge left") await combat_commands.do_defend(player, "", dodge_left)
player.writer.write.assert_called() player.writer.write.assert_called()
message = player.writer.write.call_args[0][0] message = player.writer.write.call_args[0][0]
@ -148,10 +164,10 @@ async def test_defense_only_in_combat(player):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_defense_records_pending_defense(player, target): async def test_defense_records_pending_defense(player, target, punch_right, dodge_left):
"""Test defense command records the defense move.""" """Test do_defend records the defense move."""
# Start combat # Start combat
await combat_commands.cmd_attack(player, "punch right Vegeta") await combat_commands.do_attack(player, "Vegeta", punch_right)
player.writer.write.reset_mock() player.writer.write.reset_mock()
# Switch to defender's perspective # Switch to defender's perspective
@ -159,7 +175,7 @@ async def test_defense_records_pending_defense(player, target):
target.mode_stack = ["combat"] target.mode_stack = ["combat"]
# Defend # Defend
await combat_commands.cmd_defend(target, "dodge left") await combat_commands.do_defend(target, "", dodge_left)
encounter = get_encounter(target) encounter = get_encounter(target)
assert encounter is not None assert encounter is not None
@ -168,60 +184,107 @@ async def test_defense_records_pending_defense(player, target):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_defense_unknown_move(player, target): async def test_defense_insufficient_stamina(player, target, punch_right, dodge_left):
"""Test defense with unknown move gives error.""" """Test do_defend with insufficient stamina gives error."""
# Start combat # Start combat
await combat_commands.cmd_attack(player, "punch right Vegeta") await combat_commands.do_attack(player, "Vegeta", punch_right)
target.writer = player.writer
target.mode_stack = ["combat"]
player.writer.write.reset_mock()
await combat_commands.cmd_defend(target, "teleport")
target.writer.write.assert_called()
message = target.writer.write.call_args[0][0]
assert "unknown" in message.lower() or "don't know" in message.lower()
@pytest.mark.asyncio
async def test_defense_insufficient_stamina(player, target):
"""Test defense with insufficient stamina gives error."""
# Start combat
await combat_commands.cmd_attack(player, "punch right Vegeta")
target.writer = player.writer target.writer = player.writer
target.mode_stack = ["combat"] target.mode_stack = ["combat"]
target.stamina = 1.0 # Not enough for dodge (costs 3) target.stamina = 1.0 # Not enough for dodge (costs 3)
player.writer.write.reset_mock() player.writer.write.reset_mock()
await combat_commands.cmd_defend(target, "dodge left") await combat_commands.do_defend(target, "", dodge_left)
target.writer.write.assert_called() target.writer.write.assert_called()
message = target.writer.write.call_args[0][0] message = target.writer.write.call_args[0][0]
assert "stamina" in message.lower() or "exhausted" in message.lower() assert "stamina" in message.lower()
# --- variant handler tests ---
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_attack_alias_works(player, target): async def test_variant_handler_parses_direction(player, target, moves):
"""Test attack using alias (pr for punch right).""" """Test the variant handler parses direction from args."""
await combat_commands.cmd_attack(player, "pr Vegeta") variant_moves = {
"left": moves["punch left"],
"right": moves["punch right"],
}
handler = combat_commands._make_variant_handler(
"punch", variant_moves, combat_commands.do_attack
)
await handler(player, "right Vegeta")
encounter = get_encounter(player) encounter = get_encounter(player)
assert encounter is not None assert encounter is not None
assert encounter.current_move is not None
assert encounter.current_move.name == "punch right" assert encounter.current_move.name == "punch right"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_defense_alias_works(player, target): async def test_variant_handler_no_direction(player, moves):
"""Test defense using alias (dl for dodge left).""" """Test the variant handler prompts when no direction given."""
# Start combat variant_moves = {
await combat_commands.cmd_attack(player, "punch right Vegeta") "left": moves["punch left"],
target.writer = player.writer "right": moves["punch right"],
target.mode_stack = ["combat"] }
handler = combat_commands._make_variant_handler(
"punch", variant_moves, combat_commands.do_attack
)
await combat_commands.cmd_defend(target, "dl") await handler(player, "")
encounter = get_encounter(target) player.writer.write.assert_called()
message = player.writer.write.call_args[0][0]
assert "which way" in message.lower()
@pytest.mark.asyncio
async def test_variant_handler_bad_direction(player, moves):
"""Test the variant handler rejects invalid direction."""
variant_moves = {
"left": moves["punch left"],
"right": moves["punch right"],
}
handler = combat_commands._make_variant_handler(
"punch", variant_moves, combat_commands.do_attack
)
await handler(player, "up Vegeta")
player.writer.write.assert_called()
message = player.writer.write.call_args[0][0]
assert "unknown" in message.lower()
# --- direct handler tests ---
@pytest.mark.asyncio
async def test_direct_handler_passes_move(player, target, punch_right):
"""Test the direct handler passes the bound move through."""
handler = combat_commands._make_direct_handler(
punch_right, combat_commands.do_attack
)
await handler(player, "Vegeta")
encounter = get_encounter(player)
assert encounter is not None assert encounter is not None
assert encounter.pending_defense is not None assert encounter.current_move.name == "punch right"
assert encounter.pending_defense.name == "dodge left"
@pytest.mark.asyncio
async def test_direct_handler_alias_for_variant(player, target, punch_right):
"""Test alias handler (e.g. pr) works for variant moves."""
handler = combat_commands._make_direct_handler(
punch_right, combat_commands.do_attack
)
await handler(player, "Vegeta")
encounter = get_encounter(player)
assert encounter is not None
assert encounter.current_move.name == "punch right"
assert encounter.attacker is player
assert encounter.defender is target

View file

@ -16,6 +16,8 @@ def test_combat_move_dataclass():
timing_window_ms=800, timing_window_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left", "parry high"], countered_by=["dodge left", "parry high"],
command="punch",
variant="right",
) )
assert move.name == "punch right" assert move.name == "punch right"
assert move.move_type == "attack" assert move.move_type == "attack"
@ -26,6 +28,8 @@ def test_combat_move_dataclass():
assert move.damage_pct == 0.15 assert move.damage_pct == 0.15
assert move.countered_by == ["dodge left", "parry high"] assert move.countered_by == ["dodge left", "parry high"]
assert move.handler is None assert move.handler is None
assert move.command == "punch"
assert move.variant == "right"
def test_combat_move_minimal(): def test_combat_move_minimal():
@ -44,32 +48,115 @@ def test_combat_move_minimal():
assert move.timing_window_ms == 500 assert move.timing_window_ms == 500
assert move.damage_pct == 0.0 assert move.damage_pct == 0.0
assert move.countered_by == [] assert move.countered_by == []
assert move.command == ""
assert move.variant == ""
def test_load_move_from_toml(tmp_path): def test_load_simple_move_from_toml(tmp_path):
"""Test loading a combat move from TOML file.""" """Test loading a simple combat move from TOML file."""
toml_content = """ toml_content = """
name = "punch right" name = "roundhouse"
aliases = ["pr"] aliases = ["rh"]
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 8.0
telegraph = "{attacker} winds up a right hook!" telegraph = "{attacker} spins into a roundhouse kick!"
timing_window_ms = 800 timing_window_ms = 600
damage_pct = 0.15 damage_pct = 0.25
countered_by = ["dodge left", "parry high"] countered_by = ["duck", "parry high", "parry low"]
""" """
toml_file = tmp_path / "punch_right.toml" toml_file = tmp_path / "roundhouse.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)
move = load_move(toml_file) moves = load_move(toml_file)
assert move.name == "punch right" assert len(moves) == 1
assert move.move_type == "attack" move = moves[0]
assert move.aliases == ["pr"] assert move.name == "roundhouse"
assert move.stamina_cost == 5.0 assert move.command == "roundhouse"
assert move.telegraph == "{attacker} winds up a right hook!" assert move.variant == ""
assert move.timing_window_ms == 800 assert move.aliases == ["rh"]
assert move.damage_pct == 0.15 assert move.damage_pct == 0.25
assert move.countered_by == ["dodge left", "parry high"]
def test_load_variant_move_from_toml(tmp_path):
"""Test loading a variant combat move from TOML file."""
toml_content = """
name = "punch"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
damage_pct = 0.15
[variants.left]
aliases = ["pl"]
telegraph = "{attacker} winds up a left hook!"
countered_by = ["dodge right", "parry high"]
[variants.right]
aliases = ["pr"]
telegraph = "{attacker} winds up a right hook!"
countered_by = ["dodge left", "parry high"]
"""
toml_file = tmp_path / "punch.toml"
toml_file.write_text(toml_content)
moves = load_move(toml_file)
assert len(moves) == 2
by_name = {m.name: m for m in moves}
assert "punch left" in by_name
assert "punch right" in by_name
left = by_name["punch left"]
assert left.command == "punch"
assert left.variant == "left"
assert left.aliases == ["pl"]
assert left.telegraph == "{attacker} winds up a left hook!"
assert left.countered_by == ["dodge right", "parry high"]
# Inherited from parent
assert left.stamina_cost == 5.0
assert left.timing_window_ms == 800
assert left.damage_pct == 0.15
right = by_name["punch right"]
assert right.command == "punch"
assert right.variant == "right"
assert right.aliases == ["pr"]
assert right.countered_by == ["dodge left", "parry high"]
def test_variant_inherits_shared_properties(tmp_path):
"""Test that variants inherit shared properties and can override them."""
toml_content = """
name = "kick"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
damage_pct = 0.10
[variants.low]
aliases = ["kl"]
damage_pct = 0.08
timing_window_ms = 600
[variants.high]
aliases = ["kh"]
damage_pct = 0.15
"""
toml_file = tmp_path / "kick.toml"
toml_file.write_text(toml_content)
moves = load_move(toml_file)
by_name = {m.name: m for m in moves}
low = by_name["kick low"]
assert low.damage_pct == 0.08
assert low.timing_window_ms == 600 # overridden
assert low.stamina_cost == 5.0 # inherited
high = by_name["kick high"]
assert high.damage_pct == 0.15
assert high.timing_window_ms == 800 # inherited
assert high.stamina_cost == 5.0 # inherited
def test_load_move_with_defaults(tmp_path): def test_load_move_with_defaults(tmp_path):
@ -83,7 +170,9 @@ timing_window_ms = 600
toml_file = tmp_path / "basic.toml" toml_file = tmp_path / "basic.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)
move = load_move(toml_file) moves = load_move(toml_file)
assert len(moves) == 1
move = moves[0]
assert move.name == "basic move" assert move.name == "basic move"
assert move.aliases == [] assert move.aliases == []
assert move.telegraph == "" assert move.telegraph == ""
@ -149,32 +238,31 @@ stamina_cost = 5.0
def test_load_moves_from_directory(tmp_path): def test_load_moves_from_directory(tmp_path):
"""Test loading all moves from a directory.""" """Test loading all moves from a directory."""
# Create multiple TOML files # Create a variant move
punch_toml = tmp_path / "punch_right.toml" punch_toml = tmp_path / "punch.toml"
punch_toml.write_text( punch_toml.write_text(
""" """
name = "punch right" name = "punch"
aliases = ["pr"]
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
telegraph = "{attacker} winds up a right hook!"
timing_window_ms = 800 timing_window_ms = 800
damage_pct = 0.15 damage_pct = 0.15
countered_by = ["dodge left", "parry high"]
[variants.right]
aliases = ["pr"]
telegraph = "{attacker} winds up a right hook!"
countered_by = ["dodge left"]
""" """
) )
dodge_toml = tmp_path / "dodge_left.toml" # Create a simple move
dodge_toml = tmp_path / "dodge.toml"
dodge_toml.write_text( dodge_toml.write_text(
""" """
name = "dodge left" name = "duck"
aliases = ["dl"]
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
telegraph = ""
timing_window_ms = 500 timing_window_ms = 500
damage_pct = 0.0
countered_by = []
""" """
) )
@ -184,19 +272,19 @@ countered_by = []
moves = load_moves(tmp_path) moves = load_moves(tmp_path)
# Should have entries for both names and all aliases # Should have entries for variant qualified names, aliases, and simple moves
assert "punch right" in moves assert "punch right" in moves
assert "pr" in moves assert "pr" in moves
assert "dodge left" in moves assert "duck" in moves
assert "dl" in moves
# All aliases should point to the same object # Aliases should point to the same object
assert moves["punch right"] is moves["pr"] assert moves["punch right"] is moves["pr"]
assert moves["dodge left"] is moves["dl"]
# Check move properties # Check variant properties
assert moves["punch right"].name == "punch right" assert moves["punch right"].command == "punch"
assert moves["dodge left"].name == "dodge left" assert moves["punch right"].variant == "right"
assert moves["duck"].command == "duck"
assert moves["duck"].variant == ""
def test_load_moves_empty_directory(tmp_path): def test_load_moves_empty_directory(tmp_path):
@ -264,26 +352,30 @@ def test_load_moves_validates_countered_by_refs(tmp_path, caplog):
"""Test that invalid countered_by references log warnings.""" """Test that invalid countered_by references log warnings."""
import logging import logging
# Create a move with an invalid counter reference punch_toml = tmp_path / "punch.toml"
punch_toml = tmp_path / "punch_right.toml"
punch_toml.write_text( punch_toml.write_text(
""" """
name = "punch right" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 800 timing_window_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.right]
countered_by = ["dodge left", "nonexistent move"] countered_by = ["dodge left", "nonexistent move"]
""" """
) )
dodge_toml = tmp_path / "dodge_left.toml" dodge_toml = tmp_path / "dodge.toml"
dodge_toml.write_text( dodge_toml.write_text(
""" """
name = "dodge left" name = "dodge"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
timing_window_ms = 500 timing_window_ms = 500
[variants.left]
aliases = ["dl"]
""" """
) )
@ -303,36 +395,43 @@ def test_load_moves_valid_countered_by_refs_no_warning(tmp_path, caplog):
"""Test that valid countered_by references don't log warnings.""" """Test that valid countered_by references don't log warnings."""
import logging import logging
# Create moves with valid counter references punch_toml = tmp_path / "punch.toml"
punch_toml = tmp_path / "punch_right.toml"
punch_toml.write_text( punch_toml.write_text(
""" """
name = "punch right" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 800 timing_window_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.right]
countered_by = ["dodge left", "parry high"] countered_by = ["dodge left", "parry high"]
""" """
) )
dodge_toml = tmp_path / "dodge_left.toml" dodge_toml = tmp_path / "dodge.toml"
dodge_toml.write_text( dodge_toml.write_text(
""" """
name = "dodge left" name = "dodge"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
timing_window_ms = 500 timing_window_ms = 500
[variants.left]
aliases = ["dl"]
""" """
) )
parry_toml = tmp_path / "parry_high.toml" parry_toml = tmp_path / "parry.toml"
parry_toml.write_text( parry_toml.write_text(
""" """
name = "parry high" name = "parry"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
timing_window_ms = 500 timing_window_ms = 500
[variants.high]
aliases = ["f"]
""" """
) )
@ -346,3 +445,40 @@ timing_window_ms = 500
# Should have no warnings # Should have no warnings
assert len(caplog.records) == 0 assert len(caplog.records) == 0
def test_load_content_combat_directory():
"""Test loading the actual content/combat directory."""
from pathlib import Path
content_dir = Path(__file__).parent.parent / "content" / "combat"
moves = load_moves(content_dir)
# Verify variant moves have correct structure
assert "punch left" in moves
assert "punch right" in moves
assert moves["punch left"].command == "punch"
assert moves["punch left"].variant == "left"
assert "dodge left" in moves
assert "dodge right" in moves
assert moves["dodge left"].command == "dodge"
assert "parry high" in moves
assert "parry low" in moves
assert moves["parry high"].command == "parry"
# Verify simple moves
assert "roundhouse" in moves
assert moves["roundhouse"].command == "roundhouse"
assert moves["roundhouse"].variant == ""
assert "sweep" in moves
assert "duck" in moves
assert "jump" in moves
# Verify aliases
assert "pl" in moves
assert moves["pl"] is moves["punch left"]
assert "pr" in moves
assert moves["pr"] is moves["punch right"]