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.container import Container
from mudlib.entity import Mob from mudlib.entity import Mob
from mudlib.loot import LootEntry, roll_loot
from mudlib.mobs import despawn_mob from mudlib.mobs import despawn_mob
from mudlib.zone import Zone from mudlib.zone import Zone
@ -28,16 +29,19 @@ class Corpse(Container):
decompose_at: float = 0.0 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. """Create a corpse from a defeated mob.
Args: Args:
mob: The mob that died mob: The mob that died
zone: The zone where the corpse will be placed zone: The zone where the corpse will be placed
ttl: Time to live in seconds (default 300) ttl: Time to live in seconds (default 300)
loot_table: Optional loot table to roll for additional items
Returns: 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 # Create corpse at mob's position
corpse = Corpse( corpse = Corpse(
@ -52,6 +56,11 @@ def create_corpse(mob: Mob, zone: Zone, ttl: int = 300) -> Corpse:
for item in list(mob._contents): for item in list(mob._contents):
item.move_to(corpse) 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 # Remove mob from world
despawn_mob(mob) despawn_mob(mob)
@ -69,13 +78,16 @@ async def process_decomposing() -> None:
if now >= corpse.decompose_at: if now >= corpse.decompose_at:
# Broadcast to entities at the same tile # Broadcast to entities at the same tile
zone = corpse.location 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 from mudlib.entity import Entity
for obj in zone.contents_at(corpse.x, corpse.y): for obj in zone.contents_at(corpse.x, corpse.y):
if isinstance(obj, Entity): if isinstance(obj, Entity):
await obj.send(f"{corpse.name} decomposes.\r\n") 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 # Remove corpse from world
corpse.move_to(None) corpse.move_to(None)
active_corpses.remove(corpse) active_corpses.remove(corpse)

View file

@ -221,6 +221,14 @@ class TestCorpseAsContainer:
class TestCombatDeathCorpse: class TestCombatDeathCorpse:
"""Tests for corpse spawning when a mob dies in combat.""" """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 @pytest.mark.asyncio
async def test_mob_death_in_combat_spawns_corpse(self, test_zone): async def test_mob_death_in_combat_spawns_corpse(self, test_zone):
"""Mob death in combat spawns a corpse at mob's position.""" """Mob death in combat spawns a corpse at mob's position."""
@ -572,14 +580,19 @@ class TestDecomposition:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_decomposition_broadcasts_message(self, test_zone): async def test_decomposition_broadcasts_message(self, test_zone):
"""Decomposition broadcasts to entities at the same tile.""" """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.corpse import active_corpses, process_decomposing
from mudlib.entity import Entity from mudlib.player import Player
# Create entity at same position # Create player at same position
entity = Entity(name="hero", x=5, y=10, location=test_zone) writer = MagicMock()
entity.send = AsyncMock() 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( corpse = Corpse(
name="goblin's corpse", name="goblin's corpse",
@ -592,19 +605,26 @@ class TestDecomposition:
await process_decomposing() 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 @pytest.mark.asyncio
async def test_decomposition_only_broadcasts_to_same_tile(self, test_zone): async def test_decomposition_only_broadcasts_to_same_tile(self, test_zone):
"""Decomposition does NOT broadcast to entities at different tiles.""" """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.corpse import active_corpses, process_decomposing
from mudlib.entity import Entity from mudlib.player import Player
# Entity at different position # Player at different position
entity = Entity(name="faraway", x=50, y=50, location=test_zone) writer = MagicMock()
entity.send = AsyncMock() 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( corpse = Corpse(
name="goblin's corpse", name="goblin's corpse",
@ -617,7 +637,9 @@ class TestDecomposition:
await process_decomposing() 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): def test_create_corpse_registers_in_active_corpses(self, goblin_mob, test_zone):
"""create_corpse adds corpse to active_corpses list.""" """create_corpse adds corpse to active_corpses list."""
@ -639,12 +661,31 @@ class TestDecomposition:
y=10, y=10,
decompose_at=time.monotonic() - 1, decompose_at=time.monotonic() - 1,
) )
_sword = Thing(name="sword", location=corpse) sword = Thing(name="sword", location=corpse)
active_corpses.append(corpse) active_corpses.append(corpse)
await process_decomposing() await process_decomposing()
# Corpse is gone # Corpse is gone
assert corpse.location is None assert corpse.location is None
# Sword's location is still the corpse (orphaned) # Sword should also be removed (items rot with the corpse)
# This is expected — 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