diff --git a/src/mudlib/corpse.py b/src/mudlib/corpse.py index 919796b..66bc799 100644 --- a/src/mudlib/corpse.py +++ b/src/mudlib/corpse.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from mudlib.container import Container from mudlib.entity import Mob +from mudlib.loot import LootEntry, roll_loot from mudlib.mobs import despawn_mob from mudlib.zone import Zone @@ -28,16 +29,19 @@ class Corpse(Container): decompose_at: float = 0.0 -def create_corpse(mob: Mob, zone: Zone, ttl: int = 300) -> Corpse: +def create_corpse( + mob: Mob, zone: Zone, ttl: int = 300, loot_table: list[LootEntry] | None = None +) -> Corpse: """Create a corpse from a defeated mob. Args: mob: The mob that died zone: The zone where the corpse will be placed ttl: Time to live in seconds (default 300) + loot_table: Optional loot table to roll for additional items Returns: - The created corpse with the mob's inventory + The created corpse with the mob's inventory and loot """ # Create corpse at mob's position corpse = Corpse( @@ -52,6 +56,11 @@ def create_corpse(mob: Mob, zone: Zone, ttl: int = 300) -> Corpse: for item in list(mob._contents): item.move_to(corpse) + # Roll and add loot + if loot_table: + for item in roll_loot(loot_table): + item.move_to(corpse) + # Remove mob from world despawn_mob(mob) @@ -69,13 +78,16 @@ async def process_decomposing() -> None: if now >= corpse.decompose_at: # Broadcast to entities at the same tile zone = corpse.location - if isinstance(zone, Zone): + if isinstance(zone, Zone) and corpse.x is not None and corpse.y is not None: from mudlib.entity import Entity for obj in zone.contents_at(corpse.x, corpse.y): if isinstance(obj, Entity): await obj.send(f"{corpse.name} decomposes.\r\n") + # Clear contents — items rot with the corpse + for item in list(corpse._contents): + item.move_to(None) # Remove corpse from world corpse.move_to(None) active_corpses.remove(corpse) diff --git a/tests/test_corpse.py b/tests/test_corpse.py index 6a61388..cf73826 100644 --- a/tests/test_corpse.py +++ b/tests/test_corpse.py @@ -221,6 +221,14 @@ class TestCorpseAsContainer: class TestCombatDeathCorpse: """Tests for corpse spawning when a mob dies in combat.""" + @pytest.fixture(autouse=True) + def clear_corpses(self): + from mudlib.corpse import active_corpses + + active_corpses.clear() + yield + active_corpses.clear() + @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.""" @@ -572,14 +580,19 @@ class TestDecomposition: @pytest.mark.asyncio async def test_decomposition_broadcasts_message(self, test_zone): """Decomposition broadcasts to entities at the same tile.""" - from unittest.mock import AsyncMock + from unittest.mock import AsyncMock, MagicMock from mudlib.corpse import active_corpses, process_decomposing - from mudlib.entity import Entity + from mudlib.player import Player - # Create entity at same position - entity = Entity(name="hero", x=5, y=10, location=test_zone) - entity.send = AsyncMock() + # Create player at same position + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + reader = MagicMock() + _player = Player( + name="hero", x=5, y=10, reader=reader, writer=writer, location=test_zone + ) corpse = Corpse( name="goblin's corpse", @@ -592,19 +605,26 @@ class TestDecomposition: await process_decomposing() - entity.send.assert_called_once_with("goblin's corpse decomposes.\r\n") + # Check that decomposition message was written + messages = [call[0][0] for call in writer.write.call_args_list] + assert any("goblin's corpse decomposes" in msg for msg in messages) @pytest.mark.asyncio async def test_decomposition_only_broadcasts_to_same_tile(self, test_zone): """Decomposition does NOT broadcast to entities at different tiles.""" - from unittest.mock import AsyncMock + from unittest.mock import AsyncMock, MagicMock from mudlib.corpse import active_corpses, process_decomposing - from mudlib.entity import Entity + from mudlib.player import Player - # Entity at different position - entity = Entity(name="faraway", x=50, y=50, location=test_zone) - entity.send = AsyncMock() + # Player at different position + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + reader = MagicMock() + _player = Player( + name="faraway", x=50, y=50, reader=reader, writer=writer, location=test_zone + ) corpse = Corpse( name="goblin's corpse", @@ -617,7 +637,9 @@ class TestDecomposition: await process_decomposing() - entity.send.assert_not_called() + # Check that no decomposition message was written + messages = [call[0][0] for call in writer.write.call_args_list] + assert not any("goblin's corpse decomposes" in msg for msg in messages) def test_create_corpse_registers_in_active_corpses(self, goblin_mob, test_zone): """create_corpse adds corpse to active_corpses list.""" @@ -639,12 +661,31 @@ class TestDecomposition: y=10, decompose_at=time.monotonic() - 1, ) - _sword = Thing(name="sword", location=corpse) + sword = Thing(name="sword", location=corpse) active_corpses.append(corpse) await process_decomposing() # Corpse is gone assert corpse.location is None - # Sword's location is still the corpse (orphaned) - # This is expected — items rot with the corpse + # Sword should also be removed (items rot with the corpse) + assert sword.location is None + + +class TestCorpseLoot: + """Tests for loot drops in corpses.""" + + def test_create_corpse_with_loot(self, goblin_mob, test_zone): + """create_corpse with loot_table rolls loot and adds to corpse.""" + from mudlib.loot import LootEntry + + loot = [LootEntry(name="gold coin", chance=1.0)] + corpse = create_corpse(goblin_mob, test_zone, loot_table=loot) + items = [obj for obj in corpse._contents if isinstance(obj, Thing)] + assert len(items) == 1 + assert items[0].name == "gold coin" + + def test_create_corpse_without_loot(self, goblin_mob, test_zone): + """create_corpse without loot_table creates empty corpse.""" + corpse = create_corpse(goblin_mob, test_zone) + assert len(corpse._contents) == 0