From 56169a5ed6d151f8c70333132ad4981cdb04d0f7 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 10:02:33 -0500 Subject: [PATCH] Add corpse decomposition system with active_corpses registry process_decomposing removes expired corpses and broadcasts messages to entities at the same tile. Registered in game loop. --- src/mudlib/corpse.py | 26 +++++++++ src/mudlib/server.py | 2 + tests/test_corpse.py | 129 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+) diff --git a/src/mudlib/corpse.py b/src/mudlib/corpse.py index dc1252f..919796b 100644 --- a/src/mudlib/corpse.py +++ b/src/mudlib/corpse.py @@ -10,6 +10,9 @@ from mudlib.entity import Mob from mudlib.mobs import despawn_mob from mudlib.zone import Zone +# Module-level registry of active corpses +active_corpses: list[Corpse] = [] + @dataclass(eq=False) class Corpse(Container): @@ -52,4 +55,27 @@ def create_corpse(mob: Mob, zone: Zone, ttl: int = 300) -> Corpse: # Remove mob from world despawn_mob(mob) + # Register corpse for decomposition tracking + active_corpses.append(corpse) + return corpse + + +async def process_decomposing() -> None: + """Remove expired corpses and broadcast decomposition messages.""" + now = time.monotonic() + + for corpse in active_corpses[:]: # copy to allow modification + if now >= corpse.decompose_at: + # Broadcast to entities at the same tile + zone = corpse.location + if isinstance(zone, Zone): + 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") + + # Remove corpse from world + corpse.move_to(None) + active_corpses.remove(corpse) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index dee9e29..09cb409 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -35,6 +35,7 @@ from mudlib.caps import parse_mtts from mudlib.combat.commands import register_combat_commands from mudlib.combat.engine import process_combat from mudlib.content import load_commands +from mudlib.corpse import process_decomposing from mudlib.effects import clear_expired from mudlib.gmcp import ( send_char_status, @@ -101,6 +102,7 @@ async def game_loop() -> None: await process_mobs(mudlib.combat.commands.combat_moves) await process_resting() await process_unconscious() + await process_decomposing() # MSDP updates once per second (every TICK_RATE ticks) if tick_count % TICK_RATE == 0: diff --git a/tests/test_corpse.py b/tests/test_corpse.py index a7c1696..6a61388 100644 --- a/tests/test_corpse.py +++ b/tests/test_corpse.py @@ -519,3 +519,132 @@ class TestCorpseDisplay: # Both corpses should appear assert "goblin's corpse is here." in output assert "orc's corpse is here." in output + + +class TestDecomposition: + @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_expired_corpse_removed(self, test_zone): + """Corpse past its decompose_at is removed from the world.""" + from mudlib.corpse import active_corpses, process_decomposing + + corpse = Corpse( + name="goblin's corpse", + location=test_zone, + x=5, + y=10, + decompose_at=time.monotonic() - 1, # already expired + ) + active_corpses.append(corpse) + + await process_decomposing() + + assert corpse not in active_corpses + assert corpse.location is None + assert corpse not in test_zone._contents + + @pytest.mark.asyncio + async def test_unexpired_corpse_stays(self, test_zone): + """Corpse before its decompose_at stays in the world.""" + from mudlib.corpse import active_corpses, process_decomposing + + corpse = Corpse( + name="goblin's corpse", + location=test_zone, + x=5, + y=10, + decompose_at=time.monotonic() + 300, # far future + ) + active_corpses.append(corpse) + + await process_decomposing() + + assert corpse in active_corpses + assert corpse.location is test_zone + + @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 mudlib.corpse import active_corpses, process_decomposing + from mudlib.entity import Entity + + # Create entity at same position + entity = Entity(name="hero", x=5, y=10, location=test_zone) + entity.send = AsyncMock() + + corpse = Corpse( + name="goblin's corpse", + location=test_zone, + x=5, + y=10, + decompose_at=time.monotonic() - 1, + ) + active_corpses.append(corpse) + + await process_decomposing() + + entity.send.assert_called_once_with("goblin's corpse decomposes.\r\n") + + @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 mudlib.corpse import active_corpses, process_decomposing + from mudlib.entity import Entity + + # Entity at different position + entity = Entity(name="faraway", x=50, y=50, location=test_zone) + entity.send = AsyncMock() + + corpse = Corpse( + name="goblin's corpse", + location=test_zone, + x=5, + y=10, + decompose_at=time.monotonic() - 1, + ) + active_corpses.append(corpse) + + await process_decomposing() + + entity.send.assert_not_called() + + def test_create_corpse_registers_in_active_corpses(self, goblin_mob, test_zone): + """create_corpse adds corpse to active_corpses list.""" + from mudlib.corpse import active_corpses + + corpse = create_corpse(goblin_mob, test_zone) + + assert corpse in active_corpses + + @pytest.mark.asyncio + async def test_items_in_decomposed_corpse_are_lost(self, test_zone): + """Items inside a corpse when it decomposes are removed with it.""" + from mudlib.corpse import active_corpses, process_decomposing + + corpse = Corpse( + name="goblin's corpse", + location=test_zone, + x=5, + y=10, + decompose_at=time.monotonic() - 1, + ) + _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