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.
This commit is contained in:
Jared Miller 2026-02-14 10:02:33 -05:00
parent 68f8c64cf3
commit 56169a5ed6
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 157 additions and 0 deletions

View file

@ -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)

View file

@ -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:

View file

@ -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