From 487e31662972eea1ebc0ba993c907611c9f4a165 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 09:56:37 -0500 Subject: [PATCH] Wire corpse spawning into combat death handling When a mob dies in combat, create_corpse is called to spawn a corpse at the mob's position with the mob's inventory transferred. This replaces the direct despawn_mob call, making combat deaths leave lootable corpses behind. The fallback to despawn_mob is kept if the mob somehow has no zone. --- src/mudlib/combat/engine.py | 11 ++- tests/test_corpse.py | 190 ++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 2 deletions(-) diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py index 39a8953..39d2c9e 100644 --- a/src/mudlib/combat/engine.py +++ b/src/mudlib/combat/engine.py @@ -170,9 +170,16 @@ async def process_combat() -> None: # Despawn mob losers, send victory/defeat messages if isinstance(loser, Mob): - from mudlib.mobs import despawn_mob + from mudlib.corpse import create_corpse + from mudlib.zone import Zone - despawn_mob(loser) + zone = loser.location + if isinstance(zone, Zone): + create_corpse(loser, zone) + 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( diff --git a/tests/test_corpse.py b/tests/test_corpse.py index 54e705c..0cc1554 100644 --- a/tests/test_corpse.py +++ b/tests/test_corpse.py @@ -216,3 +216,193 @@ class TestCorpseAsContainer: dummy_entity = Entity(name="dummy", x=0, y=0, location=test_zone) assert dummy_entity.can_accept(corpse) is False + + +class TestCombatDeathCorpse: + """Tests for corpse spawning when a mob dies in combat.""" + + @pytest.mark.asyncio + async def test_mob_death_in_combat_spawns_corpse(self, test_zone): + """Mob death in combat spawns a corpse at mob's position.""" + from mudlib.combat.encounter import CombatState + from mudlib.combat.engine import ( + active_encounters, + process_combat, + start_encounter, + ) + from mudlib.combat.moves import CombatMove + from mudlib.entity import Entity + + # Clear active encounters + active_encounters.clear() + + # Create a weak mob + mob = Mob( + name="goblin", + x=5, + y=10, + location=test_zone, + pl=1.0, + stamina=40.0, + ) + mobs.append(mob) + + # Create attacker + attacker = Entity( + name="hero", + x=5, + y=10, + location=test_zone, + pl=100.0, + stamina=50.0, + ) + + # Start encounter + encounter = start_encounter(attacker, mob) + + # Set up a lethal move + 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) + encounter.state = CombatState.RESOLVE + + # Process combat to trigger resolve + await process_combat() + + # Check for corpse at mob's position + corpses = [ + obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse) + ] + assert len(corpses) == 1 + assert corpses[0].name == "goblin's corpse" + + @pytest.mark.asyncio + async def test_mob_death_transfers_inventory_to_corpse(self, test_zone, sword): + """Mob death transfers inventory to corpse.""" + from mudlib.combat.encounter import CombatState + from mudlib.combat.engine import ( + active_encounters, + process_combat, + start_encounter, + ) + from mudlib.combat.moves import CombatMove + from mudlib.entity import Entity + + # Clear active encounters + active_encounters.clear() + + # Create a weak mob with inventory + mob = Mob( + name="goblin", + x=5, + y=10, + location=test_zone, + pl=1.0, + stamina=40.0, + ) + mobs.append(mob) + sword.move_to(mob) + + # Create attacker + attacker = Entity( + name="hero", + x=5, + y=10, + location=test_zone, + pl=100.0, + stamina=50.0, + ) + + # Start encounter and kill mob + encounter = start_encounter(attacker, mob) + 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) + encounter.state = CombatState.RESOLVE + + # Process combat + await process_combat() + + # Find corpse + corpses = [ + obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse) + ] + assert len(corpses) == 1 + corpse = corpses[0] + + # Verify sword is in corpse + assert sword in corpse._contents + assert sword.location is corpse + + @pytest.mark.asyncio + async def test_corpse_appears_in_zone_contents(self, test_zone): + """Corpse appears in zone.contents_at after mob death.""" + from mudlib.combat.encounter import CombatState + from mudlib.combat.engine import ( + active_encounters, + process_combat, + start_encounter, + ) + from mudlib.combat.moves import CombatMove + from mudlib.entity import Entity + + # Clear active encounters + active_encounters.clear() + + # Create a weak mob + mob = Mob( + name="goblin", + x=5, + y=10, + location=test_zone, + pl=1.0, + stamina=40.0, + ) + mobs.append(mob) + + # Create attacker + attacker = Entity( + name="hero", + x=5, + y=10, + location=test_zone, + pl=100.0, + stamina=50.0, + ) + + # Start encounter and kill mob + encounter = start_encounter(attacker, mob) + 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) + encounter.state = CombatState.RESOLVE + + # Process combat + await process_combat() + + # Verify corpse is in zone contents + contents = list(test_zone.contents_at(5, 10)) + corpse_count = sum(1 for obj in contents if isinstance(obj, Corpse)) + assert corpse_count == 1 + + # Verify it's the goblin's corpse + corpse = next(obj for obj in contents if isinstance(obj, Corpse)) + assert corpse.name == "goblin's corpse"