Add decomposition timer with broadcast and game loop integration
This commit is contained in:
parent
0fbd63a1f7
commit
189f8ac273
2 changed files with 71 additions and 18 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue