"""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, )