Rework combat state machine

PENDING phase, defense active/recovery windows
This commit is contained in:
Jared Miller 2026-02-15 16:24:26 -05:00
parent 312da1dbac
commit edbad4666f
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
24 changed files with 488 additions and 238 deletions

View file

@ -1,6 +1,5 @@
"""Combat command handlers.""" """Combat command handlers."""
import asyncio
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
@ -102,10 +101,7 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
await defender.send(f"{telegraph}\r\n") await defender.send(f"{telegraph}\r\n")
# Detect switch before attack() modifies state # Detect switch before attack() modifies state
switching = encounter.state in ( switching = encounter.state == CombatState.PENDING
CombatState.TELEGRAPH,
CombatState.WINDOW,
)
# Execute the attack (deducts stamina) # Execute the attack (deducts stamina)
encounter.attack(move) encounter.attack(move)
@ -127,8 +123,8 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
async def do_defend(player: Player, _args: str, move: CombatMove) -> None: async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
"""Core defense logic with a resolved move. """Core defense logic with a resolved move.
Works both in and outside combat. Applies a recovery lock Works both in and outside combat. The encounter tracks active/recovery
(based on timing_window_ms) so defenses have commitment. windows internally.
Args: Args:
player: The defending player player: The defending player
@ -156,7 +152,7 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
# Check stamina cues after defense cost # Check stamina cues after defense cost
await check_stamina_cues(player) await check_stamina_cues(player)
# If in combat, queue the defense on the encounter # If in combat, queue/activate the defense on the encounter
encounter = get_encounter(player) encounter = get_encounter(player)
if encounter is not None: if encounter is not None:
encounter.defend(move) encounter.defend(move)
@ -171,9 +167,6 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
f"{player.name} {move.command}s!\r\n", f"{player.name} {move.command}s!\r\n",
) )
# Commitment: block for the timing window (inputs queue naturally)
await asyncio.sleep(move.timing_window_ms / 1000.0)
if encounter is not None: if encounter is not None:
await player.send(f"You {move.name}!\r\n") await player.send(f"You {move.name}!\r\n")
else: else:

View file

@ -12,14 +12,10 @@ class CombatState(Enum):
"""States of the combat state machine.""" """States of the combat state machine."""
IDLE = "idle" IDLE = "idle"
TELEGRAPH = "telegraph" PENDING = "pending"
WINDOW = "window"
RESOLVE = "resolve" RESOLVE = "resolve"
# Telegraph phase duration in seconds (3 game ticks at 100ms/tick)
TELEGRAPH_DURATION = 0.3
# Seconds since last landed damage before combat fizzles out # Seconds since last landed damage before combat fizzles out
IDLE_TIMEOUT = 30.0 IDLE_TIMEOUT = 30.0
@ -44,45 +40,60 @@ class CombatEncounter:
current_move: CombatMove | None = None current_move: CombatMove | None = None
move_started_at: float = 0.0 move_started_at: float = 0.0
pending_defense: CombatMove | None = None pending_defense: CombatMove | None = None
defense_activated_at: float | None = None
defense_recovery_until: float | None = None
queued_defense: CombatMove | None = None
# Monotonic timestamp of most recent landed damage in this encounter. # Monotonic timestamp of most recent landed damage in this encounter.
last_action_at: float = 0.0 last_action_at: float = 0.0
def attack(self, move: CombatMove) -> None: def attack(self, move: CombatMove) -> None:
"""Initiate or switch an attack move. """Initiate or switch an attack move.
If called during TELEGRAPH or WINDOW, switches to the new move If called during PENDING, switches to the new move and restarts
without resetting the timer. Refunds old move's stamina cost. the timer. Refunds old move's stamina cost.
Args: Args:
move: The attack move to execute move: The attack move to execute
""" """
now = time.monotonic() now = time.monotonic()
if self.state in (CombatState.TELEGRAPH, CombatState.WINDOW): if self.state == CombatState.PENDING and self.current_move:
# Switching — refund old cost, keep timer # Switching — refund old cost
if self.current_move: self.attacker.stamina = min(
self.attacker.stamina = min( self.attacker.stamina + self.current_move.stamina_cost,
self.attacker.stamina + self.current_move.stamina_cost, self.attacker.max_stamina,
self.attacker.max_stamina, )
)
else:
# First attack — start timer
self.move_started_at = now
# Always restart timer
self.move_started_at = now
self.current_move = move self.current_move = move
self.attacker.stamina -= move.stamina_cost self.attacker.stamina -= move.stamina_cost
if self.state == CombatState.IDLE: if self.state == CombatState.IDLE:
self.state = CombatState.TELEGRAPH self.state = CombatState.PENDING
def defend(self, move: CombatMove) -> None: def defend(self, move: CombatMove) -> None:
"""Queue a defense move on the encounter. """Queue or activate a defense move.
Stamina cost and lock are handled by the command layer (do_defend). If in recovery, queues the defense. Otherwise activates immediately.
Stamina cost is handled by the command layer (do_defend).
Args: Args:
move: The defense move to attempt move: The defense move to attempt
""" """
self.pending_defense = move now = time.monotonic()
# Check if in recovery
if (
self.defense_recovery_until is not None
and now < self.defense_recovery_until
):
# Queue for later activation
self.queued_defense = move
else:
# Activate immediately
self.pending_defense = move
self.defense_activated_at = now
self.queued_defense = None
def tick(self, now: float) -> None: def tick(self, now: float) -> None:
"""Advance the state machine based on current time. """Advance the state machine based on current time.
@ -90,23 +101,45 @@ class CombatEncounter:
Args: Args:
now: Current time from monotonic clock now: Current time from monotonic clock
""" """
if self.state == CombatState.TELEGRAPH: # Check if queued defense should activate
# Check if telegraph phase is over if (
elapsed = now - self.move_started_at self.queued_defense is not None
if elapsed >= TELEGRAPH_DURATION: and self.defense_recovery_until is not None
self.state = CombatState.WINDOW and now >= self.defense_recovery_until
):
# Activate queued defense
self.pending_defense = self.queued_defense
self.defense_activated_at = now
self.queued_defense = None
self.defense_recovery_until = None
elif self.state == CombatState.WINDOW: # Check PENDING -> RESOLVE transition
# Check if timing window has expired if self.state == CombatState.PENDING:
if self.current_move is None: if self.current_move is None:
return return
elapsed = now - self.move_started_at elapsed = now - self.move_started_at
window_seconds = self.current_move.timing_window_ms / 1000.0 hit_time_seconds = self.current_move.hit_time_ms / 1000.0
total_time = TELEGRAPH_DURATION + window_seconds
if elapsed >= total_time: if elapsed >= hit_time_seconds:
self.state = CombatState.RESOLVE self.state = CombatState.RESOLVE
# Don't expire defense here - resolve() will handle it after checking
# Only expire defense if NOT in RESOLVE state
# (resolve() will clear defense after checking for counters)
if (
self.state != CombatState.RESOLVE
and self.defense_activated_at is not None
and self.pending_defense is not None
):
active_duration = now - self.defense_activated_at
active_seconds = self.pending_defense.active_ms / 1000.0
if active_duration >= active_seconds:
# Defense window expired, enter recovery
recovery_seconds = self.pending_defense.recovery_ms / 1000.0
self.defense_recovery_until = now + recovery_seconds
self.defense_activated_at = None
self.pending_defense = None
def resolve(self) -> ResolveResult: def resolve(self) -> ResolveResult:
"""Resolve the combat exchange and return result. """Resolve the combat exchange and return result.
@ -140,8 +173,16 @@ class CombatEncounter:
) )
# Check if defense counters attack # Check if defense counters attack
# Defense must be active (in active window) to succeed
defense_is_active = False
if self.defense_activated_at is not None and self.pending_defense is not None:
active_duration = time.monotonic() - self.defense_activated_at
active_window = self.pending_defense.active_ms / 1000.0
defense_is_active = active_duration < active_window
defense_succeeds = ( defense_succeeds = (
self.pending_defense defense_is_active
and self.pending_defense is not None
and self.pending_defense.name in self.current_move.countered_by and self.pending_defense.name in self.current_move.countered_by
) )
if defense_succeeds: if defense_succeeds:
@ -178,10 +219,12 @@ class CombatEncounter:
self.last_action_at = time.monotonic() self.last_action_at = time.monotonic()
combat_ended = False combat_ended = False
# Reset to IDLE # Reset to IDLE and clear defense state
# Note: defense_recovery_until persists across attacks
self.state = CombatState.IDLE self.state = CombatState.IDLE
self.current_move = None self.current_move = None
self.pending_defense = None self.pending_defense = None
self.defense_activated_at = None
return ResolveResult( return ResolveResult(
resolve_template=template, resolve_template=template,

View file

@ -108,10 +108,10 @@ async def process_combat() -> None:
# Tick the state machine # Tick the state machine
encounter.tick(now) encounter.tick(now)
# Send announce message on TELEGRAPH → WINDOW transition # Send announce message on PENDING → RESOLVE transition
if ( if (
previous_state == CombatState.TELEGRAPH previous_state == CombatState.PENDING
and encounter.state == CombatState.WINDOW and encounter.state == CombatState.RESOLVE
and encounter.current_move and encounter.current_move
and encounter.current_move.announce and encounter.current_move.announce
): ):

View file

@ -28,7 +28,9 @@ class CombatMove:
name: str name: str
move_type: str # "attack" or "defense" move_type: str # "attack" or "defense"
stamina_cost: float stamina_cost: float
timing_window_ms: int hit_time_ms: int = 0 # for attacks: ms from initiation to impact
active_ms: int = 0 # for defenses: how long defense blocks once activated
recovery_ms: int = 0 # for defenses: lockout after active window ends
aliases: list[str] = field(default_factory=list) aliases: list[str] = field(default_factory=list)
telegraph: str = "" telegraph: str = ""
damage_pct: float = 0.0 damage_pct: float = 0.0
@ -69,7 +71,7 @@ def load_move(path: Path) -> list[CombatMove]:
data = tomllib.load(f) data = tomllib.load(f)
# Required fields # Required fields
required_fields = ["name", "move_type", "stamina_cost", "timing_window_ms"] required_fields = ["name", "move_type", "stamina_cost"]
for field_name in required_fields: for field_name in required_fields:
if field_name not in data: if field_name not in data:
msg = f"missing required field: {field_name}" msg = f"missing required field: {field_name}"
@ -97,8 +99,12 @@ def load_move(path: Path) -> list[CombatMove]:
name=qualified_name, name=qualified_name,
move_type=data["move_type"], move_type=data["move_type"],
stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]), stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]),
timing_window_ms=variant_data.get( hit_time_ms=variant_data.get(
"timing_window_ms", data["timing_window_ms"] "hit_time_ms", data.get("hit_time_ms", 0)
),
active_ms=variant_data.get("active_ms", data.get("active_ms", 0)),
recovery_ms=variant_data.get(
"recovery_ms", data.get("recovery_ms", 0)
), ),
aliases=variant_data.get("aliases", []), aliases=variant_data.get("aliases", []),
telegraph=variant_data.get("telegraph", data.get("telegraph", "")), telegraph=variant_data.get("telegraph", data.get("telegraph", "")),
@ -139,7 +145,9 @@ def load_move(path: Path) -> list[CombatMove]:
name=base_name, name=base_name,
move_type=data["move_type"], move_type=data["move_type"],
stamina_cost=data["stamina_cost"], stamina_cost=data["stamina_cost"],
timing_window_ms=data["timing_window_ms"], hit_time_ms=data.get("hit_time_ms", 0),
active_ms=data.get("active_ms", 0),
recovery_ms=data.get("recovery_ms", 0),
aliases=data.get("aliases", []), aliases=data.get("aliases", []),
telegraph=data.get("telegraph", ""), telegraph=data.get("telegraph", ""),
damage_pct=data.get("damage_pct", 0.0), damage_pct=data.get("damage_pct", 0.0),

View file

@ -123,7 +123,11 @@ async def _show_single_command(
# Combat move specific details # Combat move specific details
if move is not None: if move is not None:
lines.append(f" stamina: {move.stamina_cost}") lines.append(f" stamina: {move.stamina_cost}")
lines.append(f" timing window: {move.timing_window_ms}ms") if move.move_type == "attack":
lines.append(f" hit time: {move.hit_time_ms}ms")
else: # defense
lines.append(f" active window: {move.active_ms}ms")
lines.append(f" recovery: {move.recovery_ms}ms")
if move.damage_pct > 0: if move.damage_pct > 0:
damage_pct = int(move.damage_pct * 100) damage_pct = int(move.damage_pct * 100)
lines.append(f" damage: {damage_pct}%") lines.append(f" damage: {damage_pct}%")
@ -188,7 +192,11 @@ async def _show_variant_overview(
lines.append(f" aliases: {aliases_str}") lines.append(f" aliases: {aliases_str}")
lines.append(f" stamina: {move.stamina_cost}") lines.append(f" stamina: {move.stamina_cost}")
lines.append(f" timing window: {move.timing_window_ms}ms") if move.move_type == "attack":
lines.append(f" hit time: {move.hit_time_ms}ms")
else: # defense
lines.append(f" active window: {move.active_ms}ms")
lines.append(f" recovery: {move.recovery_ms}ms")
if move.damage_pct > 0: if move.damage_pct > 0:
damage_pct = int(move.damage_pct * 100) damage_pct = int(move.damage_pct * 100)

View file

@ -46,11 +46,8 @@ async def process_mobs(combat_moves: dict[str, CombatMove]) -> None:
# Determine if mob is attacker or defender in this encounter # Determine if mob is attacker or defender in this encounter
mob_is_defender = encounter.defender is mob mob_is_defender = encounter.defender is mob
# Defense AI: react during TELEGRAPH or WINDOW when mob is defender # Defense AI: react during PENDING when mob is defender
if mob_is_defender and encounter.state in ( if mob_is_defender and encounter.state == CombatState.PENDING:
CombatState.TELEGRAPH,
CombatState.WINDOW,
):
_try_defend(mob, encounter, combat_moves, now) _try_defend(mob, encounter, combat_moves, now)
continue continue

View file

@ -345,14 +345,14 @@ async def test_switch_attack_sends_new_telegraph(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_defense_blocks_for_timing_window(player, dodge_left): async def test_defense_does_not_block(player, dodge_left):
"""Test defense sleeps for timing_window_ms (commitment via blocking).""" """Test defense no longer blocks (encounter tracks active/recovery internally)."""
before = time.monotonic() before = time.monotonic()
await combat_commands.do_defend(player, "", dodge_left) await combat_commands.do_defend(player, "", dodge_left)
elapsed = time.monotonic() - before elapsed = time.monotonic() - before
expected = dodge_left.timing_window_ms / 1000.0 # Should return immediately, not block for active_ms
assert elapsed >= expected - 0.05 assert elapsed < 0.1 # Allow for some overhead
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -25,7 +25,7 @@ def punch():
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
timing_window_ms=800, hit_time_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left", "parry high"], countered_by=["dodge left", "parry high"],
) )
@ -37,7 +37,8 @@ def dodge():
name="dodge left", name="dodge left",
move_type="defense", move_type="defense",
stamina_cost=3.0, stamina_cost=3.0,
timing_window_ms=800, active_ms=800,
recovery_ms=2700,
) )
@ -47,7 +48,8 @@ def wrong_dodge():
name="dodge right", name="dodge right",
move_type="defense", move_type="defense",
stamina_cost=3.0, stamina_cost=3.0,
timing_window_ms=800, active_ms=800,
recovery_ms=2700,
) )
@ -57,7 +59,7 @@ def sweep():
name="sweep", name="sweep",
move_type="attack", move_type="attack",
stamina_cost=8.0, stamina_cost=8.0,
timing_window_ms=600, hit_time_ms=600,
damage_pct=0.20, damage_pct=0.20,
countered_by=["jump"], countered_by=["jump"],
) )
@ -72,12 +74,12 @@ def test_combat_encounter_initial_state(attacker, defender):
assert encounter.move_started_at == 0.0 assert encounter.move_started_at == 0.0
def test_attack_transitions_to_telegraph(attacker, defender, punch): def test_attack_transitions_to_pending(attacker, defender, punch):
"""Test attacking transitions to TELEGRAPH state.""" """Test attacking transitions to PENDING state."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
assert encounter.state == CombatState.TELEGRAPH assert encounter.state == CombatState.PENDING
assert encounter.current_move is punch assert encounter.current_move is punch
assert encounter.move_started_at > 0.0 assert encounter.move_started_at > 0.0
@ -92,12 +94,13 @@ def test_attack_applies_stamina_cost(attacker, defender, punch):
def test_defend_records_pending_defense(attacker, defender, punch, dodge): def test_defend_records_pending_defense(attacker, defender, punch, dodge):
"""Test defend records the defense move.""" """Test defend records the defense move and activates it."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
encounter.defend(dodge) encounter.defend(dodge)
assert encounter.pending_defense is dodge assert encounter.pending_defense is dodge
assert encounter.defense_activated_at is not None
def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge): def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
@ -111,29 +114,12 @@ def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
assert defender.stamina == initial_stamina assert defender.stamina == initial_stamina
def test_tick_telegraph_to_window(attacker, defender, punch): def test_tick_pending_to_resolve(attacker, defender, punch):
"""Test tick advances from TELEGRAPH to WINDOW after brief delay.""" """Test tick advances from PENDING to RESOLVE after hit time."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
# Wait for telegraph phase (300ms) # Wait for hit_time_ms (800ms)
time.sleep(0.31)
now = time.monotonic()
encounter.tick(now)
assert encounter.state == CombatState.WINDOW
def test_tick_window_to_resolve(attacker, defender, punch):
"""Test tick advances from WINDOW to RESOLVE after timing window."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
# Skip to WINDOW state
time.sleep(0.31)
encounter.tick(time.monotonic())
# Wait for timing window to expire (800ms)
time.sleep(0.85) time.sleep(0.85)
now = time.monotonic() now = time.monotonic()
encounter.tick(now) encounter.tick(now)
@ -212,16 +198,11 @@ def test_full_state_machine_cycle(attacker, defender, punch):
"""Test complete state machine cycle from IDLE to IDLE.""" """Test complete state machine cycle from IDLE to IDLE."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
# IDLE → TELEGRAPH # IDLE → PENDING
encounter.attack(punch) encounter.attack(punch)
assert encounter.state == CombatState.TELEGRAPH assert encounter.state == CombatState.PENDING
# TELEGRAPH → WINDOW # PENDING → RESOLVE (after hit_time_ms)
time.sleep(0.31)
encounter.tick(time.monotonic())
assert encounter.state == CombatState.WINDOW
# WINDOW → RESOLVE
time.sleep(0.85) time.sleep(0.85)
encounter.tick(time.monotonic()) encounter.tick(time.monotonic())
assert encounter.state == CombatState.RESOLVE assert encounter.state == CombatState.RESOLVE
@ -234,8 +215,7 @@ def test_full_state_machine_cycle(attacker, defender, punch):
def test_combat_state_enum(): def test_combat_state_enum():
"""Test CombatState enum values.""" """Test CombatState enum values."""
assert CombatState.IDLE.value == "idle" assert CombatState.IDLE.value == "idle"
assert CombatState.TELEGRAPH.value == "telegraph" assert CombatState.PENDING.value == "pending"
assert CombatState.WINDOW.value == "window"
assert CombatState.RESOLVE.value == "resolve" assert CombatState.RESOLVE.value == "resolve"
@ -324,41 +304,22 @@ def test_resolve_counter_template_indicates_counter(attacker, defender, punch, d
# --- Attack switching (feint) tests --- # --- Attack switching (feint) tests ---
def test_switch_attack_during_telegraph(attacker, defender, punch, sweep): def test_switch_attack_during_pending(attacker, defender, punch, sweep):
"""Test attack during TELEGRAPH replaces move and keeps timer.""" """Test attack during PENDING replaces move and restarts timer."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
original_start = encounter.move_started_at original_start = encounter.move_started_at
assert encounter.state == CombatState.TELEGRAPH assert encounter.state == CombatState.PENDING
# Switch to sweep during telegraph # Switch to sweep during pending
time.sleep(0.1) # Small delay to ensure timer would differ
encounter.attack(sweep) encounter.attack(sweep)
assert encounter.current_move is sweep assert encounter.current_move is sweep
assert encounter.state == CombatState.TELEGRAPH assert encounter.state == CombatState.PENDING
# Timer should NOT restart # Timer should restart on switch
assert encounter.move_started_at == original_start assert encounter.move_started_at > original_start
def test_switch_attack_during_window(attacker, defender, punch, sweep):
"""Test attack during WINDOW replaces move and keeps timer."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
original_start = encounter.move_started_at
# Advance to WINDOW
time.sleep(0.31)
encounter.tick(time.monotonic())
assert encounter.state == CombatState.WINDOW
# Switch to sweep during window
encounter.attack(sweep)
assert encounter.current_move is sweep
assert encounter.state == CombatState.WINDOW
# Timer should NOT restart
assert encounter.move_started_at == original_start
def test_switch_refunds_old_stamina(attacker, defender, punch, sweep): def test_switch_refunds_old_stamina(attacker, defender, punch, sweep):
@ -449,3 +410,244 @@ def test_last_action_at_unchanged_when_attack_is_countered(
encounter.defend(dodge) encounter.defend(dodge)
encounter.resolve() encounter.resolve()
assert encounter.last_action_at == 10.0 assert encounter.last_action_at == 10.0
# --- Defense active/recovery window tests ---
def test_defense_expired_before_resolve(attacker, defender):
"""Defense activates, active_ms passes, attack resolves.
Defense should NOT counter (hit lands).
"""
# Create attack with 800ms hit time
quick_attack = CombatMove(
name="quick punch",
move_type="attack",
stamina_cost=5.0,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["quick dodge"],
)
# Create defense with short active window (200ms)
quick_dodge = CombatMove(
name="quick dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(quick_attack)
encounter.defend(quick_dodge)
# Wait for defense to expire (200ms) but before attack lands (800ms)
time.sleep(0.31)
encounter.tick(time.monotonic())
# Now resolve — defense should be expired
initial_pl = defender.pl
result = encounter.resolve()
# Defense expired, so attack should hit for full damage
expected_damage = attacker.pl * quick_attack.damage_pct * 1.5
assert defender.pl == initial_pl - expected_damage
assert result.damage == expected_damage
assert result.countered is False
def test_defense_active_during_resolve(attacker, defender):
"""Defense activates within active_ms of resolve — defense SHOULD counter."""
# Create attack with 800ms hit time
quick_attack = CombatMove(
name="quick punch",
move_type="attack",
stamina_cost=5.0,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["quick dodge"],
)
# Create defense with long active window (1000ms)
quick_dodge = CombatMove(
name="quick dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=1000,
recovery_ms=300,
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(quick_attack)
encounter.defend(quick_dodge)
# Immediately resolve — defense is still active
initial_pl = defender.pl
result = encounter.resolve()
# Defense is active, should counter successfully
assert defender.pl == initial_pl
assert result.damage == 0.0
assert result.countered is True
def test_defense_queuing_during_recovery(attacker, defender):
"""defend() while in recovery should set queued_defense, not pending_defense."""
quick_dodge = CombatMove(
name="quick dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
# First defend — activates immediately
encounter.defend(quick_dodge)
assert encounter.pending_defense is quick_dodge
assert encounter.defense_activated_at is not None
# Wait for defense to expire and enter recovery
time.sleep(0.31)
encounter.tick(time.monotonic())
assert encounter.pending_defense is None
assert encounter.defense_recovery_until is not None
# Try to defend during recovery — should queue
second_dodge = CombatMove(
name="second dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter.defend(second_dodge)
# Should be queued, not pending
assert encounter.pending_defense is None
assert encounter.queued_defense is second_dodge
def test_queued_defense_activates_after_recovery(attacker, defender):
"""After recovery_ms passes, tick() should activate the queued defense."""
quick_dodge = CombatMove(
name="quick dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
# First defend
encounter.defend(quick_dodge)
# Wait for defense to expire
time.sleep(0.31)
encounter.tick(time.monotonic())
# Queue second defense during recovery
second_dodge = CombatMove(
name="second dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter.defend(second_dodge)
assert encounter.queued_defense is second_dodge
assert encounter.pending_defense is None
# Wait for recovery to finish
time.sleep(0.31)
encounter.tick(time.monotonic())
# Queued defense should now be active
assert encounter.pending_defense is second_dodge
assert encounter.queued_defense is None
assert encounter.defense_activated_at is not None
def test_recovery_persists_after_resolve(attacker, defender):
"""resolve() should NOT clear defense_recovery_until.
Recovery carries across attacks.
"""
quick_attack = CombatMove(
name="quick punch",
move_type="attack",
stamina_cost=5.0,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["quick dodge"],
)
quick_dodge = CombatMove(
name="quick dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
# Defend, then wait for defense to expire and enter recovery
encounter.defend(quick_dodge)
time.sleep(0.31)
encounter.tick(time.monotonic())
recovery_until = encounter.defense_recovery_until
assert recovery_until is not None
# Attack and resolve during recovery period
encounter.attack(quick_attack)
encounter.resolve()
# Recovery should persist after resolve
assert encounter.defense_recovery_until == recovery_until
def test_attack_switch_restarts_timer(attacker, defender):
"""attack(), then attack() again with different move.
move_started_at should reset.
"""
first_attack = CombatMove(
name="first punch",
move_type="attack",
stamina_cost=5.0,
hit_time_ms=800,
damage_pct=0.15,
countered_by=[],
)
second_attack = CombatMove(
name="second punch",
move_type="attack",
stamina_cost=5.0,
hit_time_ms=800,
damage_pct=0.15,
countered_by=[],
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(first_attack)
first_start = encounter.move_started_at
# Small delay to ensure time difference
time.sleep(0.1)
# Switch to second attack
encounter.attack(second_attack)
second_start = encounter.move_started_at
# Timer should have restarted
assert second_start > first_start
assert encounter.current_move is second_attack

View file

@ -49,7 +49,7 @@ def punch():
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
timing_window_ms=800, hit_time_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -107,7 +107,7 @@ async def test_process_combat_advances_encounters(attacker, defender, punch):
time.sleep(0.31) time.sleep(0.31)
await process_combat() await process_combat()
assert encounter.state == CombatState.WINDOW assert encounter.state == CombatState.PENDING
@pytest.mark.asyncio @pytest.mark.asyncio
@ -127,8 +127,8 @@ async def test_process_combat_handles_multiple_encounters(punch):
time.sleep(0.31) time.sleep(0.31)
await process_combat() await process_combat()
assert enc1.state == CombatState.WINDOW assert enc1.state == CombatState.PENDING
assert enc2.state == CombatState.WINDOW assert enc2.state == CombatState.PENDING
@pytest.mark.asyncio @pytest.mark.asyncio
@ -141,7 +141,7 @@ async def test_process_combat_auto_resolves_expired_windows(attacker, defender,
# Skip past telegraph and window # Skip past telegraph and window
time.sleep(0.31) # Telegraph time.sleep(0.31) # Telegraph
await process_combat() await process_combat()
assert encounter.state == CombatState.WINDOW assert encounter.state == CombatState.PENDING
time.sleep(0.85) # Window time.sleep(0.85) # Window
await process_combat() await process_combat()

View file

@ -13,7 +13,7 @@ def test_combat_move_dataclass():
aliases=["pr"], aliases=["pr"],
stamina_cost=5.0, stamina_cost=5.0,
telegraph="{attacker} winds up a right hook!", telegraph="{attacker} winds up a right hook!",
timing_window_ms=800, hit_time_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left", "parry high"], countered_by=["dodge left", "parry high"],
command="punch", command="punch",
@ -24,7 +24,7 @@ def test_combat_move_dataclass():
assert move.aliases == ["pr"] assert move.aliases == ["pr"]
assert move.stamina_cost == 5.0 assert move.stamina_cost == 5.0
assert move.telegraph == "{attacker} winds up a right hook!" assert move.telegraph == "{attacker} winds up a right hook!"
assert move.timing_window_ms == 800 assert move.hit_time_ms == 800
assert move.damage_pct == 0.15 assert move.damage_pct == 0.15
assert move.countered_by == ["dodge left", "parry high"] assert move.countered_by == ["dodge left", "parry high"]
assert move.handler is None assert move.handler is None
@ -38,14 +38,14 @@ def test_combat_move_minimal():
name="test move", name="test move",
move_type="attack", move_type="attack",
stamina_cost=10.0, stamina_cost=10.0,
timing_window_ms=500, hit_time_ms=500,
) )
assert move.name == "test move" assert move.name == "test move"
assert move.move_type == "attack" assert move.move_type == "attack"
assert move.aliases == [] assert move.aliases == []
assert move.stamina_cost == 10.0 assert move.stamina_cost == 10.0
assert move.telegraph == "" assert move.telegraph == ""
assert move.timing_window_ms == 500 assert move.hit_time_ms == 500
assert move.damage_pct == 0.0 assert move.damage_pct == 0.0
assert move.countered_by == [] assert move.countered_by == []
assert move.command == "" assert move.command == ""
@ -60,7 +60,7 @@ aliases = ["rh"]
move_type = "attack" move_type = "attack"
stamina_cost = 8.0 stamina_cost = 8.0
telegraph = "{attacker} spins into a roundhouse kick!" telegraph = "{attacker} spins into a roundhouse kick!"
timing_window_ms = 600 hit_time_ms = 600
damage_pct = 0.25 damage_pct = 0.25
countered_by = ["duck", "parry high", "parry low"] countered_by = ["duck", "parry high", "parry low"]
""" """
@ -83,7 +83,7 @@ def test_load_variant_move_from_toml(tmp_path):
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 800 hit_time_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.left] [variants.left]
@ -114,7 +114,7 @@ countered_by = ["dodge left", "parry high"]
assert left.countered_by == ["dodge right", "parry high"] assert left.countered_by == ["dodge right", "parry high"]
# Inherited from parent # Inherited from parent
assert left.stamina_cost == 5.0 assert left.stamina_cost == 5.0
assert left.timing_window_ms == 800 assert left.hit_time_ms == 800
assert left.damage_pct == 0.15 assert left.damage_pct == 0.15
right = by_name["punch right"] right = by_name["punch right"]
@ -130,13 +130,13 @@ def test_variant_inherits_shared_properties(tmp_path):
name = "kick" name = "kick"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 800 hit_time_ms = 800
damage_pct = 0.10 damage_pct = 0.10
[variants.low] [variants.low]
aliases = ["kl"] aliases = ["kl"]
damage_pct = 0.08 damage_pct = 0.08
timing_window_ms = 600 hit_time_ms = 600
[variants.high] [variants.high]
aliases = ["kh"] aliases = ["kh"]
@ -150,12 +150,12 @@ damage_pct = 0.15
low = by_name["kick low"] low = by_name["kick low"]
assert low.damage_pct == 0.08 assert low.damage_pct == 0.08
assert low.timing_window_ms == 600 # overridden assert low.hit_time_ms == 600 # overridden
assert low.stamina_cost == 5.0 # inherited assert low.stamina_cost == 5.0 # inherited
high = by_name["kick high"] high = by_name["kick high"]
assert high.damage_pct == 0.15 assert high.damage_pct == 0.15
assert high.timing_window_ms == 800 # inherited assert high.hit_time_ms == 800 # inherited
assert high.stamina_cost == 5.0 # inherited assert high.stamina_cost == 5.0 # inherited
@ -165,7 +165,8 @@ def test_load_move_with_defaults(tmp_path):
name = "basic move" name = "basic move"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
timing_window_ms = 600 active_ms = 600
recovery_ms = 2700
""" """
toml_file = tmp_path / "basic.toml" toml_file = tmp_path / "basic.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)
@ -185,7 +186,7 @@ def test_load_move_missing_name(tmp_path):
toml_content = """ toml_content = """
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 800 hit_time_ms = 800
""" """
toml_file = tmp_path / "bad.toml" toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)
@ -213,7 +214,7 @@ def test_load_move_missing_stamina_cost(tmp_path):
toml_content = """ toml_content = """
name = "test" name = "test"
move_type = "attack" move_type = "attack"
timing_window_ms = 800 hit_time_ms = 800
""" """
toml_file = tmp_path / "bad.toml" toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)
@ -222,8 +223,8 @@ timing_window_ms = 800
load_move(toml_file) load_move(toml_file)
def test_load_move_missing_timing_window(tmp_path): def test_load_move_missing_hit_time(tmp_path):
"""Test loading move without timing_window_ms raises error.""" """Test loading move without hit_time_ms defaults to 0."""
toml_content = """ toml_content = """
name = "test" name = "test"
move_type = "attack" move_type = "attack"
@ -232,8 +233,9 @@ stamina_cost = 5.0
toml_file = tmp_path / "bad.toml" toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)
with pytest.raises(ValueError, match="missing required field.*timing_window_ms"): moves = load_move(toml_file)
load_move(toml_file) assert len(moves) == 1
assert moves[0].hit_time_ms == 0
def test_load_moves_from_directory(tmp_path): def test_load_moves_from_directory(tmp_path):
@ -245,7 +247,7 @@ def test_load_moves_from_directory(tmp_path):
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 800 hit_time_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.right] [variants.right]
@ -262,7 +264,8 @@ countered_by = ["dodge left"]
name = "duck" name = "duck"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
timing_window_ms = 500 active_ms = 500
recovery_ms = 2700
""" """
) )
@ -303,7 +306,7 @@ name = "move one"
aliases = ["m"] aliases = ["m"]
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 800 hit_time_ms = 800
""" """
) )
@ -314,7 +317,8 @@ name = "move two"
aliases = ["m"] aliases = ["m"]
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
timing_window_ms = 500 active_ms = 500
recovery_ms = 2700
""" """
) )
@ -330,7 +334,7 @@ def test_load_moves_name_collision(tmp_path):
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 800 hit_time_ms = 800
""" """
) )
@ -340,7 +344,7 @@ timing_window_ms = 800
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 800 hit_time_ms = 800
""" """
) )
@ -358,7 +362,7 @@ def test_load_moves_validates_countered_by_refs(tmp_path, caplog):
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 800 hit_time_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.right] [variants.right]
@ -372,7 +376,8 @@ countered_by = ["dodge left", "nonexistent move"]
name = "dodge" name = "dodge"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
timing_window_ms = 500 active_ms = 500
recovery_ms = 2700
[variants.left] [variants.left]
aliases = ["dl"] aliases = ["dl"]
@ -401,7 +406,7 @@ def test_load_moves_valid_countered_by_refs_no_warning(tmp_path, caplog):
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 800 hit_time_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.right] [variants.right]
@ -415,7 +420,8 @@ countered_by = ["dodge left", "parry high"]
name = "dodge" name = "dodge"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
timing_window_ms = 500 active_ms = 500
recovery_ms = 2700
[variants.left] [variants.left]
aliases = ["dl"] aliases = ["dl"]
@ -428,7 +434,8 @@ aliases = ["dl"]
name = "parry" name = "parry"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
timing_window_ms = 500 active_ms = 500
recovery_ms = 2700
[variants.high] [variants.high]
aliases = ["f"] aliases = ["f"]

View file

@ -17,7 +17,7 @@ def attack_move():
variant="left", variant="left",
move_type="attack", move_type="attack",
stamina_cost=5, stamina_cost=5,
timing_window_ms=850, hit_time_ms=850,
telegraph="telegraphs a left punch at {defender}", telegraph="telegraphs a left punch at {defender}",
telegraph_color="yellow", telegraph_color="yellow",
aliases=[], aliases=[],

View file

@ -136,7 +136,7 @@ async def test_flying_during_window_causes_miss(player, target, punch_right):
encounter.attack(punch_right) encounter.attack(punch_right)
# Advance to WINDOW phase # Advance to WINDOW phase
encounter.state = CombatState.WINDOW encounter.state = CombatState.PENDING
# Defender flies during window # Defender flies during window
target.flying = True target.flying = True
@ -159,7 +159,7 @@ async def test_both_flying_at_resolve_attack_lands(player, target, punch_right):
encounter.attack(punch_right) encounter.attack(punch_right)
# Advance to WINDOW phase (no altitude change) # Advance to WINDOW phase (no altitude change)
encounter.state = CombatState.WINDOW encounter.state = CombatState.PENDING
# Resolve # Resolve
result = encounter.resolve() result = encounter.resolve()
@ -180,7 +180,7 @@ async def test_attacker_flies_during_window_causes_miss(player, target, punch_ri
encounter.attack(punch_right) encounter.attack(punch_right)
# Advance to WINDOW phase # Advance to WINDOW phase
encounter.state = CombatState.WINDOW encounter.state = CombatState.PENDING
# Attacker flies during window # Attacker flies during window
player.flying = True player.flying = True
@ -205,7 +205,7 @@ async def test_flying_dodge_messages_correct_grammar(player, target, punch_right
encounter.attack(punch_right) encounter.attack(punch_right)
# Advance to WINDOW phase # Advance to WINDOW phase
encounter.state = CombatState.WINDOW encounter.state = CombatState.PENDING
# Defender flies during window # Defender flies during window
target.flying = True target.flying = True

View file

@ -175,7 +175,7 @@ async def test_commands_detail_simple_combat_move(player, combat_moves):
assert "roundhouse" in output assert "roundhouse" in output
assert "type: attack" in output assert "type: attack" in output
assert "stamina: 8.0" in output assert "stamina: 8.0" in output
assert "timing window: 2000ms" in output assert "hit time: 3000ms" in output
assert "damage: 25%" in output assert "damage: 25%" in output
assert "{attacker} shifts {his} weight back..." in output assert "{attacker} shifts {his} weight back..." in output
assert "countered by: duck, parry high, parry low" in output assert "countered by: duck, parry high, parry low" in output
@ -201,7 +201,7 @@ async def test_commands_detail_variant_base(player, combat_moves):
# Should show shared properties in each variant # Should show shared properties in each variant
assert "stamina: 5.0" in output assert "stamina: 5.0" in output
assert "timing window: 1800ms" in output assert "hit time: 3000ms" in output
assert "damage: 15%" in output assert "damage: 15%" in output
@ -214,7 +214,7 @@ async def test_commands_detail_specific_variant(player, combat_moves):
assert "punch left" in output assert "punch left" in output
assert "type: attack" in output assert "type: attack" in output
assert "stamina: 5.0" in output assert "stamina: 5.0" in output
assert "timing window: 1800ms" in output assert "hit time: 3000ms" in output
assert "damage: 15%" in output assert "damage: 15%" in output
assert "{attacker} retracts {his} left arm..." in output assert "{attacker} retracts {his} left arm..." in output
assert "countered by: dodge right, parry high" in output assert "countered by: dodge right, parry high" in output

View file

@ -273,7 +273,7 @@ class TestCombatDeathCorpse:
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
timing_window_ms=800, hit_time_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -334,7 +334,7 @@ class TestCombatDeathCorpse:
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
timing_window_ms=800, hit_time_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -394,7 +394,7 @@ class TestCombatDeathCorpse:
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
timing_window_ms=800, hit_time_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )

View file

@ -187,7 +187,7 @@ async def test_edit_combat_move_opens_toml(player, tmp_path):
aliases = ["rh"] aliases = ["rh"]
move_type = "attack" move_type = "attack"
stamina_cost = 8.0 stamina_cost = 8.0
timing_window_ms = 2000 hit_time_ms = 2000
""" """
toml_file = tmp_path / "roundhouse.toml" toml_file = tmp_path / "roundhouse.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)

View file

@ -370,7 +370,7 @@ async def test_char_vitals_sent_on_combat_resolve():
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
timing_window_ms=800, hit_time_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )

View file

@ -37,7 +37,7 @@ def mock_move_kill_count():
name="roundhouse", name="roundhouse",
move_type="attack", move_type="attack",
stamina_cost=30.0, stamina_cost=30.0,
timing_window_ms=850, hit_time_ms=850,
aliases=["rh"], aliases=["rh"],
description="A powerful spinning kick", description="A powerful spinning kick",
damage_pct=0.35, damage_pct=0.35,
@ -52,7 +52,7 @@ def mock_move_mob_kills():
name="goblin slayer", name="goblin slayer",
move_type="attack", move_type="attack",
stamina_cost=25.0, stamina_cost=25.0,
timing_window_ms=800, hit_time_ms=800,
description="Specialized technique against goblins", description="Specialized technique against goblins",
damage_pct=0.40, damage_pct=0.40,
unlock_condition=UnlockCondition( unlock_condition=UnlockCondition(
@ -68,7 +68,7 @@ def mock_move_no_unlock():
name="jab", name="jab",
move_type="attack", move_type="attack",
stamina_cost=10.0, stamina_cost=10.0,
timing_window_ms=600, hit_time_ms=600,
description="A quick straight punch", description="A quick straight punch",
damage_pct=0.15, damage_pct=0.15,
) )

View file

@ -132,7 +132,7 @@ class TestMobAttackAI:
await process_mobs(moves) await process_mobs(moves)
# Mob should have attacked — encounter state should be TELEGRAPH # Mob should have attacked — encounter state should be TELEGRAPH
assert encounter.state == CombatState.TELEGRAPH assert encounter.state == CombatState.PENDING
assert encounter.current_move is not None assert encounter.current_move is not None
@pytest.mark.asyncio @pytest.mark.asyncio
@ -286,7 +286,7 @@ class TestMobDefenseAI:
# Player attacks, putting encounter in TELEGRAPH # Player attacks, putting encounter in TELEGRAPH
encounter.attack(punch_right) encounter.attack(punch_right)
assert encounter.state == CombatState.TELEGRAPH assert encounter.state == CombatState.PENDING
await process_mobs(moves) await process_mobs(moves)

View file

@ -399,7 +399,7 @@ def test_move_shows_name_when_in_combat_with_active_move():
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
timing_window_ms=800, hit_time_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -463,7 +463,7 @@ def test_combat_state_shows_state_when_in_combat():
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
timing_window_ms=800, hit_time_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -473,7 +473,7 @@ def test_combat_state_shows_state_when_in_combat():
active_encounters.append(encounter) active_encounters.append(encounter)
result = render_prompt(player) result = render_prompt(player)
assert result == "[telegraph] > " assert result == "[pending] > "
def test_terrain_variable_grass(): def test_terrain_variable_grass():

View file

@ -178,7 +178,7 @@ async def test_attack_blocked_in_safe_zone(safe_zone, mock_writer, mock_reader):
variant="left", variant="left",
move_type="attack", move_type="attack",
stamina_cost=5, stamina_cost=5,
timing_window_ms=850, hit_time_ms=850,
damage_pct=0.15, damage_pct=0.15,
) )
@ -227,7 +227,7 @@ async def test_attack_allowed_in_unsafe_zone(unsafe_zone, mock_writer, mock_read
variant="left", variant="left",
move_type="attack", move_type="attack",
stamina_cost=5, stamina_cost=5,
timing_window_ms=850, hit_time_ms=850,
damage_pct=0.15, damage_pct=0.15,
) )

View file

@ -70,7 +70,7 @@ async def test_stamina_cue_after_combat_damage(player, defender):
move_type="attack", move_type="attack",
damage_pct=0.5, damage_pct=0.5,
stamina_cost=5.0, stamina_cost=5.0,
timing_window_ms=100, hit_time_ms=100,
) )
# Start encounter # Start encounter
@ -141,7 +141,7 @@ async def test_stamina_cue_after_attack_cost(player, defender):
move_type="attack", move_type="attack",
damage_pct=0.3, damage_pct=0.3,
stamina_cost=60.0, stamina_cost=60.0,
timing_window_ms=1000, hit_time_ms=1000,
) )
player.stamina = 100.0 player.stamina = 100.0
@ -171,15 +171,17 @@ async def test_stamina_cue_after_defense_cost(player):
command="duck", command="duck",
move_type="defense", move_type="defense",
stamina_cost=30.0, stamina_cost=30.0,
timing_window_ms=100, active_ms=100,
recovery_ms=2700,
) )
player.stamina = 100.0 player.stamina = 100.0
player.max_stamina = 100.0 player.max_stamina = 100.0
with patch("mudlib.combat.commands.check_stamina_cues") as mock_check: with patch("mudlib.combat.commands.check_stamina_cues") as mock_check:
# Use a short timing window to avoid blocking too long # Use a short active window to avoid blocking too long
move.timing_window_ms = 10 move.active_ms = 10
move.recovery_ms = 10
await do_defend(player, "", move) await do_defend(player, "", move)
# check_stamina_cues should have been called # check_stamina_cues should have been called

View file

@ -37,7 +37,7 @@ def punch():
name="punch left", name="punch left",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
timing_window_ms=800, hit_time_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge right"], countered_by=["dodge right"],
telegraph="{attacker} retracts {his} left arm...", telegraph="{attacker} retracts {his} left arm...",
@ -51,8 +51,8 @@ def punch():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_announce_sent_on_telegraph_to_window_transition(punch): async def test_announce_sent_on_pending_to_resolve_transition(punch):
"""Test announce message sent when TELEGRAPH→WINDOW transition occurs.""" """Test announce message sent when PENDING→RESOLVE transition occurs."""
atk_writer = _mock_writer() atk_writer = _mock_writer()
def_writer = _mock_writer() def_writer = _mock_writer()
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer) attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer)
@ -63,19 +63,19 @@ async def test_announce_sent_on_telegraph_to_window_transition(punch):
encounter = start_encounter(attacker, defender) encounter = start_encounter(attacker, defender)
encounter.attack(punch) encounter.attack(punch)
# Should be in TELEGRAPH state # Should be in PENDING state
assert encounter.state == CombatState.TELEGRAPH assert encounter.state == CombatState.PENDING
# Reset mocks to ignore previous messages # Reset mocks to ignore previous messages
atk_writer.write.reset_mock() atk_writer.write.reset_mock()
def_writer.write.reset_mock() def_writer.write.reset_mock()
# Wait for telegraph phase and process # Wait for hit time (800ms) and process
time.sleep(0.31) time.sleep(0.85)
await process_combat() await process_combat()
# Should have transitioned to WINDOW # Should have auto-resolved and returned to IDLE
assert encounter.state == CombatState.WINDOW assert encounter.state == CombatState.IDLE
# Check announce message was sent # Check announce message was sent
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
@ -103,7 +103,8 @@ async def test_announce_uses_pov_templates(punch):
atk_writer.write.reset_mock() atk_writer.write.reset_mock()
def_writer.write.reset_mock() def_writer.write.reset_mock()
time.sleep(0.31) # Wait for hit time (800ms) to trigger announce
time.sleep(0.85)
await process_combat() await process_combat()
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
@ -128,14 +129,7 @@ async def test_resolve_uses_pov_templates(punch):
encounter = start_encounter(attacker, defender) encounter = start_encounter(attacker, defender)
encounter.attack(punch) encounter.attack(punch)
# Advance to window # Advance to resolve (announce and resolve happen on same tick)
time.sleep(0.31)
await process_combat()
atk_writer.write.reset_mock()
def_writer.write.reset_mock()
# Advance to resolve
time.sleep(0.85) time.sleep(0.85)
await process_combat() await process_combat()
@ -162,20 +156,18 @@ async def test_resolve_uses_resolve_miss_on_counter(punch):
name="dodge right", name="dodge right",
move_type="defense", move_type="defense",
stamina_cost=3.0, stamina_cost=3.0,
timing_window_ms=800, active_ms=1000, # Longer than hit_time to still be active at resolve
recovery_ms=2700,
) )
encounter = start_encounter(attacker, defender) encounter = start_encounter(attacker, defender)
encounter.attack(punch) encounter.attack(punch)
encounter.defend(dodge) encounter.defend(dodge)
# Advance to resolve
time.sleep(0.31)
await process_combat()
atk_writer.write.reset_mock() atk_writer.write.reset_mock()
def_writer.write.reset_mock() def_writer.write.reset_mock()
# Advance to resolve (defense is already active)
time.sleep(0.85) time.sleep(0.85)
await process_combat() await process_combat()
@ -220,7 +212,8 @@ async def test_announce_color_applied(punch):
atk_writer.write.reset_mock() atk_writer.write.reset_mock()
def_writer.write.reset_mock() def_writer.write.reset_mock()
time.sleep(0.31) # Wait for hit time to trigger announce
time.sleep(0.85)
await process_combat() await process_combat()
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
@ -260,13 +253,10 @@ async def test_resolve_bold_color_applied(punch):
encounter = start_encounter(attacker, defender) encounter = start_encounter(attacker, defender)
encounter.attack(punch) encounter.attack(punch)
# Advance to resolve
time.sleep(0.31)
await process_combat()
atk_writer.write.reset_mock() atk_writer.write.reset_mock()
def_writer.write.reset_mock() def_writer.write.reset_mock()
# Advance to resolve
time.sleep(0.85) time.sleep(0.85)
await process_combat() await process_combat()
@ -280,8 +270,8 @@ async def test_resolve_bold_color_applied(punch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_announce_on_idle_to_telegraph(): async def test_no_announce_on_idle_to_pending():
"""Test no announce message sent on IDLE→TELEGRAPH transition.""" """Test no announce message sent on IDLE→PENDING transition."""
atk_writer = _mock_writer() atk_writer = _mock_writer()
def_writer = _mock_writer() def_writer = _mock_writer()
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer) attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer)
@ -293,7 +283,7 @@ async def test_no_announce_on_idle_to_telegraph():
name="punch left", name="punch left",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
timing_window_ms=800, hit_time_ms=800,
damage_pct=0.15, damage_pct=0.15,
announce="{attacker} throw{s} a left hook at {defender}!", announce="{attacker} throw{s} a left hook at {defender}!",
) )
@ -305,7 +295,7 @@ async def test_no_announce_on_idle_to_telegraph():
encounter.attack(punch) encounter.attack(punch)
# Process combat immediately (still in TELEGRAPH) # Process combat immediately (still in PENDING)
await process_combat() await process_combat()
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]

View file

@ -237,7 +237,7 @@ async def test_knockout_ends_combat(clear_state, mock_writer):
move_type="attack", move_type="attack",
damage_pct=0.5, damage_pct=0.5,
stamina_cost=10.0, stamina_cost=10.0,
timing_window_ms=850, hit_time_ms=850,
countered_by=[], countered_by=[],
) )
@ -266,7 +266,7 @@ async def test_defender_stamina_ko_ends_combat(clear_state, mock_writer):
move_type="attack", move_type="attack",
damage_pct=0.5, damage_pct=0.5,
stamina_cost=10.0, stamina_cost=10.0,
timing_window_ms=850, hit_time_ms=850,
countered_by=[], countered_by=[],
) )

View file

@ -40,7 +40,7 @@ def test_combat_move_with_unlock_condition():
name="roundhouse", name="roundhouse",
move_type="attack", move_type="attack",
stamina_cost=20.0, stamina_cost=20.0,
timing_window_ms=500, hit_time_ms=500,
unlock_condition=condition, unlock_condition=condition,
) )
assert move.unlock_condition is condition assert move.unlock_condition is condition
@ -53,7 +53,7 @@ def test_combat_move_unlock_condition_defaults_to_none():
name="punch", name="punch",
move_type="attack", move_type="attack",
stamina_cost=10.0, stamina_cost=10.0,
timing_window_ms=300, hit_time_ms=300,
) )
assert move.unlock_condition is None assert move.unlock_condition is None
@ -68,7 +68,7 @@ def test_check_unlocks_grants_move_on_kill_threshold():
name="roundhouse", name="roundhouse",
move_type="attack", move_type="attack",
stamina_cost=20.0, stamina_cost=20.0,
timing_window_ms=500, hit_time_ms=500,
command="roundhouse", command="roundhouse",
unlock_condition=UnlockCondition(type="kill_count", threshold=5), unlock_condition=UnlockCondition(type="kill_count", threshold=5),
) )
@ -90,7 +90,7 @@ def test_check_unlocks_grants_move_on_mob_kills_threshold():
name="goblin_slayer", name="goblin_slayer",
move_type="attack", move_type="attack",
stamina_cost=15.0, stamina_cost=15.0,
timing_window_ms=400, hit_time_ms=400,
command="goblin_slayer", command="goblin_slayer",
unlock_condition=UnlockCondition( unlock_condition=UnlockCondition(
type="mob_kills", mob_name="goblin", threshold=3 type="mob_kills", mob_name="goblin", threshold=3
@ -114,7 +114,7 @@ def test_check_unlocks_does_not_unlock_when_threshold_not_met():
name="roundhouse", name="roundhouse",
move_type="attack", move_type="attack",
stamina_cost=20.0, stamina_cost=20.0,
timing_window_ms=500, hit_time_ms=500,
command="roundhouse", command="roundhouse",
unlock_condition=UnlockCondition(type="kill_count", threshold=5), unlock_condition=UnlockCondition(type="kill_count", threshold=5),
) )
@ -136,7 +136,7 @@ def test_check_unlocks_returns_empty_list_if_nothing_new():
name="roundhouse", name="roundhouse",
move_type="attack", move_type="attack",
stamina_cost=20.0, stamina_cost=20.0,
timing_window_ms=500, hit_time_ms=500,
command="roundhouse", command="roundhouse",
unlock_condition=UnlockCondition(type="kill_count", threshold=5), unlock_condition=UnlockCondition(type="kill_count", threshold=5),
) )
@ -153,7 +153,7 @@ def test_load_toml_with_unlock_condition():
name = "roundhouse" name = "roundhouse"
move_type = "attack" move_type = "attack"
stamina_cost = 20.0 stamina_cost = 20.0
timing_window_ms = 500 hit_time_ms = 500
[unlock] [unlock]
type = "kill_count" type = "kill_count"
@ -181,7 +181,7 @@ def test_load_toml_without_unlock_section():
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 10.0 stamina_cost = 10.0
timing_window_ms = 300 hit_time_ms = 300
""" """
with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content) f.write(toml_content)
@ -202,7 +202,7 @@ def test_load_toml_with_mob_kills_unlock():
name = "goblin_slayer" name = "goblin_slayer"
move_type = "attack" move_type = "attack"
stamina_cost = 15.0 stamina_cost = 15.0
timing_window_ms = 400 hit_time_ms = 400
[unlock] [unlock]
type = "mob_kills" type = "mob_kills"
@ -238,7 +238,7 @@ async def test_gating_locked_move_rejected_in_do_attack():
name="roundhouse", name="roundhouse",
move_type="attack", move_type="attack",
stamina_cost=20.0, stamina_cost=20.0,
timing_window_ms=500, hit_time_ms=500,
command="roundhouse", command="roundhouse",
unlock_condition=UnlockCondition(type="kill_count", threshold=5), unlock_condition=UnlockCondition(type="kill_count", threshold=5),
) )
@ -265,7 +265,7 @@ async def test_gating_unlocked_move_works_normally():
name="roundhouse", name="roundhouse",
move_type="attack", move_type="attack",
stamina_cost=20.0, stamina_cost=20.0,
timing_window_ms=500, hit_time_ms=500,
command="roundhouse", command="roundhouse",
unlock_condition=UnlockCondition(type="kill_count", threshold=5), unlock_condition=UnlockCondition(type="kill_count", threshold=5),
) )
@ -295,7 +295,7 @@ async def test_gating_move_without_unlock_condition_always_works():
name="punch", name="punch",
move_type="attack", move_type="attack",
stamina_cost=10.0, stamina_cost=10.0,
timing_window_ms=300, hit_time_ms=300,
command="punch", command="punch",
unlock_condition=None, # no unlock needed unlock_condition=None, # no unlock needed
) )
@ -322,7 +322,7 @@ def test_check_unlocks_deduplicates_variants():
name="punch left", name="punch left",
move_type="attack", move_type="attack",
stamina_cost=10.0, stamina_cost=10.0,
timing_window_ms=300, hit_time_ms=300,
command="punch", command="punch",
variant="left", variant="left",
unlock_condition=UnlockCondition(type="kill_count", threshold=5), unlock_condition=UnlockCondition(type="kill_count", threshold=5),
@ -331,7 +331,7 @@ def test_check_unlocks_deduplicates_variants():
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=10.0, stamina_cost=10.0,
timing_window_ms=300, hit_time_ms=300,
command="punch", command="punch",
variant="right", variant="right",
unlock_condition=UnlockCondition(type="kill_count", threshold=5), unlock_condition=UnlockCondition(type="kill_count", threshold=5),