Compare commits
4 commits
8f3455ce0a
...
6344c09275
| Author | SHA1 | Date | |
|---|---|---|---|
| 6344c09275 | |||
| 91c57d89e1 | |||
| 83455c525f | |||
| ef658fd987 |
16 changed files with 901 additions and 517 deletions
|
|
@ -4,7 +4,7 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv.
|
|||
|
||||
## Commands
|
||||
|
||||
- `just check` - lint (ruff --fix + format), typecheck (pyright), test (pytest)
|
||||
- `just check` - lint (ruff --fix + format), typecheck (ty), test (pytest)
|
||||
- `just lint` / `just typecheck` / `just test` - individual steps
|
||||
- `just run` - start the server (`python -m mudlib`)
|
||||
- `just debug` - start with debug logging
|
||||
|
|
@ -14,6 +14,7 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv.
|
|||
|
||||
- `src/mudlib/` - the engine (commands, world, combat, render, store)
|
||||
- `tests/` - pytest tests
|
||||
- `content/` - content definitions (commands, combat moves) loaded at runtime
|
||||
- `worlds/` - world definition files (yaml/toml, version controlled)
|
||||
- `docs/` - project knowledge (see below)
|
||||
- `DREAMBOOK.md` - the vision, philosophy, wild ideas. not a spec
|
||||
|
|
@ -23,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
|
||||
|
|
@ -49,6 +50,12 @@ Update docs when:
|
|||
- world definitions live in data files, runtime state lives in memory
|
||||
- SQLite for persistence (player accounts, progress)
|
||||
- session mode stack filters what events reach the player (normal/combat/editor)
|
||||
- combat system: state machine with TOML-defined moves (attacks, defends, counters). see `docs/how/combat.rst`
|
||||
- moves with directional variants (punch left/right) use a single TOML with `[variants]` sections
|
||||
- commands are always single words; direction is an argument, not part of the command name
|
||||
- content loading: TOML definitions for commands and combat moves, loaded at startup
|
||||
- entity model: Entity base class, Player and Mob subclasses sharing common interface
|
||||
- editor mode: in-world text editor with syntax highlighting and search/replace
|
||||
|
||||
## Style
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
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"]
|
||||
|
|
@ -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"
|
||||
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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -2,10 +2,10 @@ architecture plan — designing for programmability
|
|||
====================================================
|
||||
|
||||
where we are and where we're going. we have a working telnet MUD with
|
||||
procedural terrain, movement, viewport rendering, effects. combat, persistence,
|
||||
session modes, editor, in-world programming — all ahead of us. this doc
|
||||
captures what we should get right in the foundation so those features don't
|
||||
require rewrites.
|
||||
procedural terrain, movement, viewport rendering, effects. we've built the
|
||||
command system, game loop, mode stack, content loading, entity model, combat,
|
||||
persistence, and editor mode. in-world programming remains ahead. this doc
|
||||
captured what we needed to get right in the foundation — most of it is done.
|
||||
|
||||
the core lesson from studying the ecosystem: the things that lasted (LPC
|
||||
mudlibs, MOO worlds, evennia typeclasses) all share one trait — game content
|
||||
|
|
|
|||
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).
|
||||
|
|
@ -58,6 +58,58 @@ adding commands
|
|||
4. call register(CommandDefinition(...))
|
||||
5. import the module in server.py so registration runs at startup
|
||||
|
||||
combat commands
|
||||
---------------
|
||||
|
||||
combat moves are defined in TOML files in content/combat/. each file describes
|
||||
a move (attack, defend, counter) with metadata:
|
||||
|
||||
name
|
||||
type (attack/defend)
|
||||
stamina_cost
|
||||
telegraph (what the opponent sees)
|
||||
timing_window_ms (how long to react)
|
||||
damage_pct (base damage as % of attacker PL)
|
||||
counters (list of valid defensive moves)
|
||||
|
||||
the combat system loads these at startup and registers them as commands via
|
||||
combat/commands.py. when combat mode is active, these commands become available.
|
||||
players use the move name to execute it during their turn.
|
||||
|
||||
content-defined commands
|
||||
------------------------
|
||||
|
||||
commands can be defined in TOML files in content/commands/. these are loaded
|
||||
at startup by content.py and registered alongside python-defined commands.
|
||||
|
||||
format:
|
||||
|
||||
name = "example"
|
||||
aliases = ["ex"]
|
||||
help = "example command help text"
|
||||
mode = "normal"
|
||||
|
||||
content commands currently require a python handler (body field pointing to a
|
||||
callable). when the DSL arrives, this will be replaced with inline scripting.
|
||||
|
||||
editor mode
|
||||
-----------
|
||||
|
||||
the edit command pushes editor mode onto the mode stack. while editor mode is
|
||||
active, all input bypasses the command dispatcher and goes to the editor buffer
|
||||
instead.
|
||||
|
||||
the editor provides:
|
||||
|
||||
line editing with insert/append/delete/replace
|
||||
search and replace (regex supported)
|
||||
syntax highlighting for TOML/Python
|
||||
save/discard changes
|
||||
dirty flag tracking
|
||||
|
||||
quit (:q) pops editor mode and returns to normal. the editor is implemented in
|
||||
editor.py and uses the mode stack to capture input.
|
||||
|
||||
code
|
||||
----
|
||||
|
||||
|
|
@ -66,4 +118,7 @@ src/mudlib/commands/movement.py direction commands
|
|||
src/mudlib/commands/look.py look/l
|
||||
src/mudlib/commands/fly.py fly
|
||||
src/mudlib/commands/quit.py quit/q (mode="*")
|
||||
src/mudlib/combat/commands.py combat move commands (loaded from TOML)
|
||||
src/mudlib/content.py content loading (commands and combat moves)
|
||||
src/mudlib/editor.py editor mode and buffer management
|
||||
src/mudlib/player.py Player dataclass + registry
|
||||
|
|
|
|||
9
mud.tin
9
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}
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue