Add decomposition timer with broadcast and game loop integration

This commit is contained in:
Jared Miller 2026-02-14 10:03:43 -05:00
parent 0fbd63a1f7
commit 189f8ac273
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 71 additions and 18 deletions

View file

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

View file

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