From edbad4666f90fc9444e11604ef3235c90f47b5e9 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sun, 15 Feb 2026 16:24:26 -0500 Subject: [PATCH] Rework combat state machine PENDING phase, defense active/recovery windows --- src/mudlib/combat/commands.py | 15 +- src/mudlib/combat/encounter.py | 109 +++++++--- src/mudlib/combat/engine.py | 6 +- src/mudlib/combat/moves.py | 18 +- src/mudlib/commands/help.py | 12 +- src/mudlib/mob_ai.py | 7 +- tests/test_combat_commands.py | 8 +- tests/test_combat_encounter.py | 332 +++++++++++++++++++++++++------ tests/test_combat_engine.py | 10 +- tests/test_combat_moves.py | 65 +++--- tests/test_combat_targeting.py | 2 +- tests/test_combat_zaxis.py | 8 +- tests/test_commands_list.py | 6 +- tests/test_corpse.py | 6 +- tests/test_editor_integration.py | 2 +- tests/test_gmcp.py | 2 +- tests/test_help_unlock.py | 6 +- tests/test_mob_ai.py | 4 +- tests/test_prompt.py | 6 +- tests/test_safe_zones.py | 4 +- tests/test_stamina_cue_wiring.py | 12 +- tests/test_three_beat.py | 54 ++--- tests/test_unconscious.py | 4 +- tests/test_unlock_system.py | 28 +-- 24 files changed, 488 insertions(+), 238 deletions(-) diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index 7c9e061..dc6e3e6 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -1,6 +1,5 @@ """Combat command handlers.""" -import asyncio from collections import defaultdict 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") # Detect switch before attack() modifies state - switching = encounter.state in ( - CombatState.TELEGRAPH, - CombatState.WINDOW, - ) + switching = encounter.state == CombatState.PENDING # Execute the attack (deducts stamina) 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: """Core defense logic with a resolved move. - Works both in and outside combat. Applies a recovery lock - (based on timing_window_ms) so defenses have commitment. + Works both in and outside combat. The encounter tracks active/recovery + windows internally. Args: 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 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) if encounter is not None: 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", ) - # Commitment: block for the timing window (inputs queue naturally) - await asyncio.sleep(move.timing_window_ms / 1000.0) - if encounter is not None: await player.send(f"You {move.name}!\r\n") else: diff --git a/src/mudlib/combat/encounter.py b/src/mudlib/combat/encounter.py index bcc052d..75f154b 100644 --- a/src/mudlib/combat/encounter.py +++ b/src/mudlib/combat/encounter.py @@ -12,14 +12,10 @@ class CombatState(Enum): """States of the combat state machine.""" IDLE = "idle" - TELEGRAPH = "telegraph" - WINDOW = "window" + PENDING = "pending" 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 IDLE_TIMEOUT = 30.0 @@ -44,45 +40,60 @@ class CombatEncounter: current_move: CombatMove | None = None move_started_at: float = 0.0 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. 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. + If called during PENDING, switches to the new move and restarts + 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 + if self.state == CombatState.PENDING and self.current_move: + # Switching — refund old cost + self.attacker.stamina = min( + self.attacker.stamina + self.current_move.stamina_cost, + self.attacker.max_stamina, + ) + # Always restart timer + self.move_started_at = now self.current_move = move self.attacker.stamina -= move.stamina_cost if self.state == CombatState.IDLE: - self.state = CombatState.TELEGRAPH + self.state = CombatState.PENDING 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: 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: """Advance the state machine based on current time. @@ -90,23 +101,45 @@ class CombatEncounter: 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 + # Check if queued defense should activate + if ( + self.queued_defense is not None + and self.defense_recovery_until is not None + 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 if timing window has expired + # Check PENDING -> RESOLVE transition + if self.state == CombatState.PENDING: 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 + hit_time_seconds = self.current_move.hit_time_ms / 1000.0 - if elapsed >= total_time: + if elapsed >= hit_time_seconds: 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: """Resolve the combat exchange and return result. @@ -140,8 +173,16 @@ class CombatEncounter: ) # 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 = ( - self.pending_defense + defense_is_active + and self.pending_defense is not None and self.pending_defense.name in self.current_move.countered_by ) if defense_succeeds: @@ -178,10 +219,12 @@ class CombatEncounter: self.last_action_at = time.monotonic() 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.current_move = None self.pending_defense = None + self.defense_activated_at = None return ResolveResult( resolve_template=template, diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py index f462259..fba563c 100644 --- a/src/mudlib/combat/engine.py +++ b/src/mudlib/combat/engine.py @@ -108,10 +108,10 @@ async def process_combat() -> None: # Tick the state machine encounter.tick(now) - # Send announce message on TELEGRAPH → WINDOW transition + # Send announce message on PENDING → RESOLVE transition if ( - previous_state == CombatState.TELEGRAPH - and encounter.state == CombatState.WINDOW + previous_state == CombatState.PENDING + and encounter.state == CombatState.RESOLVE and encounter.current_move and encounter.current_move.announce ): diff --git a/src/mudlib/combat/moves.py b/src/mudlib/combat/moves.py index 2058e39..8c2f518 100644 --- a/src/mudlib/combat/moves.py +++ b/src/mudlib/combat/moves.py @@ -28,7 +28,9 @@ class CombatMove: name: str move_type: str # "attack" or "defense" 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) telegraph: str = "" damage_pct: float = 0.0 @@ -69,7 +71,7 @@ def load_move(path: Path) -> list[CombatMove]: data = tomllib.load(f) # 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: if field_name not in data: msg = f"missing required field: {field_name}" @@ -97,8 +99,12 @@ def load_move(path: Path) -> list[CombatMove]: name=qualified_name, move_type=data["move_type"], stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]), - timing_window_ms=variant_data.get( - "timing_window_ms", data["timing_window_ms"] + hit_time_ms=variant_data.get( + "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", []), telegraph=variant_data.get("telegraph", data.get("telegraph", "")), @@ -139,7 +145,9 @@ def load_move(path: Path) -> list[CombatMove]: name=base_name, move_type=data["move_type"], 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", []), telegraph=data.get("telegraph", ""), damage_pct=data.get("damage_pct", 0.0), diff --git a/src/mudlib/commands/help.py b/src/mudlib/commands/help.py index c80e446..32ec16a 100644 --- a/src/mudlib/commands/help.py +++ b/src/mudlib/commands/help.py @@ -123,7 +123,11 @@ async def _show_single_command( # Combat move specific details if move is not None: 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: damage_pct = int(move.damage_pct * 100) lines.append(f" damage: {damage_pct}%") @@ -188,7 +192,11 @@ async def _show_variant_overview( lines.append(f" aliases: {aliases_str}") 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: damage_pct = int(move.damage_pct * 100) diff --git a/src/mudlib/mob_ai.py b/src/mudlib/mob_ai.py index 5bb5579..5a99066 100644 --- a/src/mudlib/mob_ai.py +++ b/src/mudlib/mob_ai.py @@ -46,11 +46,8 @@ async def process_mobs(combat_moves: dict[str, CombatMove]) -> None: # Determine if mob is attacker or defender in this encounter mob_is_defender = encounter.defender is mob - # Defense AI: react during TELEGRAPH or WINDOW when mob is defender - if mob_is_defender and encounter.state in ( - CombatState.TELEGRAPH, - CombatState.WINDOW, - ): + # Defense AI: react during PENDING when mob is defender + if mob_is_defender and encounter.state == CombatState.PENDING: _try_defend(mob, encounter, combat_moves, now) continue diff --git a/tests/test_combat_commands.py b/tests/test_combat_commands.py index d63641f..01e1670 100644 --- a/tests/test_combat_commands.py +++ b/tests/test_combat_commands.py @@ -345,14 +345,14 @@ async def test_switch_attack_sends_new_telegraph( @pytest.mark.asyncio -async def test_defense_blocks_for_timing_window(player, dodge_left): - """Test defense sleeps for timing_window_ms (commitment via blocking).""" +async def test_defense_does_not_block(player, dodge_left): + """Test defense no longer blocks (encounter tracks active/recovery internally).""" before = time.monotonic() await combat_commands.do_defend(player, "", dodge_left) elapsed = time.monotonic() - before - expected = dodge_left.timing_window_ms / 1000.0 - assert elapsed >= expected - 0.05 + # Should return immediately, not block for active_ms + assert elapsed < 0.1 # Allow for some overhead @pytest.mark.asyncio diff --git a/tests/test_combat_encounter.py b/tests/test_combat_encounter.py index 698d04b..5a78d20 100644 --- a/tests/test_combat_encounter.py +++ b/tests/test_combat_encounter.py @@ -25,7 +25,7 @@ def punch(): name="punch right", move_type="attack", stamina_cost=5.0, - timing_window_ms=800, + hit_time_ms=800, damage_pct=0.15, countered_by=["dodge left", "parry high"], ) @@ -37,7 +37,8 @@ def dodge(): name="dodge left", move_type="defense", stamina_cost=3.0, - timing_window_ms=800, + active_ms=800, + recovery_ms=2700, ) @@ -47,7 +48,8 @@ def wrong_dodge(): name="dodge right", move_type="defense", stamina_cost=3.0, - timing_window_ms=800, + active_ms=800, + recovery_ms=2700, ) @@ -57,7 +59,7 @@ def sweep(): name="sweep", move_type="attack", stamina_cost=8.0, - timing_window_ms=600, + hit_time_ms=600, damage_pct=0.20, countered_by=["jump"], ) @@ -72,12 +74,12 @@ def test_combat_encounter_initial_state(attacker, defender): assert encounter.move_started_at == 0.0 -def test_attack_transitions_to_telegraph(attacker, defender, punch): - """Test attacking transitions to TELEGRAPH state.""" +def test_attack_transitions_to_pending(attacker, defender, punch): + """Test attacking transitions to PENDING state.""" encounter = CombatEncounter(attacker=attacker, defender=defender) encounter.attack(punch) - assert encounter.state == CombatState.TELEGRAPH + assert encounter.state == CombatState.PENDING assert encounter.current_move is punch 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): - """Test defend records the defense move.""" + """Test defend records the defense move and activates it.""" encounter = CombatEncounter(attacker=attacker, defender=defender) encounter.attack(punch) encounter.defend(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): @@ -111,29 +114,12 @@ def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge): assert defender.stamina == initial_stamina -def test_tick_telegraph_to_window(attacker, defender, punch): - """Test tick advances from TELEGRAPH to WINDOW after brief delay.""" +def test_tick_pending_to_resolve(attacker, defender, punch): + """Test tick advances from PENDING to RESOLVE after hit time.""" encounter = CombatEncounter(attacker=attacker, defender=defender) encounter.attack(punch) - # Wait for telegraph phase (300ms) - 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) + # Wait for hit_time_ms (800ms) time.sleep(0.85) now = time.monotonic() 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.""" encounter = CombatEncounter(attacker=attacker, defender=defender) - # IDLE → TELEGRAPH + # IDLE → PENDING encounter.attack(punch) - assert encounter.state == CombatState.TELEGRAPH + assert encounter.state == CombatState.PENDING - # TELEGRAPH → WINDOW - time.sleep(0.31) - encounter.tick(time.monotonic()) - assert encounter.state == CombatState.WINDOW - - # WINDOW → RESOLVE + # PENDING → RESOLVE (after hit_time_ms) time.sleep(0.85) encounter.tick(time.monotonic()) assert encounter.state == CombatState.RESOLVE @@ -234,8 +215,7 @@ def test_full_state_machine_cycle(attacker, defender, punch): def test_combat_state_enum(): """Test CombatState enum values.""" assert CombatState.IDLE.value == "idle" - assert CombatState.TELEGRAPH.value == "telegraph" - assert CombatState.WINDOW.value == "window" + assert CombatState.PENDING.value == "pending" assert CombatState.RESOLVE.value == "resolve" @@ -324,41 +304,22 @@ def test_resolve_counter_template_indicates_counter(attacker, defender, punch, d # --- Attack switching (feint) tests --- -def test_switch_attack_during_telegraph(attacker, defender, punch, sweep): - """Test attack during TELEGRAPH replaces move and keeps timer.""" +def test_switch_attack_during_pending(attacker, defender, punch, sweep): + """Test attack during PENDING replaces move and restarts timer.""" encounter = CombatEncounter(attacker=attacker, defender=defender) encounter.attack(punch) 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) assert encounter.current_move is sweep - assert encounter.state == CombatState.TELEGRAPH - # Timer should NOT restart - 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 + assert encounter.state == CombatState.PENDING + # Timer should restart on switch + assert encounter.move_started_at > original_start 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.resolve() 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 diff --git a/tests/test_combat_engine.py b/tests/test_combat_engine.py index a51ac6e..c899980 100644 --- a/tests/test_combat_engine.py +++ b/tests/test_combat_engine.py @@ -49,7 +49,7 @@ def punch(): name="punch right", move_type="attack", stamina_cost=5.0, - timing_window_ms=800, + hit_time_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) @@ -107,7 +107,7 @@ async def test_process_combat_advances_encounters(attacker, defender, punch): time.sleep(0.31) await process_combat() - assert encounter.state == CombatState.WINDOW + assert encounter.state == CombatState.PENDING @pytest.mark.asyncio @@ -127,8 +127,8 @@ async def test_process_combat_handles_multiple_encounters(punch): time.sleep(0.31) await process_combat() - assert enc1.state == CombatState.WINDOW - assert enc2.state == CombatState.WINDOW + assert enc1.state == CombatState.PENDING + assert enc2.state == CombatState.PENDING @pytest.mark.asyncio @@ -141,7 +141,7 @@ async def test_process_combat_auto_resolves_expired_windows(attacker, defender, # Skip past telegraph and window time.sleep(0.31) # Telegraph await process_combat() - assert encounter.state == CombatState.WINDOW + assert encounter.state == CombatState.PENDING time.sleep(0.85) # Window await process_combat() diff --git a/tests/test_combat_moves.py b/tests/test_combat_moves.py index 6ef88a8..5878bc8 100644 --- a/tests/test_combat_moves.py +++ b/tests/test_combat_moves.py @@ -13,7 +13,7 @@ def test_combat_move_dataclass(): aliases=["pr"], stamina_cost=5.0, telegraph="{attacker} winds up a right hook!", - timing_window_ms=800, + hit_time_ms=800, damage_pct=0.15, countered_by=["dodge left", "parry high"], command="punch", @@ -24,7 +24,7 @@ def test_combat_move_dataclass(): assert move.aliases == ["pr"] assert move.stamina_cost == 5.0 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.countered_by == ["dodge left", "parry high"] assert move.handler is None @@ -38,14 +38,14 @@ def test_combat_move_minimal(): name="test move", move_type="attack", stamina_cost=10.0, - timing_window_ms=500, + hit_time_ms=500, ) assert move.name == "test move" assert move.move_type == "attack" assert move.aliases == [] assert move.stamina_cost == 10.0 assert move.telegraph == "" - assert move.timing_window_ms == 500 + assert move.hit_time_ms == 500 assert move.damage_pct == 0.0 assert move.countered_by == [] assert move.command == "" @@ -60,7 +60,7 @@ aliases = ["rh"] move_type = "attack" stamina_cost = 8.0 telegraph = "{attacker} spins into a roundhouse kick!" -timing_window_ms = 600 +hit_time_ms = 600 damage_pct = 0.25 countered_by = ["duck", "parry high", "parry low"] """ @@ -83,7 +83,7 @@ def test_load_variant_move_from_toml(tmp_path): name = "punch" move_type = "attack" stamina_cost = 5.0 -timing_window_ms = 800 +hit_time_ms = 800 damage_pct = 0.15 [variants.left] @@ -114,7 +114,7 @@ countered_by = ["dodge left", "parry high"] assert left.countered_by == ["dodge right", "parry high"] # Inherited from parent assert left.stamina_cost == 5.0 - assert left.timing_window_ms == 800 + assert left.hit_time_ms == 800 assert left.damage_pct == 0.15 right = by_name["punch right"] @@ -130,13 +130,13 @@ def test_variant_inherits_shared_properties(tmp_path): name = "kick" move_type = "attack" stamina_cost = 5.0 -timing_window_ms = 800 +hit_time_ms = 800 damage_pct = 0.10 [variants.low] aliases = ["kl"] damage_pct = 0.08 -timing_window_ms = 600 +hit_time_ms = 600 [variants.high] aliases = ["kh"] @@ -150,12 +150,12 @@ damage_pct = 0.15 low = by_name["kick low"] 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 high = by_name["kick high"] 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 @@ -165,7 +165,8 @@ def test_load_move_with_defaults(tmp_path): name = "basic move" move_type = "defense" stamina_cost = 3.0 -timing_window_ms = 600 +active_ms = 600 +recovery_ms = 2700 """ toml_file = tmp_path / "basic.toml" toml_file.write_text(toml_content) @@ -185,7 +186,7 @@ def test_load_move_missing_name(tmp_path): toml_content = """ move_type = "attack" stamina_cost = 5.0 -timing_window_ms = 800 +hit_time_ms = 800 """ toml_file = tmp_path / "bad.toml" toml_file.write_text(toml_content) @@ -213,7 +214,7 @@ def test_load_move_missing_stamina_cost(tmp_path): toml_content = """ name = "test" move_type = "attack" -timing_window_ms = 800 +hit_time_ms = 800 """ toml_file = tmp_path / "bad.toml" toml_file.write_text(toml_content) @@ -222,8 +223,8 @@ timing_window_ms = 800 load_move(toml_file) -def test_load_move_missing_timing_window(tmp_path): - """Test loading move without timing_window_ms raises error.""" +def test_load_move_missing_hit_time(tmp_path): + """Test loading move without hit_time_ms defaults to 0.""" toml_content = """ name = "test" move_type = "attack" @@ -232,8 +233,9 @@ stamina_cost = 5.0 toml_file = tmp_path / "bad.toml" toml_file.write_text(toml_content) - with pytest.raises(ValueError, match="missing required field.*timing_window_ms"): - load_move(toml_file) + moves = load_move(toml_file) + assert len(moves) == 1 + assert moves[0].hit_time_ms == 0 def test_load_moves_from_directory(tmp_path): @@ -245,7 +247,7 @@ def test_load_moves_from_directory(tmp_path): name = "punch" move_type = "attack" stamina_cost = 5.0 -timing_window_ms = 800 +hit_time_ms = 800 damage_pct = 0.15 [variants.right] @@ -262,7 +264,8 @@ countered_by = ["dodge left"] name = "duck" move_type = "defense" stamina_cost = 3.0 -timing_window_ms = 500 +active_ms = 500 +recovery_ms = 2700 """ ) @@ -303,7 +306,7 @@ name = "move one" aliases = ["m"] move_type = "attack" stamina_cost = 5.0 -timing_window_ms = 800 +hit_time_ms = 800 """ ) @@ -314,7 +317,8 @@ name = "move two" aliases = ["m"] move_type = "defense" 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" move_type = "attack" stamina_cost = 5.0 -timing_window_ms = 800 +hit_time_ms = 800 """ ) @@ -340,7 +344,7 @@ timing_window_ms = 800 name = "punch" move_type = "attack" 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" move_type = "attack" stamina_cost = 5.0 -timing_window_ms = 800 +hit_time_ms = 800 damage_pct = 0.15 [variants.right] @@ -372,7 +376,8 @@ countered_by = ["dodge left", "nonexistent move"] name = "dodge" move_type = "defense" stamina_cost = 3.0 -timing_window_ms = 500 +active_ms = 500 +recovery_ms = 2700 [variants.left] aliases = ["dl"] @@ -401,7 +406,7 @@ def test_load_moves_valid_countered_by_refs_no_warning(tmp_path, caplog): name = "punch" move_type = "attack" stamina_cost = 5.0 -timing_window_ms = 800 +hit_time_ms = 800 damage_pct = 0.15 [variants.right] @@ -415,7 +420,8 @@ countered_by = ["dodge left", "parry high"] name = "dodge" move_type = "defense" stamina_cost = 3.0 -timing_window_ms = 500 +active_ms = 500 +recovery_ms = 2700 [variants.left] aliases = ["dl"] @@ -428,7 +434,8 @@ aliases = ["dl"] name = "parry" move_type = "defense" stamina_cost = 3.0 -timing_window_ms = 500 +active_ms = 500 +recovery_ms = 2700 [variants.high] aliases = ["f"] diff --git a/tests/test_combat_targeting.py b/tests/test_combat_targeting.py index 1ff692a..77a1e61 100644 --- a/tests/test_combat_targeting.py +++ b/tests/test_combat_targeting.py @@ -17,7 +17,7 @@ def attack_move(): variant="left", move_type="attack", stamina_cost=5, - timing_window_ms=850, + hit_time_ms=850, telegraph="telegraphs a left punch at {defender}", telegraph_color="yellow", aliases=[], diff --git a/tests/test_combat_zaxis.py b/tests/test_combat_zaxis.py index 18617f8..620f9c3 100644 --- a/tests/test_combat_zaxis.py +++ b/tests/test_combat_zaxis.py @@ -136,7 +136,7 @@ async def test_flying_during_window_causes_miss(player, target, punch_right): encounter.attack(punch_right) # Advance to WINDOW phase - encounter.state = CombatState.WINDOW + encounter.state = CombatState.PENDING # Defender flies during window target.flying = True @@ -159,7 +159,7 @@ async def test_both_flying_at_resolve_attack_lands(player, target, punch_right): encounter.attack(punch_right) # Advance to WINDOW phase (no altitude change) - encounter.state = CombatState.WINDOW + encounter.state = CombatState.PENDING # 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) # Advance to WINDOW phase - encounter.state = CombatState.WINDOW + encounter.state = CombatState.PENDING # Attacker flies during window player.flying = True @@ -205,7 +205,7 @@ async def test_flying_dodge_messages_correct_grammar(player, target, punch_right encounter.attack(punch_right) # Advance to WINDOW phase - encounter.state = CombatState.WINDOW + encounter.state = CombatState.PENDING # Defender flies during window target.flying = True diff --git a/tests/test_commands_list.py b/tests/test_commands_list.py index 4ebf2c6..61d0c62 100644 --- a/tests/test_commands_list.py +++ b/tests/test_commands_list.py @@ -175,7 +175,7 @@ async def test_commands_detail_simple_combat_move(player, combat_moves): assert "roundhouse" in output assert "type: attack" 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 "{attacker} shifts {his} weight back..." 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 assert "stamina: 5.0" in output - assert "timing window: 1800ms" in output + assert "hit time: 3000ms" 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 "type: attack" 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 "{attacker} retracts {his} left arm..." in output assert "countered by: dodge right, parry high" in output diff --git a/tests/test_corpse.py b/tests/test_corpse.py index 680d80a..cef074a 100644 --- a/tests/test_corpse.py +++ b/tests/test_corpse.py @@ -273,7 +273,7 @@ class TestCombatDeathCorpse: name="punch right", move_type="attack", stamina_cost=5.0, - timing_window_ms=800, + hit_time_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) @@ -334,7 +334,7 @@ class TestCombatDeathCorpse: name="punch right", move_type="attack", stamina_cost=5.0, - timing_window_ms=800, + hit_time_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) @@ -394,7 +394,7 @@ class TestCombatDeathCorpse: name="punch right", move_type="attack", stamina_cost=5.0, - timing_window_ms=800, + hit_time_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) diff --git a/tests/test_editor_integration.py b/tests/test_editor_integration.py index c46c275..6a1f4b4 100644 --- a/tests/test_editor_integration.py +++ b/tests/test_editor_integration.py @@ -187,7 +187,7 @@ async def test_edit_combat_move_opens_toml(player, tmp_path): aliases = ["rh"] move_type = "attack" stamina_cost = 8.0 -timing_window_ms = 2000 +hit_time_ms = 2000 """ toml_file = tmp_path / "roundhouse.toml" toml_file.write_text(toml_content) diff --git a/tests/test_gmcp.py b/tests/test_gmcp.py index 04f69a3..899ae61 100644 --- a/tests/test_gmcp.py +++ b/tests/test_gmcp.py @@ -370,7 +370,7 @@ async def test_char_vitals_sent_on_combat_resolve(): name="punch right", move_type="attack", stamina_cost=5.0, - timing_window_ms=800, + hit_time_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) diff --git a/tests/test_help_unlock.py b/tests/test_help_unlock.py index 9da27a2..91c450b 100644 --- a/tests/test_help_unlock.py +++ b/tests/test_help_unlock.py @@ -37,7 +37,7 @@ def mock_move_kill_count(): name="roundhouse", move_type="attack", stamina_cost=30.0, - timing_window_ms=850, + hit_time_ms=850, aliases=["rh"], description="A powerful spinning kick", damage_pct=0.35, @@ -52,7 +52,7 @@ def mock_move_mob_kills(): name="goblin slayer", move_type="attack", stamina_cost=25.0, - timing_window_ms=800, + hit_time_ms=800, description="Specialized technique against goblins", damage_pct=0.40, unlock_condition=UnlockCondition( @@ -68,7 +68,7 @@ def mock_move_no_unlock(): name="jab", move_type="attack", stamina_cost=10.0, - timing_window_ms=600, + hit_time_ms=600, description="A quick straight punch", damage_pct=0.15, ) diff --git a/tests/test_mob_ai.py b/tests/test_mob_ai.py index 23a0e21..5f75dd0 100644 --- a/tests/test_mob_ai.py +++ b/tests/test_mob_ai.py @@ -132,7 +132,7 @@ class TestMobAttackAI: await process_mobs(moves) # 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 @pytest.mark.asyncio @@ -286,7 +286,7 @@ class TestMobDefenseAI: # Player attacks, putting encounter in TELEGRAPH encounter.attack(punch_right) - assert encounter.state == CombatState.TELEGRAPH + assert encounter.state == CombatState.PENDING await process_mobs(moves) diff --git a/tests/test_prompt.py b/tests/test_prompt.py index aeacdc6..da32962 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -399,7 +399,7 @@ def test_move_shows_name_when_in_combat_with_active_move(): name="punch right", move_type="attack", stamina_cost=5.0, - timing_window_ms=800, + hit_time_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) @@ -463,7 +463,7 @@ def test_combat_state_shows_state_when_in_combat(): name="punch right", move_type="attack", stamina_cost=5.0, - timing_window_ms=800, + hit_time_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) @@ -473,7 +473,7 @@ def test_combat_state_shows_state_when_in_combat(): active_encounters.append(encounter) result = render_prompt(player) - assert result == "[telegraph] > " + assert result == "[pending] > " def test_terrain_variable_grass(): diff --git a/tests/test_safe_zones.py b/tests/test_safe_zones.py index f8aaef6..bbdf655 100644 --- a/tests/test_safe_zones.py +++ b/tests/test_safe_zones.py @@ -178,7 +178,7 @@ async def test_attack_blocked_in_safe_zone(safe_zone, mock_writer, mock_reader): variant="left", move_type="attack", stamina_cost=5, - timing_window_ms=850, + hit_time_ms=850, damage_pct=0.15, ) @@ -227,7 +227,7 @@ async def test_attack_allowed_in_unsafe_zone(unsafe_zone, mock_writer, mock_read variant="left", move_type="attack", stamina_cost=5, - timing_window_ms=850, + hit_time_ms=850, damage_pct=0.15, ) diff --git a/tests/test_stamina_cue_wiring.py b/tests/test_stamina_cue_wiring.py index 2e51c4b..973782d 100644 --- a/tests/test_stamina_cue_wiring.py +++ b/tests/test_stamina_cue_wiring.py @@ -70,7 +70,7 @@ async def test_stamina_cue_after_combat_damage(player, defender): move_type="attack", damage_pct=0.5, stamina_cost=5.0, - timing_window_ms=100, + hit_time_ms=100, ) # Start encounter @@ -141,7 +141,7 @@ async def test_stamina_cue_after_attack_cost(player, defender): move_type="attack", damage_pct=0.3, stamina_cost=60.0, - timing_window_ms=1000, + hit_time_ms=1000, ) player.stamina = 100.0 @@ -171,15 +171,17 @@ async def test_stamina_cue_after_defense_cost(player): command="duck", move_type="defense", stamina_cost=30.0, - timing_window_ms=100, + active_ms=100, + recovery_ms=2700, ) player.stamina = 100.0 player.max_stamina = 100.0 with patch("mudlib.combat.commands.check_stamina_cues") as mock_check: - # Use a short timing window to avoid blocking too long - move.timing_window_ms = 10 + # Use a short active window to avoid blocking too long + move.active_ms = 10 + move.recovery_ms = 10 await do_defend(player, "", move) # check_stamina_cues should have been called diff --git a/tests/test_three_beat.py b/tests/test_three_beat.py index ca7819e..a80e285 100644 --- a/tests/test_three_beat.py +++ b/tests/test_three_beat.py @@ -37,7 +37,7 @@ def punch(): name="punch left", move_type="attack", stamina_cost=5.0, - timing_window_ms=800, + hit_time_ms=800, damage_pct=0.15, countered_by=["dodge right"], telegraph="{attacker} retracts {his} left arm...", @@ -51,8 +51,8 @@ def punch(): @pytest.mark.asyncio -async def test_announce_sent_on_telegraph_to_window_transition(punch): - """Test announce message sent when TELEGRAPH→WINDOW transition occurs.""" +async def test_announce_sent_on_pending_to_resolve_transition(punch): + """Test announce message sent when PENDING→RESOLVE transition occurs.""" atk_writer = _mock_writer() def_writer = _mock_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.attack(punch) - # Should be in TELEGRAPH state - assert encounter.state == CombatState.TELEGRAPH + # Should be in PENDING state + assert encounter.state == CombatState.PENDING # Reset mocks to ignore previous messages atk_writer.write.reset_mock() def_writer.write.reset_mock() - # Wait for telegraph phase and process - time.sleep(0.31) + # Wait for hit time (800ms) and process + time.sleep(0.85) await process_combat() - # Should have transitioned to WINDOW - assert encounter.state == CombatState.WINDOW + # Should have auto-resolved and returned to IDLE + assert encounter.state == CombatState.IDLE # Check announce message was sent 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() def_writer.write.reset_mock() - time.sleep(0.31) + # Wait for hit time (800ms) to trigger announce + time.sleep(0.85) await process_combat() 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.attack(punch) - # Advance to window - time.sleep(0.31) - await process_combat() - - atk_writer.write.reset_mock() - def_writer.write.reset_mock() - - # Advance to resolve + # Advance to resolve (announce and resolve happen on same tick) time.sleep(0.85) await process_combat() @@ -162,20 +156,18 @@ async def test_resolve_uses_resolve_miss_on_counter(punch): name="dodge right", move_type="defense", 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.attack(punch) encounter.defend(dodge) - # Advance to resolve - time.sleep(0.31) - await process_combat() - atk_writer.write.reset_mock() def_writer.write.reset_mock() + # Advance to resolve (defense is already active) time.sleep(0.85) await process_combat() @@ -220,7 +212,8 @@ async def test_announce_color_applied(punch): atk_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() 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.attack(punch) - # Advance to resolve - time.sleep(0.31) - await process_combat() - atk_writer.write.reset_mock() def_writer.write.reset_mock() + # Advance to resolve time.sleep(0.85) await process_combat() @@ -280,8 +270,8 @@ async def test_resolve_bold_color_applied(punch): @pytest.mark.asyncio -async def test_no_announce_on_idle_to_telegraph(): - """Test no announce message sent on IDLE→TELEGRAPH transition.""" +async def test_no_announce_on_idle_to_pending(): + """Test no announce message sent on IDLE→PENDING transition.""" atk_writer = _mock_writer() def_writer = _mock_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", move_type="attack", stamina_cost=5.0, - timing_window_ms=800, + hit_time_ms=800, damage_pct=0.15, 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) - # Process combat immediately (still in TELEGRAPH) + # Process combat immediately (still in PENDING) await process_combat() atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] diff --git a/tests/test_unconscious.py b/tests/test_unconscious.py index 037b516..96bcb05 100644 --- a/tests/test_unconscious.py +++ b/tests/test_unconscious.py @@ -237,7 +237,7 @@ async def test_knockout_ends_combat(clear_state, mock_writer): move_type="attack", damage_pct=0.5, stamina_cost=10.0, - timing_window_ms=850, + hit_time_ms=850, countered_by=[], ) @@ -266,7 +266,7 @@ async def test_defender_stamina_ko_ends_combat(clear_state, mock_writer): move_type="attack", damage_pct=0.5, stamina_cost=10.0, - timing_window_ms=850, + hit_time_ms=850, countered_by=[], ) diff --git a/tests/test_unlock_system.py b/tests/test_unlock_system.py index e423b9b..875e0f0 100644 --- a/tests/test_unlock_system.py +++ b/tests/test_unlock_system.py @@ -40,7 +40,7 @@ def test_combat_move_with_unlock_condition(): name="roundhouse", move_type="attack", stamina_cost=20.0, - timing_window_ms=500, + hit_time_ms=500, unlock_condition=condition, ) assert move.unlock_condition is condition @@ -53,7 +53,7 @@ def test_combat_move_unlock_condition_defaults_to_none(): name="punch", move_type="attack", stamina_cost=10.0, - timing_window_ms=300, + hit_time_ms=300, ) assert move.unlock_condition is None @@ -68,7 +68,7 @@ def test_check_unlocks_grants_move_on_kill_threshold(): name="roundhouse", move_type="attack", stamina_cost=20.0, - timing_window_ms=500, + hit_time_ms=500, command="roundhouse", 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", move_type="attack", stamina_cost=15.0, - timing_window_ms=400, + hit_time_ms=400, command="goblin_slayer", unlock_condition=UnlockCondition( 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", move_type="attack", stamina_cost=20.0, - timing_window_ms=500, + hit_time_ms=500, command="roundhouse", unlock_condition=UnlockCondition(type="kill_count", threshold=5), ) @@ -136,7 +136,7 @@ def test_check_unlocks_returns_empty_list_if_nothing_new(): name="roundhouse", move_type="attack", stamina_cost=20.0, - timing_window_ms=500, + hit_time_ms=500, command="roundhouse", unlock_condition=UnlockCondition(type="kill_count", threshold=5), ) @@ -153,7 +153,7 @@ def test_load_toml_with_unlock_condition(): name = "roundhouse" move_type = "attack" stamina_cost = 20.0 -timing_window_ms = 500 +hit_time_ms = 500 [unlock] type = "kill_count" @@ -181,7 +181,7 @@ def test_load_toml_without_unlock_section(): name = "punch" move_type = "attack" stamina_cost = 10.0 -timing_window_ms = 300 +hit_time_ms = 300 """ with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: f.write(toml_content) @@ -202,7 +202,7 @@ def test_load_toml_with_mob_kills_unlock(): name = "goblin_slayer" move_type = "attack" stamina_cost = 15.0 -timing_window_ms = 400 +hit_time_ms = 400 [unlock] type = "mob_kills" @@ -238,7 +238,7 @@ async def test_gating_locked_move_rejected_in_do_attack(): name="roundhouse", move_type="attack", stamina_cost=20.0, - timing_window_ms=500, + hit_time_ms=500, command="roundhouse", unlock_condition=UnlockCondition(type="kill_count", threshold=5), ) @@ -265,7 +265,7 @@ async def test_gating_unlocked_move_works_normally(): name="roundhouse", move_type="attack", stamina_cost=20.0, - timing_window_ms=500, + hit_time_ms=500, command="roundhouse", unlock_condition=UnlockCondition(type="kill_count", threshold=5), ) @@ -295,7 +295,7 @@ async def test_gating_move_without_unlock_condition_always_works(): name="punch", move_type="attack", stamina_cost=10.0, - timing_window_ms=300, + hit_time_ms=300, command="punch", unlock_condition=None, # no unlock needed ) @@ -322,7 +322,7 @@ def test_check_unlocks_deduplicates_variants(): name="punch left", move_type="attack", stamina_cost=10.0, - timing_window_ms=300, + hit_time_ms=300, command="punch", variant="left", unlock_condition=UnlockCondition(type="kill_count", threshold=5), @@ -331,7 +331,7 @@ def test_check_unlocks_deduplicates_variants(): name="punch right", move_type="attack", stamina_cost=10.0, - timing_window_ms=300, + hit_time_ms=300, command="punch", variant="right", unlock_condition=UnlockCondition(type="kill_count", threshold=5),