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:
parent
68f8c64cf3
commit
56169a5ed6
3 changed files with 157 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue