From a4a866a77c234d1fdd279c98836f804fe2648174 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sun, 15 Feb 2026 12:40:21 -0500 Subject: [PATCH] Change combat flow: KO persists, timeout requires no landed damage --- src/mudlib/combat/encounter.py | 14 ++-- src/mudlib/combat/engine.py | 68 +------------------- tests/test_combat_encounter.py | 68 ++++++++++++++------ tests/test_combat_engine.py | 114 +++++++++++++++++++++++++++------ tests/test_gmcp.py | 21 +----- tests/test_mobs.py | 37 +++++------ 6 files changed, 177 insertions(+), 145 deletions(-) diff --git a/src/mudlib/combat/encounter.py b/src/mudlib/combat/encounter.py index ba098c5..bcc052d 100644 --- a/src/mudlib/combat/encounter.py +++ b/src/mudlib/combat/encounter.py @@ -20,7 +20,7 @@ class CombatState(Enum): # Telegraph phase duration in seconds (3 game ticks at 100ms/tick) TELEGRAPH_DURATION = 0.3 -# Seconds of no action before combat fizzles out +# Seconds since last landed damage before combat fizzles out IDLE_TIMEOUT = 30.0 @@ -44,6 +44,7 @@ class CombatEncounter: current_move: CombatMove | None = None move_started_at: float = 0.0 pending_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: @@ -70,7 +71,6 @@ class CombatEncounter: self.current_move = move self.attacker.stamina -= move.stamina_cost - self.last_action_at = now if self.state == CombatState.IDLE: self.state = CombatState.TELEGRAPH @@ -83,7 +83,6 @@ class CombatEncounter: move: The defense move to attempt """ self.pending_defense = move - self.last_action_at = time.monotonic() def tick(self, now: float) -> None: """Advance the state machine based on current time. @@ -157,7 +156,7 @@ class CombatEncounter: elif self.pending_defense: # Wrong defense - normal damage damage = self.attacker.pl * self.current_move.damage_pct - self.defender.pl -= damage + self.defender.pl = max(0.0, self.defender.pl - damage) template = ( self.current_move.resolve_hit if self.current_move.resolve_hit @@ -167,7 +166,7 @@ class CombatEncounter: else: # No defense - increased damage damage = self.attacker.pl * self.current_move.damage_pct * 1.5 - self.defender.pl -= damage + self.defender.pl = max(0.0, self.defender.pl - damage) template = ( self.current_move.resolve_hit if self.current_move.resolve_hit @@ -175,8 +174,9 @@ class CombatEncounter: ) countered = False - # Check for combat end conditions - combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0 + if damage > 0: + self.last_action_at = time.monotonic() + combat_ended = False # Reset to IDLE self.state = CombatState.IDLE diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py index 98e5adf..f462259 100644 --- a/src/mudlib/combat/engine.py +++ b/src/mudlib/combat/engine.py @@ -4,7 +4,7 @@ import time from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState from mudlib.combat.stamina import check_stamina_cues -from mudlib.entity import Entity, Mob +from mudlib.entity import Entity from mudlib.gmcp import send_char_status, send_char_vitals from mudlib.render.colors import colorize from mudlib.render.pov import render_pov @@ -89,7 +89,7 @@ async def process_combat() -> None: now = time.monotonic() for encounter in active_encounters[:]: # Copy list to allow modification - # Check for idle timeout + # Check for no-damage timeout. if now - encounter.last_action_at > IDLE_TIMEOUT: await encounter.attacker.send("Combat has fizzled out.\r\n") await encounter.defender.send("Combat has fizzled out.\r\n") @@ -156,67 +156,3 @@ async def process_combat() -> None: # Check stamina cues after damage await check_stamina_cues(encounter.attacker) await check_stamina_cues(encounter.defender) - - if result.combat_ended: - # Determine winner/loser - if encounter.defender.pl <= 0: - loser = encounter.defender - winner = encounter.attacker - else: - loser = encounter.attacker - winner = encounter.defender - - # Track kill/death stats - if isinstance(winner, Player): - winner.kills += 1 - if isinstance(loser, Mob): - winner.mob_kills[loser.name] = ( - winner.mob_kills.get(loser.name, 0) + 1 - ) - - # Check for new unlocks - from mudlib.combat.commands import combat_moves - from mudlib.combat.unlock import check_unlocks - - newly_unlocked = check_unlocks(winner, combat_moves) - for move_name in newly_unlocked: - await winner.send(f"You have learned {move_name}!\r\n") - - if isinstance(loser, Player): - loser.deaths += 1 - - # Despawn mob losers, send victory/defeat messages - if isinstance(loser, Mob): - from mudlib.corpse import create_corpse - from mudlib.mobs import mob_templates - from mudlib.zone import Zone - - zone = loser.location - if isinstance(zone, Zone): - # Look up loot table from mob template - template = mob_templates.get(loser.name) - loot_table = template.loot if template else None - create_corpse(loser, zone, loot_table=loot_table) - else: - from mudlib.mobs import despawn_mob - - despawn_mob(loser) - await winner.send(f"You have defeated the {loser.name}!\r\n") - elif isinstance(winner, Mob): - await loser.send( - f"You have been defeated by the {winner.name}!\r\n" - ) - - # Pop combat mode from both entities if they're Players - attacker = encounter.attacker - if isinstance(attacker, Player) and attacker.mode == "combat": - attacker.mode_stack.pop() - send_char_status(attacker) - - defender = encounter.defender - if isinstance(defender, Player) and defender.mode == "combat": - defender.mode_stack.pop() - send_char_status(defender) - - # Remove encounter from active list - end_encounter(encounter) diff --git a/tests/test_combat_encounter.py b/tests/test_combat_encounter.py index f7399fc..698d04b 100644 --- a/tests/test_combat_encounter.py +++ b/tests/test_combat_encounter.py @@ -239,8 +239,8 @@ def test_combat_state_enum(): assert CombatState.RESOLVE.value == "resolve" -def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch): - """Test resolve returns combat_ended=True when defender PL <= 0.""" +def test_resolve_knockout_does_not_end_combat(attacker, defender, punch): + """KO should not end combat by itself.""" encounter = CombatEncounter(attacker=attacker, defender=defender) # Set defender to low PL so attack will knock them out @@ -250,12 +250,12 @@ def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch): result = encounter.resolve() assert defender.pl <= 0 - assert result.combat_ended is True + assert result.combat_ended is False assert result.damage > 0 -def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch): - """Test resolve returns combat_ended=True when attacker stamina <= 0.""" +def test_resolve_exhaustion_does_not_end_combat(attacker, defender, punch): + """Exhaustion should not end combat by itself.""" encounter = CombatEncounter(attacker=attacker, defender=defender) # Set attacker stamina to exactly the cost so attack depletes it @@ -265,7 +265,19 @@ def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch): result = encounter.resolve() assert attacker.stamina <= 0 - assert result.combat_ended is True + assert result.combat_ended is False + + +def test_resolve_exhausted_defender_does_not_end_combat(attacker, defender, punch): + """Exhausted defender still does not auto-end encounter.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + defender.stamina = 0.0 + + encounter.attack(punch) + result = encounter.resolve() + + assert defender.stamina <= 0 + assert result.combat_ended is False def test_resolve_returns_combat_continues_normally(attacker, defender, punch): @@ -393,27 +405,47 @@ def test_resolve_uses_final_move(attacker, defender, punch, sweep): assert result.resolve_template != "" -# --- last_action_at tracking tests --- +# --- last_action_at (last landed damage) tracking tests --- -def test_last_action_at_updates_on_attack(attacker, defender, punch): - """Test last_action_at is set when attack() is called.""" +def test_last_action_at_not_updated_on_attack(attacker, defender, punch): + """Attack startup should not reset timeout until damage lands.""" encounter = CombatEncounter(attacker=attacker, defender=defender) assert encounter.last_action_at == 0.0 - before = time.monotonic() encounter.attack(punch) + assert encounter.last_action_at == 0.0 + + +def test_last_action_at_not_updated_on_defend(attacker, defender, punch, dodge): + """Defense input should not reset timeout without landed damage.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + encounter.attack(punch) + assert encounter.last_action_at == 0.0 + encounter.defend(dodge) + + assert encounter.last_action_at == 0.0 + + +def test_last_action_at_updates_when_damage_lands(attacker, defender, punch): + """Landed damage should refresh timeout timestamp.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + assert encounter.last_action_at == 0.0 + encounter.attack(punch) + before = time.monotonic() + encounter.resolve() assert encounter.last_action_at >= before -def test_last_action_at_updates_on_defend(attacker, defender, punch, dodge): - """Test last_action_at is set when defend() is called.""" - encounter = CombatEncounter(attacker=attacker, defender=defender) +def test_last_action_at_unchanged_when_attack_is_countered( + attacker, defender, punch, dodge +): + """No damage (successful counter) should not refresh timeout timestamp.""" + encounter = CombatEncounter( + attacker=attacker, defender=defender, last_action_at=10.0 + ) encounter.attack(punch) - first_action = encounter.last_action_at - - time.sleep(0.01) encounter.defend(dodge) - - assert encounter.last_action_at > first_action + encounter.resolve() + assert encounter.last_action_at == 10.0 diff --git a/tests/test_combat_engine.py b/tests/test_combat_engine.py index ae60220..a51ac6e 100644 --- a/tests/test_combat_engine.py +++ b/tests/test_combat_engine.py @@ -201,8 +201,8 @@ async def test_encounter_cleanup_after_resolution(attacker, defender, punch): @pytest.mark.asyncio -async def test_process_combat_ends_encounter_on_knockout(punch): - """Test process_combat ends encounter when defender is knocked out.""" +async def test_process_combat_keeps_encounter_after_knockout(punch): + """KO should not end combat; encounter stays active.""" w = _mock_writer attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w()) defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0, writer=w()) @@ -220,17 +220,16 @@ async def test_process_combat_ends_encounter_on_knockout(punch): time.sleep(0.85) await process_combat() - # Combat should have ended and been cleaned up - assert get_encounter(attacker) is None - assert get_encounter(defender) is None - # Mode stacks should have combat popped - assert attacker.mode_stack == ["normal"] - assert defender.mode_stack == ["normal"] + # Combat should remain active after KO + assert get_encounter(attacker) is encounter + assert get_encounter(defender) is encounter + assert attacker.mode_stack == ["normal", "combat"] + assert defender.mode_stack == ["normal", "combat"] @pytest.mark.asyncio -async def test_process_combat_ends_encounter_on_exhaustion(punch): - """Test process_combat ends encounter when attacker is exhausted.""" +async def test_process_combat_keeps_encounter_after_exhaustion(punch): + """Exhaustion should not end combat; encounter stays active.""" w = _mock_writer attacker = Player( name="Goku", @@ -262,11 +261,68 @@ async def test_process_combat_ends_encounter_on_exhaustion(punch): time.sleep(0.85) await process_combat() - # Combat should have ended - assert get_encounter(attacker) is None - assert get_encounter(defender) is None - assert attacker.mode_stack == ["normal"] - assert defender.mode_stack == ["normal"] + # Combat should remain active + assert get_encounter(attacker) is encounter + assert get_encounter(defender) is encounter + assert attacker.mode_stack == ["normal", "combat"] + assert defender.mode_stack == ["normal", "combat"] + + +@pytest.mark.asyncio +async def test_process_combat_keeps_encounter_when_defender_already_exhausted(punch): + """Defender exhaustion should not auto-end encounter.""" + w = _mock_writer + attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w()) + defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=0.0, writer=w()) + + attacker.mode_stack.append("combat") + defender.mode_stack.append("combat") + + encounter = start_encounter(attacker, defender) + encounter.attack(punch) + + time.sleep(0.31) + await process_combat() + time.sleep(0.85) + await process_combat() + + assert get_encounter(attacker) is encounter + assert get_encounter(defender) is encounter + assert attacker.mode_stack == ["normal", "combat"] + assert defender.mode_stack == ["normal", "combat"] + + +@pytest.mark.asyncio +async def test_process_combat_keeps_encounter_when_both_unconscious(punch): + """Double unconscious should not auto-end; timeout/finisher decides.""" + w = _mock_writer + attacker = Player( + name="Goku", + x=0, + y=0, + pl=100.0, + stamina=punch.stamina_cost, + writer=w(), + ) + defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=0.0, writer=w()) + + attacker.mode_stack.append("combat") + defender.mode_stack.append("combat") + + encounter = start_encounter(attacker, defender) + encounter.attack(punch) + + time.sleep(0.31) + await process_combat() + time.sleep(0.85) + await process_combat() + + assert get_encounter(attacker) is encounter + assert get_encounter(defender) is encounter + assert attacker.kills == 0 + assert defender.kills == 0 + assert attacker.deaths == 0 + assert defender.deaths == 0 @pytest.mark.asyncio @@ -335,7 +391,7 @@ async def test_process_combat_sends_messages_on_resolve(punch): @pytest.mark.asyncio async def test_idle_timeout_ends_encounter(): - """Test encounter times out after 30s of no actions.""" + """Encounter times out after 30s without landed damage.""" w = _mock_writer attacker = Player(name="Goku", x=0, y=0, writer=w()) defender = Player(name="Vegeta", x=0, y=0, writer=w()) @@ -393,7 +449,7 @@ async def test_idle_timeout_pops_combat_mode(): @pytest.mark.asyncio async def test_recent_action_prevents_timeout(): - """Test recent action prevents idle timeout.""" + """Fresh encounter start prevents immediate timeout.""" w = _mock_writer attacker = Player(name="Goku", x=0, y=0, writer=w()) defender = Player(name="Vegeta", x=0, y=0, writer=w()) @@ -411,7 +467,7 @@ async def test_recent_action_prevents_timeout(): @pytest.mark.asyncio async def test_start_encounter_sets_last_action_at(): - """Test start_encounter initializes last_action_at.""" + """start_encounter initializes no-damage timeout clock.""" attacker = Entity(name="Goku", x=0, y=0) defender = Entity(name="Vegeta", x=0, y=0) @@ -419,3 +475,25 @@ async def test_start_encounter_sets_last_action_at(): encounter = start_encounter(attacker, defender) assert encounter.last_action_at >= before + + +@pytest.mark.asyncio +async def test_landed_damage_refreshes_timeout_clock(punch): + """Successful hit should refresh timeout timer.""" + w = _mock_writer + attacker = Player(name="Goku", x=0, y=0, writer=w()) + defender = Player(name="Vegeta", x=0, y=0, writer=w()) + attacker.mode_stack.append("combat") + defender.mode_stack.append("combat") + + encounter = start_encounter(attacker, defender) + # Keep close to timeout, but still allow resolve to land damage first. + encounter.last_action_at = time.monotonic() - 28.5 + encounter.attack(punch) + time.sleep(0.31) + await process_combat() + time.sleep(0.85) + await process_combat() + + # A landed hit should keep encounter alive by refreshing the timer. + assert get_encounter(attacker) is encounter diff --git a/tests/test_gmcp.py b/tests/test_gmcp.py index df83513..04f69a3 100644 --- a/tests/test_gmcp.py +++ b/tests/test_gmcp.py @@ -413,11 +413,9 @@ async def test_char_vitals_sent_on_combat_resolve(): @pytest.mark.asyncio async def test_char_status_sent_on_combat_end(): - """Test Char.Status is sent when combat ends (victory/defeat).""" - import time + """Test Char.Status is sent when combat ends (timeout).""" from mudlib.combat.engine import active_encounters, process_combat, start_encounter - from mudlib.combat.moves import CombatMove # Clear encounters active_encounters.clear() @@ -443,22 +441,9 @@ async def test_char_status_sent_on_combat_end(): attacker.mode_stack.append("combat") defender.mode_stack.append("combat") - # Create encounter and attack (will kill defender) + # Create encounter and force timeout end. encounter = start_encounter(attacker, defender) - punch = CombatMove( - name="punch right", - move_type="attack", - stamina_cost=5.0, - timing_window_ms=800, - damage_pct=0.15, - countered_by=["dodge left"], - ) - encounter.attack(punch) - - # Advance past telegraph and window to trigger resolution - time.sleep(0.31) - await process_combat() - time.sleep(0.85) + encounter.last_action_at -= 31.0 # Reset mocks before the resolution call mock_writer_1.send_gmcp.reset_mock() diff --git a/tests/test_mobs.py b/tests/test_mobs.py index 7a06db7..3bc79de 100644 --- a/tests/test_mobs.py +++ b/tests/test_mobs.py @@ -388,8 +388,8 @@ class TestMobDefeat: return spawn_mob(template, 0, 0, test_zone) @pytest.mark.asyncio - async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right): - """Mob with PL <= 0 gets despawned after combat resolves.""" + async def test_mob_not_despawned_on_pl_zero(self, player, goblin_mob, punch_right): + """KO does not despawn mob without an explicit finisher.""" from mudlib.combat.engine import process_combat, start_encounter encounter = start_encounter(player, goblin_mob) @@ -404,12 +404,14 @@ class TestMobDefeat: await process_combat() - assert goblin_mob not in mobs - assert goblin_mob.alive is False + assert goblin_mob in mobs + assert goblin_mob.alive is True @pytest.mark.asyncio - async def test_player_gets_victory_message(self, player, goblin_mob, punch_right): - """Player receives a victory message when mob is defeated.""" + async def test_player_gets_no_victory_message_on_ko( + self, player, goblin_mob, punch_right + ): + """KO should not be treated as a defeat/kill message.""" from mudlib.combat.engine import process_combat, start_encounter encounter = start_encounter(player, goblin_mob) @@ -422,29 +424,30 @@ class TestMobDefeat: await process_combat() messages = [call[0][0] for call in player.writer.write.call_args_list] - assert any("defeated" in msg.lower() for msg in messages) + assert not any("defeated" in msg.lower() for msg in messages) @pytest.mark.asyncio - async def test_mob_stamina_depleted_despawns(self, player, goblin_mob, punch_right): - """Mob is despawned when attacker stamina depleted (combat end).""" + async def test_exhaustion_does_not_end_encounter( + self, player, goblin_mob, punch_right + ): + """Attacker exhaustion does not auto-end combat.""" from mudlib.combat.engine import process_combat, start_encounter encounter = start_encounter(player, goblin_mob) player.mode_stack.append("combat") - # Drain player stamina so combat ends on exhaustion + # Drain player stamina before resolve player.stamina = 0.0 encounter.attack(punch_right) encounter.state = CombatState.RESOLVE await process_combat() - # Encounter should have ended - assert get_encounter(player) is None + assert get_encounter(player) is encounter @pytest.mark.asyncio - async def test_player_defeat_not_despawned(self, player, goblin_mob, punch_right): - """When player loses, player is not despawned.""" + async def test_player_ko_not_despawned(self, player, goblin_mob, punch_right): + """When player is KO'd, player remains present.""" from mudlib.combat.engine import process_combat, start_encounter # Mob attacks player — mob is attacker, player is defender @@ -457,10 +460,8 @@ class TestMobDefeat: await process_combat() - # Player should get defeat message, not be despawned messages = [call[0][0] for call in player.writer.write.call_args_list] - assert any( - "defeated" in msg.lower() or "damage" in msg.lower() for msg in messages - ) + assert len(messages) > 0 + assert not any("defeated" in msg.lower() for msg in messages) # Player is still in players dict (not removed) assert player.name in players