mud/docs/how/combat.rst

301 lines
8.6 KiB
ReStructuredText

==============================
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 → PENDING → RESOLVE → IDLE
IDLE:
no active move. attacker can initiate attack. defender can do nothing
combat-specific.
PENDING:
attacker has declared a move. telegraph message sent to defender.
the move is now in flight — defender can queue a defense any time
during this phase. duration is the attack's hit_time_ms.
RESOLVE:
hit_time_ms elapsed. check if defender's active defense counters the
attack. calculate damage, apply stamina costs, check for knockouts
or exhaustion. 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!"
hit_time_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
hit_time_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, hit_time_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="".
**defense move** (directional)::
# content/combat/dodge.toml
name = "dodge"
move_type = "defense"
stamina_cost = 3.0
active_ms = 800
recovery_ms = 2700
[variants.left]
[variants.right]
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
- hit_time_ms: (attacks) time in ms from initiation to impact
- active_ms: (defenses) how long defense blocks once activated, in ms
- recovery_ms: (defenses) lockout after active window ends, in ms
- telegraph: shown to defender during PENDING 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.