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.mobs import despawn_mob
from mudlib.zone import Zone from mudlib.zone import Zone
# Module-level registry of active corpses
active_corpses: list[Corpse] = []
@dataclass(eq=False) @dataclass(eq=False)
class Corpse(Container): class Corpse(Container):
@ -52,4 +55,27 @@ def create_corpse(mob: Mob, zone: Zone, ttl: int = 300) -> Corpse:
# Remove mob from world # Remove mob from world
despawn_mob(mob) despawn_mob(mob)
# Register corpse for decomposition tracking
active_corpses.append(corpse)
return 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.commands import register_combat_commands
from mudlib.combat.engine import process_combat from mudlib.combat.engine import process_combat
from mudlib.content import load_commands from mudlib.content import load_commands
from mudlib.corpse import process_decomposing
from mudlib.effects import clear_expired from mudlib.effects import clear_expired
from mudlib.gmcp import ( from mudlib.gmcp import (
send_char_status, send_char_status,
@ -101,6 +102,7 @@ async def game_loop() -> None:
await process_mobs(mudlib.combat.commands.combat_moves) await process_mobs(mudlib.combat.commands.combat_moves)
await process_resting() await process_resting()
await process_unconscious() await process_unconscious()
await process_decomposing()
# MSDP updates once per second (every TICK_RATE ticks) # MSDP updates once per second (every TICK_RATE ticks)
if tick_count % TICK_RATE == 0: if tick_count % TICK_RATE == 0:

View file

@ -519,3 +519,132 @@ class TestCorpseDisplay:
# Both corpses should appear # Both corpses should appear
assert "goblin's corpse is here." in output assert "goblin's corpse is here." in output
assert "orc'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