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.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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue