mud/src/mudlib/combat/encounter.py
Jared Miller d6d62abdb8
Add flying dodge mechanic at resolve time
When altitude differs at resolve time (attacker or defender changed flying state during window phase), attack misses. Treated as successful dodge with zero damage.
2026-02-14 01:00:37 -05:00

191 lines
6 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
# Seconds of no action before combat fizzles out
IDLE_TIMEOUT = 30.0
@dataclass
class ResolveResult:
"""Result of resolving a combat exchange."""
resolve_template: str # POV template for resolve message
damage: float
countered: bool
combat_ended: bool
@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
last_action_at: float = 0.0
def attack(self, move: CombatMove) -> None:
"""Initiate or switch an attack move.
If called during TELEGRAPH or WINDOW, switches to the new move
without resetting the timer. Refunds old move's stamina cost.
Args:
move: The attack move to execute
"""
now = time.monotonic()
if self.state in (CombatState.TELEGRAPH, CombatState.WINDOW):
# Switching — refund old cost, keep timer
if self.current_move:
self.attacker.stamina = min(
self.attacker.stamina + self.current_move.stamina_cost,
self.attacker.max_stamina,
)
else:
# First attack — start timer
self.move_started_at = now
self.current_move = move
self.attacker.stamina -= move.stamina_cost
self.last_action_at = now
if self.state == CombatState.IDLE:
self.state = CombatState.TELEGRAPH
def defend(self, move: CombatMove) -> None:
"""Queue a defense move on the encounter.
Stamina cost and lock are handled by the command layer (do_defend).
Args:
move: The defense move to attempt
"""
self.pending_defense = move
self.last_action_at = time.monotonic()
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) -> ResolveResult:
"""Resolve the combat exchange and return result.
Returns:
ResolveResult with template for both participants
"""
if self.current_move is None:
return ResolveResult(
resolve_template="No active move to resolve.",
damage=0.0,
countered=False,
combat_ended=False,
)
# Check for altitude mismatch (flying dodge)
attacker_flying = getattr(self.attacker, "flying", False)
defender_flying = getattr(self.defender, "flying", False)
if attacker_flying != defender_flying:
# Altitude mismatch - attack misses
template = "{attacker}'s attack miss{es}{defender} {is|is} out of reach!"
# Reset to IDLE
self.state = CombatState.IDLE
self.current_move = None
self.pending_defense = None
return ResolveResult(
resolve_template=template,
damage=0.0,
countered=True,
combat_ended=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
damage = 0.0
template = (
self.current_move.resolve_miss
if self.current_move.resolve_miss
else "{defender} counter{s} {attacker}'s attack!"
)
countered = True
elif self.pending_defense:
# Wrong defense - normal damage
damage = self.attacker.pl * self.current_move.damage_pct
self.defender.pl -= damage
template = (
self.current_move.resolve_hit
if self.current_move.resolve_hit
else "{attacker}'s attack hit{s} {defender} for damage!"
)
countered = False
else:
# No defense - increased damage
damage = self.attacker.pl * self.current_move.damage_pct * 1.5
self.defender.pl -= damage
template = (
self.current_move.resolve_hit
if self.current_move.resolve_hit
else "{attacker}'s attack hit{s} {defender} for damage!"
)
countered = False
# 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 ResolveResult(
resolve_template=template,
damage=damage,
countered=countered,
combat_ended=combat_ended,
)