From a61e9982521bc9be68443a43e860dc0b6429f862 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sun, 8 Feb 2026 23:00:45 -0500 Subject: [PATCH] Handle mob defeat in combat resolution Phase 4: when combat ends, determine winner/loser. If the loser is a Mob, despawn it and send a victory message to the winner. If the loser is a Player fighting a Mob, send a defeat message instead. --- src/mudlib/combat/engine.py | 23 +++++++- tests/test_mobs.py | 102 ++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py index 8925b90..40a86ac 100644 --- a/src/mudlib/combat/engine.py +++ b/src/mudlib/combat/engine.py @@ -3,7 +3,7 @@ import time from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState -from mudlib.entity import Entity +from mudlib.entity import Entity, Mob # Global list of active combat encounters active_encounters: list[CombatEncounter] = [] @@ -101,6 +101,27 @@ async def process_combat() -> None: await encounter.defender.send(result.defender_msg + "\r\n") 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 + + # Despawn mob losers, send victory/defeat messages + if isinstance(loser, Mob): + 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 from mudlib.player import Player diff --git a/tests/test_mobs.py b/tests/test_mobs.py index 12f9903..24bdb58 100644 --- a/tests/test_mobs.py +++ b/tests/test_mobs.py @@ -8,6 +8,7 @@ import pytest import mudlib.commands.movement as movement_mod from mudlib.combat import commands as combat_commands +from mudlib.combat.encounter import CombatState from mudlib.combat.engine import active_encounters, get_encounter from mudlib.combat.moves import load_moves from mudlib.entity import Mob @@ -406,3 +407,104 @@ class TestViewportRendering: assert "*" not in stripped look_mod.world = old + + +# --- Phase 4: mob defeat tests --- + + +class TestMobDefeat: + @pytest.fixture + def goblin_mob(self, goblin_toml): + template = load_mob_template(goblin_toml) + return spawn_mob(template, 0, 0) + + @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.""" + from mudlib.combat.engine import process_combat, start_encounter + + encounter = start_encounter(player, goblin_mob) + player.mode_stack.append("combat") + + # Set mob PL very low so attack kills it + goblin_mob.pl = 1.0 + + # Attack and force resolution + encounter.attack(punch_right) + encounter.state = CombatState.RESOLVE + + await process_combat() + + assert goblin_mob not in mobs + assert goblin_mob.alive is False + + @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.""" + from mudlib.combat.engine import process_combat, start_encounter + + encounter = start_encounter(player, goblin_mob) + player.mode_stack.append("combat") + + goblin_mob.pl = 1.0 + encounter.attack(punch_right) + encounter.state = CombatState.RESOLVE + + 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) + + @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).""" + 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 + 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 + + @pytest.mark.asyncio + async def test_player_defeat_not_despawned( + self, player, goblin_mob, punch_right + ): + """When player loses, player is not despawned.""" + from mudlib.combat.engine import process_combat, start_encounter + + # Mob attacks player — mob is attacker, player is defender + encounter = start_encounter(goblin_mob, player) + player.mode_stack.append("combat") + + player.pl = 1.0 + encounter.attack(punch_right) + encounter.state = CombatState.RESOLVE + + 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 + ) + # Player is still in players dict (not removed) + assert player.name in players