Compare commits

..

No commits in common. "6344c0927593929807533916cf522993454b0a16" and "8f3455ce0a09aa9693ec8b041d98f2d930dc0742" have entirely different histories.

16 changed files with 516 additions and 900 deletions

View file

@ -4,7 +4,7 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv.
## Commands
- `just check` - lint (ruff --fix + format), typecheck (ty), test (pytest)
- `just check` - lint (ruff --fix + format), typecheck (pyright), test (pytest)
- `just lint` / `just typecheck` / `just test` - individual steps
- `just run` - start the server (`python -m mudlib`)
- `just debug` - start with debug logging
@ -14,7 +14,6 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv.
- `src/mudlib/` - the engine (commands, world, combat, render, store)
- `tests/` - pytest tests
- `content/` - content definitions (commands, combat moves) loaded at runtime
- `worlds/` - world definition files (yaml/toml, version controlled)
- `docs/` - project knowledge (see below)
- `DREAMBOOK.md` - the vision, philosophy, wild ideas. not a spec
@ -24,7 +23,7 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv.
## Docs
Three categories in `docs/`. Plain text or rst, not markdown.
Three categories in `docs/`. Plain text, not markdown.
- `docs/how/` - how things work. write one when you build something non-obvious.
terrain generation, command system, etc. aimed at someone reading the code
@ -50,12 +49,6 @@ Update docs when:
- world definitions live in data files, runtime state lives in memory
- SQLite for persistence (player accounts, progress)
- session mode stack filters what events reach the player (normal/combat/editor)
- 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
- entity model: Entity base class, Player and Mob subclasses sharing common interface
- editor mode: in-world text editor with syntax highlighting and search/replace
## Style

View file

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

View file

@ -0,0 +1,8 @@
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

@ -0,0 +1,8 @@
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,10 +1,8 @@
name = "parry"
name = "parry low"
aliases = ["v"]
move_type = "defense"
stamina_cost = 4.0
telegraph = ""
timing_window_ms = 500
[variants.high]
aliases = ["f"]
[variants.low]
aliases = ["v"]
damage_pct = 0.0
countered_by = []

View file

@ -1,15 +1,8 @@
name = "punch"
name = "punch left"
aliases = ["pl"]
move_type = "attack"
stamina_cost = 5.0
telegraph = "{attacker} winds up a left hook!"
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"]

View file

@ -0,0 +1,8 @@
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"]

View file

@ -2,10 +2,10 @@ architecture plan — designing for programmability
====================================================
where we are and where we're going. we have a working telnet MUD with
procedural terrain, movement, viewport rendering, effects. we've built the
command system, game loop, mode stack, content loading, entity model, combat,
persistence, and editor mode. in-world programming remains ahead. this doc
captured what we needed to get right in the foundation — most of it is done.
procedural terrain, movement, viewport rendering, effects. combat, persistence,
session modes, editor, in-world programming — all ahead of us. this doc
captures what we should get right in the foundation so those features don't
require rewrites.
the core lesson from studying the ecosystem: the things that lasted (LPC
mudlibs, MOO worlds, evennia typeclasses) all share one trait — game content

View file

@ -1,294 +0,0 @@
==============================
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.

235
docs/how/combat.txt Normal file
View file

@ -0,0 +1,235 @@
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

@ -58,58 +58,6 @@ adding commands
4. call register(CommandDefinition(...))
5. import the module in server.py so registration runs at startup
combat commands
---------------
combat moves are defined in TOML files in content/combat/. each file describes
a move (attack, defend, counter) with metadata:
name
type (attack/defend)
stamina_cost
telegraph (what the opponent sees)
timing_window_ms (how long to react)
damage_pct (base damage as % of attacker PL)
counters (list of valid defensive moves)
the combat system loads these at startup and registers them as commands via
combat/commands.py. when combat mode is active, these commands become available.
players use the move name to execute it during their turn.
content-defined commands
------------------------
commands can be defined in TOML files in content/commands/. these are loaded
at startup by content.py and registered alongside python-defined commands.
format:
name = "example"
aliases = ["ex"]
help = "example command help text"
mode = "normal"
content commands currently require a python handler (body field pointing to a
callable). when the DSL arrives, this will be replaced with inline scripting.
editor mode
-----------
the edit command pushes editor mode onto the mode stack. while editor mode is
active, all input bypasses the command dispatcher and goes to the editor buffer
instead.
the editor provides:
line editing with insert/append/delete/replace
search and replace (regex supported)
syntax highlighting for TOML/Python
save/discard changes
dirty flag tracking
quit (:q) pops editor mode and returns to normal. the editor is implemented in
editor.py and uses the mode stack to capture input.
code
----
@ -118,7 +66,4 @@ src/mudlib/commands/movement.py direction commands
src/mudlib/commands/look.py look/l
src/mudlib/commands/fly.py fly
src/mudlib/commands/quit.py quit/q (mode="*")
src/mudlib/combat/commands.py combat move commands (loaded from TOML)
src/mudlib/content.py content loading (commands and combat moves)
src/mudlib/editor.py editor mode and buffer management
src/mudlib/player.py Player dataclass + registry

View file

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

View file

@ -1,6 +1,5 @@
"""Combat command handlers."""
from collections import defaultdict
from pathlib import Path
from mudlib.combat.engine import get_encounter, start_encounter
@ -12,22 +11,46 @@ from mudlib.player import Player, players
combat_moves: dict[str, CombatMove] = {}
async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
"""Core attack logic with a resolved move.
async def cmd_attack(player: Player, args: str) -> None:
"""Handle attack commands.
Args:
player: The attacking player
target_args: Remaining args after move resolution (just the target name)
move: The resolved combat move
args: Command arguments (move name and optional target name)
"""
# Get or create encounter
encounter = get_encounter(player)
# Parse target from args
# Parse arguments: move name (possibly multi-word) and optional target name
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_name = target_args.strip()
if encounter is None and target_name:
move_name = args.strip()
if encounter is None and len(parts) > 1:
# Try to extract target from last word
target_name = parts[-1]
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
if player.stamina < move.stamina_cost:
await player.send("You don't have enough stamina for that move.\r\n")
@ -68,13 +91,12 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
await player.send(f"You use {move.name}!\r\n")
async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
"""Core defense logic with a resolved move.
async def cmd_defend(player: Player, args: str) -> None:
"""Handle defense commands.
Args:
player: The defending player
_args: Unused (defense moves don't take a target)
move: The resolved combat move
args: Command arguments (move name)
"""
# Check if in combat
encounter = get_encounter(player)
@ -82,6 +104,23 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
await player.send("You're not in combat.\r\n")
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
if player.stamina < move.stamina_cost:
await player.send("You don't have enough stamina for that move.\r\n")
@ -92,49 +131,6 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
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:
"""Load and register all combat moves as commands.
@ -146,70 +142,35 @@ def register_combat_commands(content_dir: Path) -> None:
# Load all moves from content directory
combat_moves = load_moves(content_dir)
# Group variant moves by their base command
variant_groups: dict[str, dict[str, CombatMove]] = defaultdict(dict)
simple_moves: list[CombatMove] = []
registered_names: set[str] = set()
# Track which moves we've registered (don't register aliases separately)
registered_moves: set[str] = set()
for move in combat_moves.values():
if move.name in registered_names:
for _move_name, move in combat_moves.items():
# Only register each move once (by its canonical name)
if move.name in registered_moves:
continue
registered_names.add(move.name)
if move.variant:
variant_groups[move.command][move.variant] = move
else:
simple_moves.append(move)
registered_moves.add(move.name)
# Register simple moves (roundhouse, sweep, duck, jump)
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(
CommandDefinition(
name=move.name,
handler=_make_direct_handler(move, handler_fn),
aliases=move.aliases,
mode=mode,
help=f"{action} with {move.name}",
)
)
# 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(
CommandDefinition(
name=base_name,
handler=_make_variant_handler(base_name, variants, handler_fn),
aliases=[],
mode=mode,
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}",
)
if move.move_type == "attack":
# Attack moves work from any mode (can initiate combat)
register(
CommandDefinition(
name=move.name,
handler=cmd_attack,
aliases=move.aliases,
mode="*",
help=f"Attack with {move.name}",
)
)
elif move.move_type == "defense":
# Defense moves only work in combat mode
register(
CommandDefinition(
name=move.name,
handler=cmd_defend,
aliases=move.aliases,
mode="combat",
help=f"Defend with {move.name}",
)
)

View file

@ -25,24 +25,16 @@ class CombatMove:
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 = ""
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.
def load_move(path: Path) -> CombatMove:
"""Load a combat move from a TOML file.
Args:
path: Path to TOML file
Returns:
List of CombatMove instances (one per variant, or one for simple moves)
CombatMove instance
Raises:
ValueError: If required fields are missing
@ -57,52 +49,18 @@ def load_move(path: Path) -> list[CombatMove]:
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,
)
)
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="",
)
]
# Build move with defaults for optional fields
return CombatMove(
name=data["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, # Future: parse handler reference
)
def load_moves(directory: Path) -> dict[str, CombatMove]:
@ -126,30 +84,29 @@ def load_moves(directory: Path) -> dict[str, CombatMove]:
all_moves: list[CombatMove] = []
for toml_file in sorted(directory.glob("*.toml")):
file_moves = load_move(toml_file)
move = 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}"
# 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_names.add(move.name)
seen_aliases.add(alias)
# 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 name
moves[move.name] = move
# Add to dict by all aliases (pointing to same object)
for alias in move.aliases:
moves[alias] = move
# Add to dict by all aliases (pointing to same object)
for alias in move.aliases:
moves[alias] = move
all_moves.append(move)
all_moves.append(move)
# Validate countered_by references
for move in all_moves:

View file

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

View file

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