mud/src/mudlib/combat/encounter.py
Jared Miller dbb976be24
Add data-driven combat system with TOML move definitions
Combat moves defined as TOML content files in content/combat/,
not engine code. State machine (IDLE > TELEGRAPH > WINDOW > RESOLVE)
processes timing-based exchanges. Counter relationships, stamina
costs, damage formulas all tunable from data files.

Moves: punch right/left, roundhouse, sweep, dodge right/left,
parry high/low, duck, jump. Combat ends on knockout (PL <= 0)
or exhaustion (stamina <= 0).
2026-02-07 21:16:12 -05:00

124 lines
3.7 KiB
Python

"""Combat encounter and state machine."""
import time
from dataclasses import dataclass
from enum import Enum
from mudlib.combat.moves import CombatMove
from mudlib.entity import Entity
class CombatState(Enum):
"""States of the combat state machine."""
IDLE = "idle"
TELEGRAPH = "telegraph"
WINDOW = "window"
RESOLVE = "resolve"
# Telegraph phase duration in seconds (3 game ticks at 100ms/tick)
TELEGRAPH_DURATION = 0.3
@dataclass
class CombatEncounter:
"""Represents an active combat encounter between two entities."""
attacker: Entity
defender: Entity
state: CombatState = CombatState.IDLE
current_move: CombatMove | None = None
move_started_at: float = 0.0
pending_defense: CombatMove | None = None
def attack(self, move: CombatMove) -> None:
"""Initiate an attack move.
Args:
move: The attack move to execute
"""
self.current_move = move
self.state = CombatState.TELEGRAPH
self.move_started_at = time.monotonic()
# Apply stamina cost
self.attacker.stamina -= move.stamina_cost
def defend(self, move: CombatMove) -> None:
"""Queue a defense move.
Args:
move: The defense move to attempt
"""
self.pending_defense = move
# Apply stamina cost
self.defender.stamina -= move.stamina_cost
def tick(self, now: float) -> None:
"""Advance the state machine based on current time.
Args:
now: Current time from monotonic clock
"""
if self.state == CombatState.TELEGRAPH:
# Check if telegraph phase is over
elapsed = now - self.move_started_at
if elapsed >= TELEGRAPH_DURATION:
self.state = CombatState.WINDOW
elif self.state == CombatState.WINDOW:
# Check if timing window has expired
if self.current_move is None:
return
elapsed = now - self.move_started_at
window_seconds = self.current_move.timing_window_ms / 1000.0
total_time = TELEGRAPH_DURATION + window_seconds
if elapsed >= total_time:
self.state = CombatState.RESOLVE
def resolve(self) -> tuple[str, bool]:
"""Resolve the combat exchange and return result message.
Returns:
Tuple of (result message, combat_ended flag)
"""
if self.current_move is None:
return ("No active move to resolve.", False)
# Check if defense counters attack
defense_succeeds = (
self.pending_defense
and self.pending_defense.name in self.current_move.countered_by
)
if defense_succeeds:
# Successful counter - no damage
result = f"{self.defender.name} countered the attack!"
elif self.pending_defense:
# Wrong defense - normal damage
damage = self.attacker.pl * self.current_move.damage_pct
self.defender.pl -= damage
result = (
f"{self.attacker.name} hit {self.defender.name} "
f"for {damage:.1f} damage!"
)
else:
# No defense - increased damage
damage = self.attacker.pl * self.current_move.damage_pct * 1.5
self.defender.pl -= damage
result = (
f"{self.defender.name} took the hit full force for {damage:.1f} damage!"
)
# Check for combat end conditions
combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0
# Reset to IDLE
self.state = CombatState.IDLE
self.current_move = None
self.pending_defense = None
return (result, combat_ended)