diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index dcaa712..780a7b8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -24,7 +24,7 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv. ## 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. 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 - 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) +- 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 diff --git a/content/combat/dodge_left.toml b/content/combat/dodge.toml similarity index 53% rename from content/combat/dodge_left.toml rename to content/combat/dodge.toml index 0ffbb9c..5579f21 100644 --- a/content/combat/dodge_left.toml +++ b/content/combat/dodge.toml @@ -1,8 +1,10 @@ -name = "dodge left" -aliases = ["dl"] +name = "dodge" move_type = "defense" stamina_cost = 3.0 -telegraph = "" timing_window_ms = 800 -damage_pct = 0.0 -countered_by = [] + +[variants.left] +aliases = ["dl"] + +[variants.right] +aliases = ["dr"] diff --git a/content/combat/dodge_right.toml b/content/combat/dodge_right.toml deleted file mode 100644 index b1abd8d..0000000 --- a/content/combat/dodge_right.toml +++ /dev/null @@ -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 = [] diff --git a/content/combat/parry_low.toml b/content/combat/parry.toml similarity index 53% rename from content/combat/parry_low.toml rename to content/combat/parry.toml index 5923807..f106d92 100644 --- a/content/combat/parry_low.toml +++ b/content/combat/parry.toml @@ -1,8 +1,10 @@ -name = "parry low" -aliases = ["v"] +name = "parry" move_type = "defense" stamina_cost = 4.0 -telegraph = "" timing_window_ms = 500 -damage_pct = 0.0 -countered_by = [] + +[variants.high] +aliases = ["f"] + +[variants.low] +aliases = ["v"] diff --git a/content/combat/parry_high.toml b/content/combat/parry_high.toml deleted file mode 100644 index 7afb437..0000000 --- a/content/combat/parry_high.toml +++ /dev/null @@ -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 = [] diff --git a/content/combat/punch_left.toml b/content/combat/punch.toml similarity index 54% rename from content/combat/punch_left.toml rename to content/combat/punch.toml index 6755332..3f6a279 100644 --- a/content/combat/punch_left.toml +++ b/content/combat/punch.toml @@ -1,8 +1,15 @@ -name = "punch left" -aliases = ["pl"] +name = "punch" 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"] diff --git a/content/combat/punch_right.toml b/content/combat/punch_right.toml deleted file mode 100644 index e5101d6..0000000 --- a/content/combat/punch_right.toml +++ /dev/null @@ -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"] diff --git a/docs/how/combat.rst b/docs/how/combat.rst new file mode 100644 index 0000000..b188c60 --- /dev/null +++ b/docs/how/combat.rst @@ -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.]. + +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. diff --git a/docs/how/combat.txt b/docs/how/combat.txt deleted file mode 100644 index ffa37f4..0000000 --- a/docs/how/combat.txt +++ /dev/null @@ -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). diff --git a/mud.tin b/mud.tin index 69f4be2..ed6044f 100644 --- a/mud.tin +++ b/mud.tin @@ -12,12 +12,7 @@ #alias {fse} {fly southeast} #alias {fsw} {fly southwest} -#NOP combat aliases -#alias {pr} {punch right} -#alias {pl} {punch left} +#NOP combat aliases (pr/pl/dr/dl/f/v are built into the MUD) +#NOP these are extras for single-key convenience #alias {o} {sweep} #alias {r} {roundhouse} -#alias {f} {parry high} -#alias {v} {parry low} -#alias {dr} {dodge right} -#alias {dl} {dodge left} diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index 2c71462..d0b4135 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -1,5 +1,6 @@ """Combat command handlers.""" +from collections import defaultdict from pathlib import Path from mudlib.combat.engine import get_encounter, start_encounter @@ -11,46 +12,22 @@ from mudlib.player import Player, players combat_moves: dict[str, CombatMove] = {} -async def cmd_attack(player: Player, args: str) -> None: - """Handle attack commands. +async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: + """Core attack logic with a resolved move. Args: 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) - # 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 + # Parse target from args target = None - move_name = args.strip() - - if encounter is None and len(parts) > 1: - # Try to extract target from last word - target_name = parts[-1] + target_name = target_args.strip() + if encounter is None and 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 if player.stamina < move.stamina_cost: 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") -async def cmd_defend(player: Player, args: str) -> None: - """Handle defense commands. +async def do_defend(player: Player, _args: str, move: CombatMove) -> None: + """Core defense logic with a resolved move. Args: 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 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") 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") @@ -131,6 +92,49 @@ async def cmd_defend(player: Player, args: str) -> 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. @@ -142,35 +146,70 @@ def register_combat_commands(content_dir: Path) -> None: # Load all moves from content directory combat_moves = load_moves(content_dir) - # Track which moves we've registered (don't register aliases separately) - registered_moves: set[str] = set() + # 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() - for _move_name, move in combat_moves.items(): - # Only register each move once (by its canonical name) - if move.name in registered_moves: + for move in combat_moves.values(): + if move.name in registered_names: 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": - # 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}", - ) + # 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}", ) - 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}", - ) + ) + + # 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}", + ) + ) diff --git a/src/mudlib/combat/moves.py b/src/mudlib/combat/moves.py index 576f940..8491078 100644 --- a/src/mudlib/combat/moves.py +++ b/src/mudlib/combat/moves.py @@ -25,16 +25,24 @@ 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) -> CombatMove: - """Load a combat move from a TOML file. +def load_move(path: Path) -> list[CombatMove]: + """Load combat move(s) from a TOML file. + + If the file has a [variants] section, each variant produces a separate + CombatMove with a qualified name (e.g. "punch left"). Shared properties + come from the top level, variant-specific properties override them. Args: path: Path to TOML file Returns: - CombatMove instance + List of CombatMove instances (one per variant, or one for simple moves) Raises: ValueError: If required fields are missing @@ -49,18 +57,52 @@ def load_move(path: Path) -> CombatMove: msg = f"missing required field: {field_name}" raise ValueError(msg) - # 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 - ) + 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="", + ) + ] def load_moves(directory: Path) -> dict[str, CombatMove]: @@ -84,29 +126,30 @@ def load_moves(directory: Path) -> dict[str, CombatMove]: all_moves: list[CombatMove] = [] for toml_file in sorted(directory.glob("*.toml")): - move = load_move(toml_file) + file_moves = load_move(toml_file) - # 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}" + for move in file_moves: + # Check for name collisions + if move.name in seen_names: + msg = f"duplicate move name: {move.name}" raise ValueError(msg) - seen_aliases.add(alias) + seen_names.add(move.name) - # Add to dict by name - moves[move.name] = move + # 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 all aliases (pointing to same object) - for alias in move.aliases: - moves[alias] = move + # Add to dict by name + moves[move.name] = move - all_moves.append(move) + # Add to dict by all aliases (pointing to same object) + for alias in move.aliases: + moves[alias] = move + + all_moves.append(move) # Validate countered_by references for move in all_moves: diff --git a/tests/test_combat_commands.py b/tests/test_combat_commands.py index 63ae81d..23db5a5 100644 --- a/tests/test_combat_commands.py +++ b/tests/test_combat_commands.py @@ -63,10 +63,31 @@ 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): - """Test attack command with target starts combat encounter.""" - await combat_commands.cmd_attack(player, "punch right Vegeta") +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) encounter = get_encounter(player) assert encounter is not None @@ -76,26 +97,28 @@ async def test_attack_starts_combat_with_target(player, target): @pytest.mark.asyncio -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") +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) player.writer.write.assert_called() 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 -async def test_attack_without_target_when_in_combat(player, target): - """Test attack without target when in combat uses implicit target.""" +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.""" # 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 player.writer.write.reset_mock() # 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) assert encounter is not None @@ -104,31 +127,21 @@ async def test_attack_without_target_when_in_combat(player, target): @pytest.mark.asyncio -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 "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.""" +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.cmd_attack(player, "punch right Vegeta") + await combat_commands.do_attack(player, "Vegeta", punch_right) player.writer.write.assert_called() 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 -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") +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) # Check that encounter has the move 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" +# --- do_defend tests --- + + @pytest.mark.asyncio -async def test_defense_only_in_combat(player): - """Test defense command only works in combat mode.""" - await combat_commands.cmd_defend(player, "dodge left") +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) player.writer.write.assert_called() message = player.writer.write.call_args[0][0] @@ -148,10 +164,10 @@ async def test_defense_only_in_combat(player): @pytest.mark.asyncio -async def test_defense_records_pending_defense(player, target): - """Test defense command records the defense move.""" +async def test_defense_records_pending_defense(player, target, punch_right, dodge_left): + """Test do_defend records the defense move.""" # 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() # Switch to defender's perspective @@ -159,7 +175,7 @@ async def test_defense_records_pending_defense(player, target): target.mode_stack = ["combat"] # Defend - await combat_commands.cmd_defend(target, "dodge left") + await combat_commands.do_defend(target, "", dodge_left) encounter = get_encounter(target) assert encounter is not None @@ -168,60 +184,107 @@ async def test_defense_records_pending_defense(player, target): @pytest.mark.asyncio -async def test_defense_unknown_move(player, target): - """Test defense with unknown move gives error.""" +async def test_defense_insufficient_stamina(player, target, punch_right, dodge_left): + """Test do_defend with insufficient stamina gives error.""" # Start combat - 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") + await combat_commands.do_attack(player, "Vegeta", punch_right) 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.cmd_defend(target, "dodge left") + await combat_commands.do_defend(target, "", dodge_left) target.writer.write.assert_called() 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 -async def test_attack_alias_works(player, target): - """Test attack using alias (pr for punch right).""" - await combat_commands.cmd_attack(player, "pr Vegeta") +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") 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_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"] +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 + ) - 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.pending_defense is not None - assert encounter.pending_defense.name == "dodge left" + 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 diff --git a/tests/test_combat_moves.py b/tests/test_combat_moves.py index 0a46f25..1f433b9 100644 --- a/tests/test_combat_moves.py +++ b/tests/test_combat_moves.py @@ -16,6 +16,8 @@ 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" @@ -26,6 +28,8 @@ 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(): @@ -44,32 +48,115 @@ 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_move_from_toml(tmp_path): - """Test loading a combat move from TOML file.""" +def test_load_simple_move_from_toml(tmp_path): + """Test loading a simple combat move from TOML file.""" toml_content = """ -name = "punch right" -aliases = ["pr"] +name = "roundhouse" +aliases = ["rh"] 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"] +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 / "punch_right.toml" + toml_file = tmp_path / "roundhouse.toml" toml_file.write_text(toml_content) - 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"] + 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" +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): @@ -83,7 +170,9 @@ timing_window_ms = 600 toml_file = tmp_path / "basic.toml" 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.aliases == [] assert move.telegraph == "" @@ -149,32 +238,31 @@ stamina_cost = 5.0 def test_load_moves_from_directory(tmp_path): """Test loading all moves from a directory.""" - # Create multiple TOML files - punch_toml = tmp_path / "punch_right.toml" + # Create a variant move + punch_toml = tmp_path / "punch.toml" punch_toml.write_text( """ -name = "punch right" -aliases = ["pr"] +name = "punch" 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"] + +[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( """ -name = "dodge left" -aliases = ["dl"] +name = "duck" move_type = "defense" stamina_cost = 3.0 -telegraph = "" timing_window_ms = 500 -damage_pct = 0.0 -countered_by = [] """ ) @@ -184,19 +272,19 @@ countered_by = [] 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 "pr" in moves - assert "dodge left" in moves - assert "dl" in moves + assert "duck" 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["dodge left"] is moves["dl"] - # Check move properties - assert moves["punch right"].name == "punch right" - assert moves["dodge left"].name == "dodge left" + # Check variant properties + assert moves["punch right"].command == "punch" + assert moves["punch right"].variant == "right" + assert moves["duck"].command == "duck" + assert moves["duck"].variant == "" 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.""" import logging - # Create a move with an invalid counter reference - punch_toml = tmp_path / "punch_right.toml" + punch_toml = tmp_path / "punch.toml" punch_toml.write_text( """ -name = "punch right" +name = "punch" 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_left.toml" + dodge_toml = tmp_path / "dodge.toml" dodge_toml.write_text( """ -name = "dodge left" +name = "dodge" move_type = "defense" stamina_cost = 3.0 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.""" import logging - # Create moves with valid counter references - punch_toml = tmp_path / "punch_right.toml" + punch_toml = tmp_path / "punch.toml" punch_toml.write_text( """ -name = "punch right" +name = "punch" 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_left.toml" + dodge_toml = tmp_path / "dodge.toml" dodge_toml.write_text( """ -name = "dodge left" +name = "dodge" move_type = "defense" stamina_cost = 3.0 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( """ -name = "parry high" +name = "parry" move_type = "defense" stamina_cost = 3.0 timing_window_ms = 500 + +[variants.high] +aliases = ["f"] """ ) @@ -346,3 +445,40 @@ timing_window_ms = 500 # 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"]