When altitude differs at resolve time (attacker or defender changed flying state during window phase), attack misses. Treated as successful dodge with zero damage.
191 lines
6 KiB
Python
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,
|
|
)
|