Restructure combat moves: single-word commands with variant args
The DREAMBOOK always described "punch right/left [target]" as one command with a direction argument, but the implementation had separate TOML files and multi-word command names that the dispatcher couldn't reach (it only matches the first word). Aliases like "pr" also couldn't pass targets because the shared handler tried to re-derive the move from args. Changes: - Merge punch_left/right, dodge_left/right, parry_high/low into single TOML files with [variants] sections - Add command/variant fields to CombatMove for tracking move families - load_move() now returns list[CombatMove], expanding variants - Handlers bound to moves via closures at registration time: variant handler for base commands (punch → parses direction from args), direct handler for aliases and simple moves (pr → move already known) - Core logic in do_attack/do_defend takes a resolved move - Combat doc rewritten as rst with architecture details - Simplify mud.tin aliases (pr/pl/etc are built-in MUD commands now)
This commit is contained in:
parent
91c57d89e1
commit
6344c09275
14 changed files with 837 additions and 513 deletions
|
|
@ -24,7 +24,7 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv.
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
Three categories in `docs/`. Plain text, not markdown.
|
Three categories in `docs/`. Plain text or rst, not markdown.
|
||||||
|
|
||||||
- `docs/how/` - how things work. write one when you build something non-obvious.
|
- `docs/how/` - how things work. write one when you build something non-obvious.
|
||||||
terrain generation, command system, etc. aimed at someone reading the code
|
terrain generation, command system, etc. aimed at someone reading the code
|
||||||
|
|
@ -50,7 +50,9 @@ Update docs when:
|
||||||
- world definitions live in data files, runtime state lives in memory
|
- world definitions live in data files, runtime state lives in memory
|
||||||
- SQLite for persistence (player accounts, progress)
|
- SQLite for persistence (player accounts, progress)
|
||||||
- session mode stack filters what events reach the player (normal/combat/editor)
|
- session mode stack filters what events reach the player (normal/combat/editor)
|
||||||
- combat system: state machine with TOML-defined moves (attacks, defends, counters)
|
- combat system: state machine with TOML-defined moves (attacks, defends, counters). see `docs/how/combat.rst`
|
||||||
|
- moves with directional variants (punch left/right) use a single TOML with `[variants]` sections
|
||||||
|
- commands are always single words; direction is an argument, not part of the command name
|
||||||
- content loading: TOML definitions for commands and combat moves, loaded at startup
|
- content loading: TOML definitions for commands and combat moves, loaded at startup
|
||||||
- entity model: Entity base class, Player and Mob subclasses sharing common interface
|
- entity model: Entity base class, Player and Mob subclasses sharing common interface
|
||||||
- editor mode: in-world text editor with syntax highlighting and search/replace
|
- editor mode: in-world text editor with syntax highlighting and search/replace
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
name = "dodge left"
|
name = "dodge"
|
||||||
aliases = ["dl"]
|
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
telegraph = ""
|
|
||||||
timing_window_ms = 800
|
timing_window_ms = 800
|
||||||
damage_pct = 0.0
|
|
||||||
countered_by = []
|
[variants.left]
|
||||||
|
aliases = ["dl"]
|
||||||
|
|
||||||
|
[variants.right]
|
||||||
|
aliases = ["dr"]
|
||||||
|
|
@ -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 = []
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
name = "parry low"
|
name = "parry"
|
||||||
aliases = ["v"]
|
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 4.0
|
stamina_cost = 4.0
|
||||||
telegraph = ""
|
|
||||||
timing_window_ms = 500
|
timing_window_ms = 500
|
||||||
damage_pct = 0.0
|
|
||||||
countered_by = []
|
[variants.high]
|
||||||
|
aliases = ["f"]
|
||||||
|
|
||||||
|
[variants.low]
|
||||||
|
aliases = ["v"]
|
||||||
|
|
@ -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 = []
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
name = "punch left"
|
name = "punch"
|
||||||
aliases = ["pl"]
|
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
telegraph = "{attacker} winds up a left hook!"
|
|
||||||
timing_window_ms = 800
|
timing_window_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
|
[variants.left]
|
||||||
|
aliases = ["pl"]
|
||||||
|
telegraph = "{attacker} winds up a left hook!"
|
||||||
countered_by = ["dodge right", "parry high"]
|
countered_by = ["dodge right", "parry high"]
|
||||||
|
|
||||||
|
[variants.right]
|
||||||
|
aliases = ["pr"]
|
||||||
|
telegraph = "{attacker} winds up a right hook!"
|
||||||
|
countered_by = ["dodge left", "parry high"]
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
name = "punch right"
|
|
||||||
aliases = ["pr"]
|
|
||||||
move_type = "attack"
|
|
||||||
stamina_cost = 5.0
|
|
||||||
telegraph = "{attacker} winds up a right hook!"
|
|
||||||
timing_window_ms = 800
|
|
||||||
damage_pct = 0.15
|
|
||||||
countered_by = ["dodge left", "parry high"]
|
|
||||||
294
docs/how/combat.rst
Normal file
294
docs/how/combat.rst
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
==============================
|
||||||
|
combat system — moves as data
|
||||||
|
==============================
|
||||||
|
|
||||||
|
combat in this MUD is designed around the principle that moves are content,
|
||||||
|
not engine code. the engine provides a state machine and timing system. moves
|
||||||
|
are data files that define telegraphs, windows, and counters. this keeps the
|
||||||
|
combat system extensible without touching python.
|
||||||
|
|
||||||
|
|
||||||
|
core design principles
|
||||||
|
======================
|
||||||
|
|
||||||
|
1. combat moves are CONTENT (TOML files in content/combat/)
|
||||||
|
2. the engine is a STATE MACHINE that processes timing and resolution
|
||||||
|
3. direction matters — punch right is countered by dodge left
|
||||||
|
4. no "fight" command — first attack initiates combat
|
||||||
|
5. after combat starts, target is implicit until encounter ends
|
||||||
|
|
||||||
|
|
||||||
|
command structure
|
||||||
|
=================
|
||||||
|
|
||||||
|
commands are always a single word. the dispatcher splits input on the first
|
||||||
|
space: first word is the command, the rest is args.
|
||||||
|
|
||||||
|
some moves have directional variants. the direction is an argument to the
|
||||||
|
command, not part of the command name::
|
||||||
|
|
||||||
|
punch right frank → command "punch", args "right frank"
|
||||||
|
punch left → command "punch", args "left"
|
||||||
|
roundhouse frank → command "roundhouse", args "frank"
|
||||||
|
sweep → command "sweep", args ""
|
||||||
|
|
||||||
|
variant moves (punch, dodge, parry) require a direction. simple moves
|
||||||
|
(roundhouse, sweep, duck, jump) don't.
|
||||||
|
|
||||||
|
each variant has its own short alias registered as a standalone command::
|
||||||
|
|
||||||
|
pr frank → "punch right frank" (pr is a direct command)
|
||||||
|
dl → "dodge left"
|
||||||
|
f → "parry high"
|
||||||
|
|
||||||
|
aliases are defined in the TOML file and registered at startup. they bypass
|
||||||
|
the variant parsing entirely — the handler already knows which move it is.
|
||||||
|
|
||||||
|
|
||||||
|
the state machine
|
||||||
|
=================
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
IDLE → TELEGRAPH → WINDOW → RESOLVE → IDLE
|
||||||
|
|
||||||
|
IDLE:
|
||||||
|
no active move. attacker can initiate attack. defender can do nothing
|
||||||
|
combat-specific.
|
||||||
|
|
||||||
|
TELEGRAPH:
|
||||||
|
attacker has declared a move. defender sees the telegraph message.
|
||||||
|
"goku winds up a right hook!"
|
||||||
|
defender can queue a defense during this phase.
|
||||||
|
duration: brief (implementation decides, not move-defined).
|
||||||
|
|
||||||
|
WINDOW:
|
||||||
|
the timing window opens. defender can still queue defense.
|
||||||
|
if defender queued correct counter during TELEGRAPH or WINDOW, they succeed.
|
||||||
|
duration: move.timing_window_ms (defined in TOML).
|
||||||
|
|
||||||
|
RESOLVE:
|
||||||
|
timing window closes. check if defense counters attack.
|
||||||
|
calculate damage based on attacker PL, damage_pct, and defense success.
|
||||||
|
apply stamina costs.
|
||||||
|
check for knockouts (PL = 0) or exhaustion (stamina = 0).
|
||||||
|
return to IDLE.
|
||||||
|
|
||||||
|
|
||||||
|
entity stats
|
||||||
|
============
|
||||||
|
|
||||||
|
each Entity (player, mob, NPC) has::
|
||||||
|
|
||||||
|
pl: float = 100.0
|
||||||
|
power level. acts as both health AND damage multiplier.
|
||||||
|
when PL hits 0, entity is knocked out.
|
||||||
|
|
||||||
|
stamina: float = 100.0
|
||||||
|
current stamina. each move costs stamina.
|
||||||
|
when stamina hits 0, entity passes out.
|
||||||
|
|
||||||
|
max_stamina: float = 100.0
|
||||||
|
stamina ceiling. can be affected by training, items, etc (future).
|
||||||
|
|
||||||
|
|
||||||
|
the move set
|
||||||
|
============
|
||||||
|
|
||||||
|
ATTACKS:
|
||||||
|
punch right/left (pr/pl)
|
||||||
|
basic hooks. fast, low cost.
|
||||||
|
|
||||||
|
roundhouse (rh)
|
||||||
|
big spinning kick. higher damage, tighter parry window.
|
||||||
|
|
||||||
|
sweep (sw)
|
||||||
|
leg sweep. knocks down if not countered.
|
||||||
|
|
||||||
|
DEFENSES:
|
||||||
|
dodge right/left (dr/dl)
|
||||||
|
sidestep. counters opposite-side attacks.
|
||||||
|
|
||||||
|
parry high/low (f/v)
|
||||||
|
block attacks. tighter timing window than dodge.
|
||||||
|
|
||||||
|
duck
|
||||||
|
crouch under high attacks. counters roundhouse.
|
||||||
|
|
||||||
|
jump
|
||||||
|
leap over low attacks. counters sweep.
|
||||||
|
|
||||||
|
|
||||||
|
counter relationships
|
||||||
|
=====================
|
||||||
|
|
||||||
|
defined in each move's TOML file via the "countered_by" field.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
punch right → countered_by: [dodge left, parry high]
|
||||||
|
punch left → countered_by: [dodge right, parry high]
|
||||||
|
roundhouse → countered_by: [duck, parry high, parry low]
|
||||||
|
sweep → countered_by: [jump, parry low]
|
||||||
|
|
||||||
|
the engine doesn't know these relationships. it just checks:
|
||||||
|
"is defender's move in attacker's move.countered_by list?"
|
||||||
|
|
||||||
|
|
||||||
|
damage formula
|
||||||
|
==============
|
||||||
|
|
||||||
|
if defense succeeds (is in countered_by list)::
|
||||||
|
|
||||||
|
damage = 0
|
||||||
|
defender gets "you countered the attack!" message
|
||||||
|
|
||||||
|
if defense fails (wrong move or no move)::
|
||||||
|
|
||||||
|
damage = attacker.pl * move.damage_pct
|
||||||
|
defender.pl -= damage
|
||||||
|
|
||||||
|
if no defense was attempted::
|
||||||
|
|
||||||
|
damage = attacker.pl * move.damage_pct * 1.5
|
||||||
|
defender.pl -= damage
|
||||||
|
defender gets "you took the hit full force!" message
|
||||||
|
|
||||||
|
stamina costs are always applied, regardless of outcome::
|
||||||
|
|
||||||
|
attacker.stamina -= move.stamina_cost
|
||||||
|
if defender attempted defense:
|
||||||
|
defender.stamina -= defense_move.stamina_cost
|
||||||
|
|
||||||
|
|
||||||
|
starting combat
|
||||||
|
===============
|
||||||
|
|
||||||
|
NO explicit "fight" command. instead::
|
||||||
|
|
||||||
|
punch right goku
|
||||||
|
if player is not in combat: starts encounter with goku, pushes "combat" mode
|
||||||
|
if player is already in combat: error, already fighting someone else
|
||||||
|
|
||||||
|
after combat starts, target is implicit::
|
||||||
|
|
||||||
|
punch left
|
||||||
|
dodge right
|
||||||
|
roundhouse
|
||||||
|
|
||||||
|
all commands assume the current opponent.
|
||||||
|
|
||||||
|
|
||||||
|
exiting combat
|
||||||
|
==============
|
||||||
|
|
||||||
|
combat ends when:
|
||||||
|
|
||||||
|
- one combatant's PL reaches 0 (knockout)
|
||||||
|
- one combatant's stamina reaches 0 (exhaustion)
|
||||||
|
- one combatant flees (future: flee command)
|
||||||
|
- one combatant is killed (future: lethal combat toggle)
|
||||||
|
|
||||||
|
when combat ends, both entities' mode stacks pop back to "normal".
|
||||||
|
|
||||||
|
|
||||||
|
TOML move definitions
|
||||||
|
=====================
|
||||||
|
|
||||||
|
moves live in content/combat/. two formats: simple and variant.
|
||||||
|
|
||||||
|
**simple move** (one file, one command)::
|
||||||
|
|
||||||
|
# content/combat/roundhouse.toml
|
||||||
|
name = "roundhouse"
|
||||||
|
aliases = ["rh"]
|
||||||
|
move_type = "attack"
|
||||||
|
stamina_cost = 8.0
|
||||||
|
telegraph = "{attacker} spins into a roundhouse kick!"
|
||||||
|
timing_window_ms = 600
|
||||||
|
damage_pct = 0.25
|
||||||
|
countered_by = ["duck", "parry high", "parry low"]
|
||||||
|
|
||||||
|
**variant move** (one file, directional variants)::
|
||||||
|
|
||||||
|
# content/combat/punch.toml
|
||||||
|
name = "punch"
|
||||||
|
move_type = "attack"
|
||||||
|
stamina_cost = 5.0
|
||||||
|
timing_window_ms = 800
|
||||||
|
damage_pct = 0.15
|
||||||
|
|
||||||
|
[variants.left]
|
||||||
|
aliases = ["pl"]
|
||||||
|
telegraph = "{attacker} winds up a left hook!"
|
||||||
|
countered_by = ["dodge right", "parry high"]
|
||||||
|
|
||||||
|
[variants.right]
|
||||||
|
aliases = ["pr"]
|
||||||
|
telegraph = "{attacker} winds up a right hook!"
|
||||||
|
countered_by = ["dodge left", "parry high"]
|
||||||
|
|
||||||
|
shared properties (stamina_cost, timing_window_ms, damage_pct) are defined at
|
||||||
|
the top level. variants inherit these and can override them. variant-specific
|
||||||
|
properties (telegraph, countered_by, aliases) live under [variants.<key>].
|
||||||
|
|
||||||
|
each variant produces a CombatMove with a qualified name like "punch left".
|
||||||
|
the ``command`` field tracks the base command ("punch") and ``variant`` tracks
|
||||||
|
the key ("left"). simple moves have command=name and variant="".
|
||||||
|
|
||||||
|
TOML field reference:
|
||||||
|
|
||||||
|
- name: the command name (simple) or base command (variant)
|
||||||
|
- move_type: "attack" or "defense"
|
||||||
|
- stamina_cost: stamina consumed when using this move
|
||||||
|
- timing_window_ms: how long defender has to respond
|
||||||
|
- telegraph: shown to defender during TELEGRAPH phase. {attacker} replaced
|
||||||
|
- damage_pct: fraction of attacker's PL dealt as damage
|
||||||
|
- countered_by: list of qualified move names that counter this move
|
||||||
|
- aliases: short command aliases registered as standalone commands
|
||||||
|
|
||||||
|
|
||||||
|
handler architecture
|
||||||
|
====================
|
||||||
|
|
||||||
|
three types of command handlers, created at registration time:
|
||||||
|
|
||||||
|
**direct handler** — for simple moves (roundhouse, sweep) and variant aliases
|
||||||
|
(pl, pr). the move is bound at registration via closure. args = target name.
|
||||||
|
|
||||||
|
**variant handler** — for base variant commands (punch, dodge, parry). parses
|
||||||
|
first arg as direction, looks up the variant, passes remaining args as target.
|
||||||
|
|
||||||
|
**core functions** — do_attack() and do_defend() contain the actual combat
|
||||||
|
logic. they take (player, target_args, move) and handle encounter creation,
|
||||||
|
stamina checks, telegraph sending, etc. handlers call into these.
|
||||||
|
|
||||||
|
this means adding a new move = add a TOML file. the registration code groups
|
||||||
|
variants automatically, creates the right handler type, and registers aliases.
|
||||||
|
|
||||||
|
|
||||||
|
future features (NOT YET IMPLEMENTED)
|
||||||
|
======================================
|
||||||
|
|
||||||
|
stuns:
|
||||||
|
successful parry stuns opponent for 1 tick.
|
||||||
|
during stun, engine shows combo sequence in shorthand:
|
||||||
|
"you can follow up with: pr/pl/sw or pr/pl/rh"
|
||||||
|
player has narrow window to input the sequence.
|
||||||
|
|
||||||
|
combos:
|
||||||
|
if player executes shown sequence during stun window, bonus damage applied.
|
||||||
|
after successful combo, chance to chain (diminishing probability).
|
||||||
|
longer chains = exponentially harder timing.
|
||||||
|
|
||||||
|
special moves:
|
||||||
|
moves that require conditions (opponent stunned, player at high PL, etc).
|
||||||
|
defined via "requires" field in TOML.
|
||||||
|
|
||||||
|
lethal combat:
|
||||||
|
toggle that makes PL=0 mean death, not knockout.
|
||||||
|
affects NPC encounters in dangerous zones.
|
||||||
|
|
||||||
|
multi-combatant:
|
||||||
|
extend encounter to support more than 1v1.
|
||||||
|
each attacker/defender pair still follows same state machine.
|
||||||
|
|
@ -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).
|
|
||||||
9
mud.tin
9
mud.tin
|
|
@ -12,12 +12,7 @@
|
||||||
#alias {fse} {fly southeast}
|
#alias {fse} {fly southeast}
|
||||||
#alias {fsw} {fly southwest}
|
#alias {fsw} {fly southwest}
|
||||||
|
|
||||||
#NOP combat aliases
|
#NOP combat aliases (pr/pl/dr/dl/f/v are built into the MUD)
|
||||||
#alias {pr} {punch right}
|
#NOP these are extras for single-key convenience
|
||||||
#alias {pl} {punch left}
|
|
||||||
#alias {o} {sweep}
|
#alias {o} {sweep}
|
||||||
#alias {r} {roundhouse}
|
#alias {r} {roundhouse}
|
||||||
#alias {f} {parry high}
|
|
||||||
#alias {v} {parry low}
|
|
||||||
#alias {dr} {dodge right}
|
|
||||||
#alias {dl} {dodge left}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Combat command handlers."""
|
"""Combat command handlers."""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mudlib.combat.engine import get_encounter, start_encounter
|
from mudlib.combat.engine import get_encounter, start_encounter
|
||||||
|
|
@ -11,46 +12,22 @@ from mudlib.player import Player, players
|
||||||
combat_moves: dict[str, CombatMove] = {}
|
combat_moves: dict[str, CombatMove] = {}
|
||||||
|
|
||||||
|
|
||||||
async def cmd_attack(player: Player, args: str) -> None:
|
async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||||
"""Handle attack commands.
|
"""Core attack logic with a resolved move.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
player: The attacking player
|
player: The attacking player
|
||||||
args: Command arguments (move name and optional target name)
|
target_args: Remaining args after move resolution (just the target name)
|
||||||
|
move: The resolved combat move
|
||||||
"""
|
"""
|
||||||
# Get or create encounter
|
|
||||||
encounter = get_encounter(player)
|
encounter = get_encounter(player)
|
||||||
|
|
||||||
# Parse arguments: move name (possibly multi-word) and optional target name
|
# Parse target from args
|
||||||
parts = args.strip().split()
|
|
||||||
if not parts:
|
|
||||||
await player.send("Attack with what move?\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
# If not in combat, last word might be target name
|
|
||||||
target = None
|
target = None
|
||||||
move_name = args.strip()
|
target_name = target_args.strip()
|
||||||
|
if encounter is None and target_name:
|
||||||
if encounter is None and len(parts) > 1:
|
|
||||||
# Try to extract target from last word
|
|
||||||
target_name = parts[-1]
|
|
||||||
target = players.get(target_name)
|
target = players.get(target_name)
|
||||||
|
|
||||||
if target is not None:
|
|
||||||
# Remove target name from move_name
|
|
||||||
move_name = " ".join(parts[:-1])
|
|
||||||
|
|
||||||
# Look up the move
|
|
||||||
move = combat_moves.get(move_name.lower())
|
|
||||||
if move is None:
|
|
||||||
await player.send(f"Unknown move: {move_name}\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if it's an attack move
|
|
||||||
if move.move_type != "attack":
|
|
||||||
await player.send(f"{move.name} is not an attack move.\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check stamina
|
# Check stamina
|
||||||
if player.stamina < move.stamina_cost:
|
if player.stamina < move.stamina_cost:
|
||||||
await player.send("You don't have enough stamina for that move.\r\n")
|
await player.send("You don't have enough stamina for that move.\r\n")
|
||||||
|
|
@ -91,12 +68,13 @@ async def cmd_attack(player: Player, args: str) -> None:
|
||||||
await player.send(f"You use {move.name}!\r\n")
|
await player.send(f"You use {move.name}!\r\n")
|
||||||
|
|
||||||
|
|
||||||
async def cmd_defend(player: Player, args: str) -> None:
|
async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
|
||||||
"""Handle defense commands.
|
"""Core defense logic with a resolved move.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
player: The defending player
|
player: The defending player
|
||||||
args: Command arguments (move name)
|
_args: Unused (defense moves don't take a target)
|
||||||
|
move: The resolved combat move
|
||||||
"""
|
"""
|
||||||
# Check if in combat
|
# Check if in combat
|
||||||
encounter = get_encounter(player)
|
encounter = get_encounter(player)
|
||||||
|
|
@ -104,23 +82,6 @@ async def cmd_defend(player: Player, args: str) -> None:
|
||||||
await player.send("You're not in combat.\r\n")
|
await player.send("You're not in combat.\r\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse move name
|
|
||||||
move_name = args.strip()
|
|
||||||
if not move_name:
|
|
||||||
await player.send("Defend with what move?\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Look up the move
|
|
||||||
move = combat_moves.get(move_name.lower())
|
|
||||||
if move is None:
|
|
||||||
await player.send(f"Unknown move: {move_name}\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if it's a defense move
|
|
||||||
if move.move_type != "defense":
|
|
||||||
await player.send(f"{move.name} is not a defense move.\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check stamina
|
# Check stamina
|
||||||
if player.stamina < move.stamina_cost:
|
if player.stamina < move.stamina_cost:
|
||||||
await player.send("You don't have enough stamina for that move.\r\n")
|
await player.send("You don't have enough stamina for that move.\r\n")
|
||||||
|
|
@ -131,6 +92,49 @@ async def cmd_defend(player: Player, args: str) -> None:
|
||||||
await player.send(f"You attempt to {move.name}!\r\n")
|
await player.send(f"You attempt to {move.name}!\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_direct_handler(move: CombatMove, handler_fn):
|
||||||
|
"""Create a handler bound to a specific move.
|
||||||
|
|
||||||
|
Used for simple moves (roundhouse) and variant aliases (pl, pr).
|
||||||
|
Args are just the target name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def handler(player: Player, args: str) -> None:
|
||||||
|
await handler_fn(player, args, move)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _make_variant_handler(
|
||||||
|
base_name: str, variant_moves: dict[str, CombatMove], handler_fn
|
||||||
|
):
|
||||||
|
"""Create a handler for a move with directional variants.
|
||||||
|
|
||||||
|
Used for base commands like "punch" where the first arg is the direction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def handler(player: Player, args: str) -> None:
|
||||||
|
parts = args.strip().split(maxsplit=1)
|
||||||
|
if not parts:
|
||||||
|
variants = "/".join(variant_moves.keys())
|
||||||
|
await player.send(f"{base_name.capitalize()} which way? ({variants})\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
variant_key = parts[0].lower()
|
||||||
|
move = variant_moves.get(variant_key)
|
||||||
|
if move is None:
|
||||||
|
variants = "/".join(variant_moves.keys())
|
||||||
|
await player.send(
|
||||||
|
f"Unknown {base_name} direction: {variant_key}. Try: {variants}\r\n"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
target_args = parts[1] if len(parts) > 1 else ""
|
||||||
|
await handler_fn(player, target_args, move)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
def register_combat_commands(content_dir: Path) -> None:
|
def register_combat_commands(content_dir: Path) -> None:
|
||||||
"""Load and register all combat moves as commands.
|
"""Load and register all combat moves as commands.
|
||||||
|
|
||||||
|
|
@ -142,35 +146,70 @@ def register_combat_commands(content_dir: Path) -> None:
|
||||||
# Load all moves from content directory
|
# Load all moves from content directory
|
||||||
combat_moves = load_moves(content_dir)
|
combat_moves = load_moves(content_dir)
|
||||||
|
|
||||||
# Track which moves we've registered (don't register aliases separately)
|
# Group variant moves by their base command
|
||||||
registered_moves: set[str] = set()
|
variant_groups: dict[str, dict[str, CombatMove]] = defaultdict(dict)
|
||||||
|
simple_moves: list[CombatMove] = []
|
||||||
|
registered_names: set[str] = set()
|
||||||
|
|
||||||
for _move_name, move in combat_moves.items():
|
for move in combat_moves.values():
|
||||||
# Only register each move once (by its canonical name)
|
if move.name in registered_names:
|
||||||
if move.name in registered_moves:
|
|
||||||
continue
|
continue
|
||||||
|
registered_names.add(move.name)
|
||||||
|
|
||||||
registered_moves.add(move.name)
|
if move.variant:
|
||||||
|
variant_groups[move.command][move.variant] = move
|
||||||
|
else:
|
||||||
|
simple_moves.append(move)
|
||||||
|
|
||||||
if move.move_type == "attack":
|
# Register simple moves (roundhouse, sweep, duck, jump)
|
||||||
# Attack moves work from any mode (can initiate combat)
|
for move in simple_moves:
|
||||||
register(
|
handler_fn = do_attack if move.move_type == "attack" else do_defend
|
||||||
CommandDefinition(
|
mode = "*" if move.move_type == "attack" else "combat"
|
||||||
name=move.name,
|
action = "Attack" if move.move_type == "attack" else "Defend"
|
||||||
handler=cmd_attack,
|
register(
|
||||||
aliases=move.aliases,
|
CommandDefinition(
|
||||||
mode="*",
|
name=move.name,
|
||||||
help=f"Attack with {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(
|
# Register variant moves (punch, dodge, parry)
|
||||||
CommandDefinition(
|
for base_name, variants in variant_groups.items():
|
||||||
name=move.name,
|
# Determine type from first variant
|
||||||
handler=cmd_defend,
|
first_variant = next(iter(variants.values()))
|
||||||
aliases=move.aliases,
|
handler_fn = do_attack if first_variant.move_type == "attack" else do_defend
|
||||||
mode="combat",
|
mode = "*" if first_variant.move_type == "attack" else "combat"
|
||||||
help=f"Defend with {move.name}",
|
|
||||||
)
|
# 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}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,24 @@ class CombatMove:
|
||||||
damage_pct: float = 0.0
|
damage_pct: float = 0.0
|
||||||
countered_by: list[str] = field(default_factory=list)
|
countered_by: list[str] = field(default_factory=list)
|
||||||
handler: CommandHandler | None = None
|
handler: CommandHandler | None = None
|
||||||
|
# base command name ("punch" for "punch left", same as name for simple moves)
|
||||||
|
command: str = ""
|
||||||
|
# variant key ("left", "right", "" for simple moves)
|
||||||
|
variant: str = ""
|
||||||
|
|
||||||
|
|
||||||
def load_move(path: Path) -> CombatMove:
|
def load_move(path: Path) -> list[CombatMove]:
|
||||||
"""Load a combat move from a TOML file.
|
"""Load combat move(s) from a TOML file.
|
||||||
|
|
||||||
|
If the file has a [variants] section, each variant produces a separate
|
||||||
|
CombatMove with a qualified name (e.g. "punch left"). Shared properties
|
||||||
|
come from the top level, variant-specific properties override them.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to TOML file
|
path: Path to TOML file
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
CombatMove instance
|
List of CombatMove instances (one per variant, or one for simple moves)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If required fields are missing
|
ValueError: If required fields are missing
|
||||||
|
|
@ -49,18 +57,52 @@ def load_move(path: Path) -> CombatMove:
|
||||||
msg = f"missing required field: {field_name}"
|
msg = f"missing required field: {field_name}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
# Build move with defaults for optional fields
|
base_name = data["name"]
|
||||||
return CombatMove(
|
variants = data.get("variants")
|
||||||
name=data["name"],
|
|
||||||
move_type=data["move_type"],
|
if variants:
|
||||||
stamina_cost=data["stamina_cost"],
|
moves = []
|
||||||
timing_window_ms=data["timing_window_ms"],
|
for variant_key, variant_data in variants.items():
|
||||||
aliases=data.get("aliases", []),
|
qualified_name = f"{base_name} {variant_key}"
|
||||||
telegraph=data.get("telegraph", ""),
|
moves.append(
|
||||||
damage_pct=data.get("damage_pct", 0.0),
|
CombatMove(
|
||||||
countered_by=data.get("countered_by", []),
|
name=qualified_name,
|
||||||
handler=None, # Future: parse handler reference
|
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]:
|
def load_moves(directory: Path) -> dict[str, CombatMove]:
|
||||||
|
|
@ -84,29 +126,30 @@ def load_moves(directory: Path) -> dict[str, CombatMove]:
|
||||||
all_moves: list[CombatMove] = []
|
all_moves: list[CombatMove] = []
|
||||||
|
|
||||||
for toml_file in sorted(directory.glob("*.toml")):
|
for toml_file in sorted(directory.glob("*.toml")):
|
||||||
move = load_move(toml_file)
|
file_moves = load_move(toml_file)
|
||||||
|
|
||||||
# Check for name collisions
|
for move in file_moves:
|
||||||
if move.name in seen_names:
|
# Check for name collisions
|
||||||
msg = f"duplicate move name: {move.name}"
|
if move.name in seen_names:
|
||||||
raise ValueError(msg)
|
msg = f"duplicate move name: {move.name}"
|
||||||
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)
|
raise ValueError(msg)
|
||||||
seen_aliases.add(alias)
|
seen_names.add(move.name)
|
||||||
|
|
||||||
# Add to dict by name
|
# Check for alias collisions
|
||||||
moves[move.name] = move
|
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)
|
# Add to dict by name
|
||||||
for alias in move.aliases:
|
moves[move.name] = move
|
||||||
moves[alias] = 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
|
# Validate countered_by references
|
||||||
for move in all_moves:
|
for move in all_moves:
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,31 @@ def inject_moves(moves):
|
||||||
combat_commands.combat_moves = {}
|
combat_commands.combat_moves = {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def punch_right(moves):
|
||||||
|
"""Get the punch right move."""
|
||||||
|
return moves["punch right"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def punch_left(moves):
|
||||||
|
"""Get the punch left move."""
|
||||||
|
return moves["punch left"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dodge_left(moves):
|
||||||
|
"""Get the dodge left move."""
|
||||||
|
return moves["dodge left"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- do_attack tests ---
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attack_starts_combat_with_target(player, target):
|
async def test_attack_starts_combat_with_target(player, target, punch_right):
|
||||||
"""Test attack command with target starts combat encounter."""
|
"""Test do_attack with target starts combat encounter."""
|
||||||
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
||||||
|
|
||||||
encounter = get_encounter(player)
|
encounter = get_encounter(player)
|
||||||
assert encounter is not None
|
assert encounter is not None
|
||||||
|
|
@ -76,26 +97,28 @@ async def test_attack_starts_combat_with_target(player, target):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attack_without_target_when_not_in_combat(player):
|
async def test_attack_without_target_when_not_in_combat(player, punch_right):
|
||||||
"""Test attack without target when not in combat gives error."""
|
"""Test do_attack without target when not in combat gives error."""
|
||||||
await combat_commands.cmd_attack(player, "punch right")
|
await combat_commands.do_attack(player, "", punch_right)
|
||||||
|
|
||||||
player.writer.write.assert_called()
|
player.writer.write.assert_called()
|
||||||
message = player.writer.write.call_args[0][0]
|
message = player.writer.write.call_args[0][0]
|
||||||
assert "not in combat" in message.lower() or "need a target" in message.lower()
|
assert "need a target" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attack_without_target_when_in_combat(player, target):
|
async def test_attack_without_target_when_in_combat(
|
||||||
"""Test attack without target when in combat uses implicit target."""
|
player, target, punch_right, punch_left
|
||||||
|
):
|
||||||
|
"""Test do_attack without target when in combat uses implicit target."""
|
||||||
# Start combat first
|
# Start combat first
|
||||||
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
||||||
|
|
||||||
# Reset mock to track new calls
|
# Reset mock to track new calls
|
||||||
player.writer.write.reset_mock()
|
player.writer.write.reset_mock()
|
||||||
|
|
||||||
# Attack without target should work
|
# Attack without target should work
|
||||||
await combat_commands.cmd_attack(player, "punch left")
|
await combat_commands.do_attack(player, "", punch_left)
|
||||||
|
|
||||||
encounter = get_encounter(player)
|
encounter = get_encounter(player)
|
||||||
assert encounter is not None
|
assert encounter is not None
|
||||||
|
|
@ -104,31 +127,21 @@ async def test_attack_without_target_when_in_combat(player, target):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attack_unknown_move(player, target):
|
async def test_attack_insufficient_stamina(player, target, punch_right):
|
||||||
"""Test attack with unknown move name gives error."""
|
"""Test do_attack with insufficient stamina gives error."""
|
||||||
await combat_commands.cmd_attack(player, "kamehameha Vegeta")
|
|
||||||
|
|
||||||
player.writer.write.assert_called()
|
|
||||||
message = player.writer.write.call_args[0][0]
|
|
||||||
assert "unknown" in message.lower() or "don't know" in message.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_attack_insufficient_stamina(player, target):
|
|
||||||
"""Test attack with insufficient stamina gives error."""
|
|
||||||
player.stamina = 1.0 # Not enough for punch (costs 5)
|
player.stamina = 1.0 # Not enough for punch (costs 5)
|
||||||
|
|
||||||
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
||||||
|
|
||||||
player.writer.write.assert_called()
|
player.writer.write.assert_called()
|
||||||
message = player.writer.write.call_args[0][0]
|
message = player.writer.write.call_args[0][0]
|
||||||
assert "stamina" in message.lower() or "exhausted" in message.lower()
|
assert "stamina" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attack_sends_telegraph_to_defender(player, target):
|
async def test_attack_sends_telegraph_to_defender(player, target, punch_right):
|
||||||
"""Test attack sends telegraph message to defender."""
|
"""Test do_attack sends telegraph message to defender."""
|
||||||
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
||||||
|
|
||||||
# Check that encounter has the move
|
# Check that encounter has the move
|
||||||
encounter = get_encounter(player)
|
encounter = get_encounter(player)
|
||||||
|
|
@ -137,10 +150,13 @@ async def test_attack_sends_telegraph_to_defender(player, target):
|
||||||
assert encounter.current_move.name == "punch right"
|
assert encounter.current_move.name == "punch right"
|
||||||
|
|
||||||
|
|
||||||
|
# --- do_defend tests ---
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_defense_only_in_combat(player):
|
async def test_defense_only_in_combat(player, dodge_left):
|
||||||
"""Test defense command only works in combat mode."""
|
"""Test do_defend only works in combat mode."""
|
||||||
await combat_commands.cmd_defend(player, "dodge left")
|
await combat_commands.do_defend(player, "", dodge_left)
|
||||||
|
|
||||||
player.writer.write.assert_called()
|
player.writer.write.assert_called()
|
||||||
message = player.writer.write.call_args[0][0]
|
message = player.writer.write.call_args[0][0]
|
||||||
|
|
@ -148,10 +164,10 @@ async def test_defense_only_in_combat(player):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_defense_records_pending_defense(player, target):
|
async def test_defense_records_pending_defense(player, target, punch_right, dodge_left):
|
||||||
"""Test defense command records the defense move."""
|
"""Test do_defend records the defense move."""
|
||||||
# Start combat
|
# Start combat
|
||||||
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
||||||
player.writer.write.reset_mock()
|
player.writer.write.reset_mock()
|
||||||
|
|
||||||
# Switch to defender's perspective
|
# Switch to defender's perspective
|
||||||
|
|
@ -159,7 +175,7 @@ async def test_defense_records_pending_defense(player, target):
|
||||||
target.mode_stack = ["combat"]
|
target.mode_stack = ["combat"]
|
||||||
|
|
||||||
# Defend
|
# Defend
|
||||||
await combat_commands.cmd_defend(target, "dodge left")
|
await combat_commands.do_defend(target, "", dodge_left)
|
||||||
|
|
||||||
encounter = get_encounter(target)
|
encounter = get_encounter(target)
|
||||||
assert encounter is not None
|
assert encounter is not None
|
||||||
|
|
@ -168,60 +184,107 @@ async def test_defense_records_pending_defense(player, target):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_defense_unknown_move(player, target):
|
async def test_defense_insufficient_stamina(player, target, punch_right, dodge_left):
|
||||||
"""Test defense with unknown move gives error."""
|
"""Test do_defend with insufficient stamina gives error."""
|
||||||
# Start combat
|
# Start combat
|
||||||
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
||||||
target.writer = player.writer
|
|
||||||
target.mode_stack = ["combat"]
|
|
||||||
|
|
||||||
player.writer.write.reset_mock()
|
|
||||||
await combat_commands.cmd_defend(target, "teleport")
|
|
||||||
|
|
||||||
target.writer.write.assert_called()
|
|
||||||
message = target.writer.write.call_args[0][0]
|
|
||||||
assert "unknown" in message.lower() or "don't know" in message.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_defense_insufficient_stamina(player, target):
|
|
||||||
"""Test defense with insufficient stamina gives error."""
|
|
||||||
# Start combat
|
|
||||||
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
|
||||||
target.writer = player.writer
|
target.writer = player.writer
|
||||||
target.mode_stack = ["combat"]
|
target.mode_stack = ["combat"]
|
||||||
target.stamina = 1.0 # Not enough for dodge (costs 3)
|
target.stamina = 1.0 # Not enough for dodge (costs 3)
|
||||||
|
|
||||||
player.writer.write.reset_mock()
|
player.writer.write.reset_mock()
|
||||||
await combat_commands.cmd_defend(target, "dodge left")
|
await combat_commands.do_defend(target, "", dodge_left)
|
||||||
|
|
||||||
target.writer.write.assert_called()
|
target.writer.write.assert_called()
|
||||||
message = target.writer.write.call_args[0][0]
|
message = target.writer.write.call_args[0][0]
|
||||||
assert "stamina" in message.lower() or "exhausted" in message.lower()
|
assert "stamina" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- variant handler tests ---
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attack_alias_works(player, target):
|
async def test_variant_handler_parses_direction(player, target, moves):
|
||||||
"""Test attack using alias (pr for punch right)."""
|
"""Test the variant handler parses direction from args."""
|
||||||
await combat_commands.cmd_attack(player, "pr Vegeta")
|
variant_moves = {
|
||||||
|
"left": moves["punch left"],
|
||||||
|
"right": moves["punch right"],
|
||||||
|
}
|
||||||
|
handler = combat_commands._make_variant_handler(
|
||||||
|
"punch", variant_moves, combat_commands.do_attack
|
||||||
|
)
|
||||||
|
|
||||||
|
await handler(player, "right Vegeta")
|
||||||
|
|
||||||
encounter = get_encounter(player)
|
encounter = get_encounter(player)
|
||||||
assert encounter is not None
|
assert encounter is not None
|
||||||
assert encounter.current_move is not None
|
|
||||||
assert encounter.current_move.name == "punch right"
|
assert encounter.current_move.name == "punch right"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_defense_alias_works(player, target):
|
async def test_variant_handler_no_direction(player, moves):
|
||||||
"""Test defense using alias (dl for dodge left)."""
|
"""Test the variant handler prompts when no direction given."""
|
||||||
# Start combat
|
variant_moves = {
|
||||||
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
"left": moves["punch left"],
|
||||||
target.writer = player.writer
|
"right": moves["punch right"],
|
||||||
target.mode_stack = ["combat"]
|
}
|
||||||
|
handler = combat_commands._make_variant_handler(
|
||||||
|
"punch", variant_moves, combat_commands.do_attack
|
||||||
|
)
|
||||||
|
|
||||||
await combat_commands.cmd_defend(target, "dl")
|
await handler(player, "")
|
||||||
|
|
||||||
encounter = get_encounter(target)
|
player.writer.write.assert_called()
|
||||||
|
message = player.writer.write.call_args[0][0]
|
||||||
|
assert "which way" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_variant_handler_bad_direction(player, moves):
|
||||||
|
"""Test the variant handler rejects invalid direction."""
|
||||||
|
variant_moves = {
|
||||||
|
"left": moves["punch left"],
|
||||||
|
"right": moves["punch right"],
|
||||||
|
}
|
||||||
|
handler = combat_commands._make_variant_handler(
|
||||||
|
"punch", variant_moves, combat_commands.do_attack
|
||||||
|
)
|
||||||
|
|
||||||
|
await handler(player, "up Vegeta")
|
||||||
|
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
message = player.writer.write.call_args[0][0]
|
||||||
|
assert "unknown" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- direct handler tests ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direct_handler_passes_move(player, target, punch_right):
|
||||||
|
"""Test the direct handler passes the bound move through."""
|
||||||
|
handler = combat_commands._make_direct_handler(
|
||||||
|
punch_right, combat_commands.do_attack
|
||||||
|
)
|
||||||
|
|
||||||
|
await handler(player, "Vegeta")
|
||||||
|
|
||||||
|
encounter = get_encounter(player)
|
||||||
assert encounter is not None
|
assert encounter is not None
|
||||||
assert encounter.pending_defense is not None
|
assert encounter.current_move.name == "punch right"
|
||||||
assert encounter.pending_defense.name == "dodge left"
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direct_handler_alias_for_variant(player, target, punch_right):
|
||||||
|
"""Test alias handler (e.g. pr) works for variant moves."""
|
||||||
|
handler = combat_commands._make_direct_handler(
|
||||||
|
punch_right, combat_commands.do_attack
|
||||||
|
)
|
||||||
|
|
||||||
|
await handler(player, "Vegeta")
|
||||||
|
|
||||||
|
encounter = get_encounter(player)
|
||||||
|
assert encounter is not None
|
||||||
|
assert encounter.current_move.name == "punch right"
|
||||||
|
assert encounter.attacker is player
|
||||||
|
assert encounter.defender is target
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ def test_combat_move_dataclass():
|
||||||
timing_window_ms=800,
|
timing_window_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left", "parry high"],
|
countered_by=["dodge left", "parry high"],
|
||||||
|
command="punch",
|
||||||
|
variant="right",
|
||||||
)
|
)
|
||||||
assert move.name == "punch right"
|
assert move.name == "punch right"
|
||||||
assert move.move_type == "attack"
|
assert move.move_type == "attack"
|
||||||
|
|
@ -26,6 +28,8 @@ def test_combat_move_dataclass():
|
||||||
assert move.damage_pct == 0.15
|
assert move.damage_pct == 0.15
|
||||||
assert move.countered_by == ["dodge left", "parry high"]
|
assert move.countered_by == ["dodge left", "parry high"]
|
||||||
assert move.handler is None
|
assert move.handler is None
|
||||||
|
assert move.command == "punch"
|
||||||
|
assert move.variant == "right"
|
||||||
|
|
||||||
|
|
||||||
def test_combat_move_minimal():
|
def test_combat_move_minimal():
|
||||||
|
|
@ -44,32 +48,115 @@ def test_combat_move_minimal():
|
||||||
assert move.timing_window_ms == 500
|
assert move.timing_window_ms == 500
|
||||||
assert move.damage_pct == 0.0
|
assert move.damage_pct == 0.0
|
||||||
assert move.countered_by == []
|
assert move.countered_by == []
|
||||||
|
assert move.command == ""
|
||||||
|
assert move.variant == ""
|
||||||
|
|
||||||
|
|
||||||
def test_load_move_from_toml(tmp_path):
|
def test_load_simple_move_from_toml(tmp_path):
|
||||||
"""Test loading a combat move from TOML file."""
|
"""Test loading a simple combat move from TOML file."""
|
||||||
toml_content = """
|
toml_content = """
|
||||||
name = "punch right"
|
name = "roundhouse"
|
||||||
aliases = ["pr"]
|
aliases = ["rh"]
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 8.0
|
||||||
telegraph = "{attacker} winds up a right hook!"
|
telegraph = "{attacker} spins into a roundhouse kick!"
|
||||||
timing_window_ms = 800
|
timing_window_ms = 600
|
||||||
damage_pct = 0.15
|
damage_pct = 0.25
|
||||||
countered_by = ["dodge left", "parry high"]
|
countered_by = ["duck", "parry high", "parry low"]
|
||||||
"""
|
"""
|
||||||
toml_file = tmp_path / "punch_right.toml"
|
toml_file = tmp_path / "roundhouse.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
||||||
move = load_move(toml_file)
|
moves = load_move(toml_file)
|
||||||
assert move.name == "punch right"
|
assert len(moves) == 1
|
||||||
assert move.move_type == "attack"
|
move = moves[0]
|
||||||
assert move.aliases == ["pr"]
|
assert move.name == "roundhouse"
|
||||||
assert move.stamina_cost == 5.0
|
assert move.command == "roundhouse"
|
||||||
assert move.telegraph == "{attacker} winds up a right hook!"
|
assert move.variant == ""
|
||||||
assert move.timing_window_ms == 800
|
assert move.aliases == ["rh"]
|
||||||
assert move.damage_pct == 0.15
|
assert move.damage_pct == 0.25
|
||||||
assert move.countered_by == ["dodge left", "parry high"]
|
|
||||||
|
|
||||||
|
def test_load_variant_move_from_toml(tmp_path):
|
||||||
|
"""Test loading a variant combat move from TOML file."""
|
||||||
|
toml_content = """
|
||||||
|
name = "punch"
|
||||||
|
move_type = "attack"
|
||||||
|
stamina_cost = 5.0
|
||||||
|
timing_window_ms = 800
|
||||||
|
damage_pct = 0.15
|
||||||
|
|
||||||
|
[variants.left]
|
||||||
|
aliases = ["pl"]
|
||||||
|
telegraph = "{attacker} winds up a left hook!"
|
||||||
|
countered_by = ["dodge right", "parry high"]
|
||||||
|
|
||||||
|
[variants.right]
|
||||||
|
aliases = ["pr"]
|
||||||
|
telegraph = "{attacker} winds up a right hook!"
|
||||||
|
countered_by = ["dodge left", "parry high"]
|
||||||
|
"""
|
||||||
|
toml_file = tmp_path / "punch.toml"
|
||||||
|
toml_file.write_text(toml_content)
|
||||||
|
|
||||||
|
moves = load_move(toml_file)
|
||||||
|
assert len(moves) == 2
|
||||||
|
|
||||||
|
by_name = {m.name: m for m in moves}
|
||||||
|
assert "punch left" in by_name
|
||||||
|
assert "punch right" in by_name
|
||||||
|
|
||||||
|
left = by_name["punch left"]
|
||||||
|
assert left.command == "punch"
|
||||||
|
assert left.variant == "left"
|
||||||
|
assert left.aliases == ["pl"]
|
||||||
|
assert left.telegraph == "{attacker} winds up a left hook!"
|
||||||
|
assert left.countered_by == ["dodge right", "parry high"]
|
||||||
|
# Inherited from parent
|
||||||
|
assert left.stamina_cost == 5.0
|
||||||
|
assert left.timing_window_ms == 800
|
||||||
|
assert left.damage_pct == 0.15
|
||||||
|
|
||||||
|
right = by_name["punch right"]
|
||||||
|
assert right.command == "punch"
|
||||||
|
assert right.variant == "right"
|
||||||
|
assert right.aliases == ["pr"]
|
||||||
|
assert right.countered_by == ["dodge left", "parry high"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_variant_inherits_shared_properties(tmp_path):
|
||||||
|
"""Test that variants inherit shared properties and can override them."""
|
||||||
|
toml_content = """
|
||||||
|
name = "kick"
|
||||||
|
move_type = "attack"
|
||||||
|
stamina_cost = 5.0
|
||||||
|
timing_window_ms = 800
|
||||||
|
damage_pct = 0.10
|
||||||
|
|
||||||
|
[variants.low]
|
||||||
|
aliases = ["kl"]
|
||||||
|
damage_pct = 0.08
|
||||||
|
timing_window_ms = 600
|
||||||
|
|
||||||
|
[variants.high]
|
||||||
|
aliases = ["kh"]
|
||||||
|
damage_pct = 0.15
|
||||||
|
"""
|
||||||
|
toml_file = tmp_path / "kick.toml"
|
||||||
|
toml_file.write_text(toml_content)
|
||||||
|
|
||||||
|
moves = load_move(toml_file)
|
||||||
|
by_name = {m.name: m for m in moves}
|
||||||
|
|
||||||
|
low = by_name["kick low"]
|
||||||
|
assert low.damage_pct == 0.08
|
||||||
|
assert low.timing_window_ms == 600 # overridden
|
||||||
|
assert low.stamina_cost == 5.0 # inherited
|
||||||
|
|
||||||
|
high = by_name["kick high"]
|
||||||
|
assert high.damage_pct == 0.15
|
||||||
|
assert high.timing_window_ms == 800 # inherited
|
||||||
|
assert high.stamina_cost == 5.0 # inherited
|
||||||
|
|
||||||
|
|
||||||
def test_load_move_with_defaults(tmp_path):
|
def test_load_move_with_defaults(tmp_path):
|
||||||
|
|
@ -83,7 +170,9 @@ timing_window_ms = 600
|
||||||
toml_file = tmp_path / "basic.toml"
|
toml_file = tmp_path / "basic.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
||||||
move = load_move(toml_file)
|
moves = load_move(toml_file)
|
||||||
|
assert len(moves) == 1
|
||||||
|
move = moves[0]
|
||||||
assert move.name == "basic move"
|
assert move.name == "basic move"
|
||||||
assert move.aliases == []
|
assert move.aliases == []
|
||||||
assert move.telegraph == ""
|
assert move.telegraph == ""
|
||||||
|
|
@ -149,32 +238,31 @@ stamina_cost = 5.0
|
||||||
|
|
||||||
def test_load_moves_from_directory(tmp_path):
|
def test_load_moves_from_directory(tmp_path):
|
||||||
"""Test loading all moves from a directory."""
|
"""Test loading all moves from a directory."""
|
||||||
# Create multiple TOML files
|
# Create a variant move
|
||||||
punch_toml = tmp_path / "punch_right.toml"
|
punch_toml = tmp_path / "punch.toml"
|
||||||
punch_toml.write_text(
|
punch_toml.write_text(
|
||||||
"""
|
"""
|
||||||
name = "punch right"
|
name = "punch"
|
||||||
aliases = ["pr"]
|
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
telegraph = "{attacker} winds up a right hook!"
|
|
||||||
timing_window_ms = 800
|
timing_window_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
countered_by = ["dodge left", "parry high"]
|
|
||||||
|
[variants.right]
|
||||||
|
aliases = ["pr"]
|
||||||
|
telegraph = "{attacker} winds up a right hook!"
|
||||||
|
countered_by = ["dodge left"]
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
dodge_toml = tmp_path / "dodge_left.toml"
|
# Create a simple move
|
||||||
|
dodge_toml = tmp_path / "dodge.toml"
|
||||||
dodge_toml.write_text(
|
dodge_toml.write_text(
|
||||||
"""
|
"""
|
||||||
name = "dodge left"
|
name = "duck"
|
||||||
aliases = ["dl"]
|
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
telegraph = ""
|
|
||||||
timing_window_ms = 500
|
timing_window_ms = 500
|
||||||
damage_pct = 0.0
|
|
||||||
countered_by = []
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -184,19 +272,19 @@ countered_by = []
|
||||||
|
|
||||||
moves = load_moves(tmp_path)
|
moves = load_moves(tmp_path)
|
||||||
|
|
||||||
# Should have entries for both names and all aliases
|
# Should have entries for variant qualified names, aliases, and simple moves
|
||||||
assert "punch right" in moves
|
assert "punch right" in moves
|
||||||
assert "pr" in moves
|
assert "pr" in moves
|
||||||
assert "dodge left" in moves
|
assert "duck" in moves
|
||||||
assert "dl" in moves
|
|
||||||
|
|
||||||
# All aliases should point to the same object
|
# Aliases should point to the same object
|
||||||
assert moves["punch right"] is moves["pr"]
|
assert moves["punch right"] is moves["pr"]
|
||||||
assert moves["dodge left"] is moves["dl"]
|
|
||||||
|
|
||||||
# Check move properties
|
# Check variant properties
|
||||||
assert moves["punch right"].name == "punch right"
|
assert moves["punch right"].command == "punch"
|
||||||
assert moves["dodge left"].name == "dodge left"
|
assert moves["punch right"].variant == "right"
|
||||||
|
assert moves["duck"].command == "duck"
|
||||||
|
assert moves["duck"].variant == ""
|
||||||
|
|
||||||
|
|
||||||
def test_load_moves_empty_directory(tmp_path):
|
def test_load_moves_empty_directory(tmp_path):
|
||||||
|
|
@ -264,26 +352,30 @@ def test_load_moves_validates_countered_by_refs(tmp_path, caplog):
|
||||||
"""Test that invalid countered_by references log warnings."""
|
"""Test that invalid countered_by references log warnings."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Create a move with an invalid counter reference
|
punch_toml = tmp_path / "punch.toml"
|
||||||
punch_toml = tmp_path / "punch_right.toml"
|
|
||||||
punch_toml.write_text(
|
punch_toml.write_text(
|
||||||
"""
|
"""
|
||||||
name = "punch right"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
timing_window_ms = 800
|
timing_window_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
|
[variants.right]
|
||||||
countered_by = ["dodge left", "nonexistent move"]
|
countered_by = ["dodge left", "nonexistent move"]
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
dodge_toml = tmp_path / "dodge_left.toml"
|
dodge_toml = tmp_path / "dodge.toml"
|
||||||
dodge_toml.write_text(
|
dodge_toml.write_text(
|
||||||
"""
|
"""
|
||||||
name = "dodge left"
|
name = "dodge"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
timing_window_ms = 500
|
timing_window_ms = 500
|
||||||
|
|
||||||
|
[variants.left]
|
||||||
|
aliases = ["dl"]
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -303,36 +395,43 @@ def test_load_moves_valid_countered_by_refs_no_warning(tmp_path, caplog):
|
||||||
"""Test that valid countered_by references don't log warnings."""
|
"""Test that valid countered_by references don't log warnings."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Create moves with valid counter references
|
punch_toml = tmp_path / "punch.toml"
|
||||||
punch_toml = tmp_path / "punch_right.toml"
|
|
||||||
punch_toml.write_text(
|
punch_toml.write_text(
|
||||||
"""
|
"""
|
||||||
name = "punch right"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
timing_window_ms = 800
|
timing_window_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
|
[variants.right]
|
||||||
countered_by = ["dodge left", "parry high"]
|
countered_by = ["dodge left", "parry high"]
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
dodge_toml = tmp_path / "dodge_left.toml"
|
dodge_toml = tmp_path / "dodge.toml"
|
||||||
dodge_toml.write_text(
|
dodge_toml.write_text(
|
||||||
"""
|
"""
|
||||||
name = "dodge left"
|
name = "dodge"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
timing_window_ms = 500
|
timing_window_ms = 500
|
||||||
|
|
||||||
|
[variants.left]
|
||||||
|
aliases = ["dl"]
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
parry_toml = tmp_path / "parry_high.toml"
|
parry_toml = tmp_path / "parry.toml"
|
||||||
parry_toml.write_text(
|
parry_toml.write_text(
|
||||||
"""
|
"""
|
||||||
name = "parry high"
|
name = "parry"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
timing_window_ms = 500
|
timing_window_ms = 500
|
||||||
|
|
||||||
|
[variants.high]
|
||||||
|
aliases = ["f"]
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -346,3 +445,40 @@ timing_window_ms = 500
|
||||||
|
|
||||||
# Should have no warnings
|
# Should have no warnings
|
||||||
assert len(caplog.records) == 0
|
assert len(caplog.records) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_content_combat_directory():
|
||||||
|
"""Test loading the actual content/combat directory."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
content_dir = Path(__file__).parent.parent / "content" / "combat"
|
||||||
|
moves = load_moves(content_dir)
|
||||||
|
|
||||||
|
# Verify variant moves have correct structure
|
||||||
|
assert "punch left" in moves
|
||||||
|
assert "punch right" in moves
|
||||||
|
assert moves["punch left"].command == "punch"
|
||||||
|
assert moves["punch left"].variant == "left"
|
||||||
|
|
||||||
|
assert "dodge left" in moves
|
||||||
|
assert "dodge right" in moves
|
||||||
|
assert moves["dodge left"].command == "dodge"
|
||||||
|
|
||||||
|
assert "parry high" in moves
|
||||||
|
assert "parry low" in moves
|
||||||
|
assert moves["parry high"].command == "parry"
|
||||||
|
|
||||||
|
# Verify simple moves
|
||||||
|
assert "roundhouse" in moves
|
||||||
|
assert moves["roundhouse"].command == "roundhouse"
|
||||||
|
assert moves["roundhouse"].variant == ""
|
||||||
|
|
||||||
|
assert "sweep" in moves
|
||||||
|
assert "duck" in moves
|
||||||
|
assert "jump" in moves
|
||||||
|
|
||||||
|
# Verify aliases
|
||||||
|
assert "pl" in moves
|
||||||
|
assert moves["pl"] is moves["punch left"]
|
||||||
|
assert "pr" in moves
|
||||||
|
assert moves["pr"] is moves["punch right"]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue