Add Corpse class and create_corpse factory
Corpse is a non-portable Container subclass that holds a deceased mob's inventory. The create_corpse factory transfers items from the mob to the corpse, sets a decompose_at timestamp for eventual cleanup, and calls despawn_mob to remove the mob from the world.
This commit is contained in:
parent
4878f39124
commit
4f487d5178
2 changed files with 273 additions and 0 deletions
55
src/mudlib/corpse.py
Normal file
55
src/mudlib/corpse.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Corpse — a container left behind when an entity dies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from mudlib.container import Container
|
||||
from mudlib.entity import Mob
|
||||
from mudlib.mobs import despawn_mob
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class Corpse(Container):
|
||||
"""A corpse left behind when an entity dies.
|
||||
|
||||
Corpses are containers that hold the deceased entity's inventory.
|
||||
They are not portable (can't be picked up) and are always open.
|
||||
They have a decompose_at timestamp for eventual cleanup.
|
||||
"""
|
||||
|
||||
portable: bool = False
|
||||
closed: bool = False
|
||||
decompose_at: float = 0.0
|
||||
|
||||
|
||||
def create_corpse(mob: Mob, zone: Zone, ttl: int = 300) -> 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)
|
||||
|
||||
Returns:
|
||||
The created corpse with the mob's inventory
|
||||
"""
|
||||
# Create corpse at mob's position
|
||||
corpse = Corpse(
|
||||
name=f"{mob.name}'s corpse",
|
||||
location=zone,
|
||||
x=mob.x,
|
||||
y=mob.y,
|
||||
decompose_at=time.monotonic() + ttl,
|
||||
)
|
||||
|
||||
# Transfer mob's inventory to corpse
|
||||
for item in list(mob._contents):
|
||||
item.move_to(corpse)
|
||||
|
||||
# Remove mob from world
|
||||
despawn_mob(mob)
|
||||
|
||||
return corpse
|
||||
218
tests/test_corpse.py
Normal file
218
tests/test_corpse.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""Tests for Corpse class and create_corpse factory."""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.container import Container
|
||||
from mudlib.corpse import Corpse, create_corpse
|
||||
from mudlib.entity import Mob
|
||||
from mudlib.mobs import mobs
|
||||
from mudlib.thing import Thing
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_mobs():
|
||||
"""Clear mobs registry before and after each test."""
|
||||
mobs.clear()
|
||||
yield
|
||||
mobs.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_zone():
|
||||
"""Create a test zone for entities."""
|
||||
terrain = [["." for _ in range(256)] for _ in range(256)]
|
||||
zone = Zone(
|
||||
name="testzone",
|
||||
width=256,
|
||||
height=256,
|
||||
toroidal=True,
|
||||
terrain=terrain,
|
||||
impassable=set(),
|
||||
)
|
||||
return zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def goblin_mob(test_zone):
|
||||
"""Create a goblin mob at (5, 10) in the test zone."""
|
||||
mob = Mob(
|
||||
name="goblin",
|
||||
x=5,
|
||||
y=10,
|
||||
location=test_zone,
|
||||
pl=50.0,
|
||||
stamina=40.0,
|
||||
)
|
||||
mobs.append(mob)
|
||||
return mob
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sword():
|
||||
"""Create a sword Thing."""
|
||||
return Thing(name="sword", description="a rusty sword", portable=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def potion():
|
||||
"""Create a potion Thing."""
|
||||
return Thing(name="potion", description="a health potion", portable=True)
|
||||
|
||||
|
||||
class TestCorpseClass:
|
||||
def test_corpse_is_container_subclass(self):
|
||||
"""Corpse is a subclass of Container."""
|
||||
assert issubclass(Corpse, Container)
|
||||
|
||||
def test_corpse_not_portable(self, test_zone):
|
||||
"""Corpse is not portable (can't pick up a corpse)."""
|
||||
corpse = Corpse(name="test corpse", location=test_zone, x=0, y=0)
|
||||
assert corpse.portable is False
|
||||
|
||||
def test_corpse_always_open(self, test_zone):
|
||||
"""Corpse is always open (closed=False)."""
|
||||
corpse = Corpse(name="test corpse", location=test_zone, x=0, y=0)
|
||||
assert corpse.closed is False
|
||||
|
||||
def test_corpse_has_decompose_at_field(self, test_zone):
|
||||
"""Corpse has decompose_at field (float, monotonic time)."""
|
||||
decompose_time = time.monotonic() + 300
|
||||
corpse = Corpse(
|
||||
name="test corpse",
|
||||
location=test_zone,
|
||||
x=0,
|
||||
y=0,
|
||||
decompose_at=decompose_time,
|
||||
)
|
||||
assert hasattr(corpse, "decompose_at")
|
||||
assert isinstance(corpse.decompose_at, float)
|
||||
assert corpse.decompose_at == decompose_time
|
||||
|
||||
|
||||
class TestCreateCorpseFactory:
|
||||
def test_creates_corpse_at_mob_position(self, goblin_mob, test_zone):
|
||||
"""create_corpse creates a corpse at mob's x, y in the zone."""
|
||||
corpse = create_corpse(goblin_mob, test_zone)
|
||||
|
||||
assert isinstance(corpse, Corpse)
|
||||
assert corpse.x == 5
|
||||
assert corpse.y == 10
|
||||
assert corpse.location is test_zone
|
||||
|
||||
def test_corpse_name_from_mob(self, goblin_mob, test_zone):
|
||||
"""Corpse name is '{mob.name}'s corpse'."""
|
||||
corpse = create_corpse(goblin_mob, test_zone)
|
||||
|
||||
assert corpse.name == "goblin's corpse"
|
||||
|
||||
def test_transfers_mob_inventory(self, goblin_mob, test_zone, sword, potion):
|
||||
"""Transfers mob's inventory items into the corpse."""
|
||||
# Add items to mob's inventory
|
||||
sword.move_to(goblin_mob)
|
||||
potion.move_to(goblin_mob)
|
||||
|
||||
corpse = create_corpse(goblin_mob, test_zone)
|
||||
|
||||
# Items should now be in the corpse
|
||||
assert sword in corpse._contents
|
||||
assert potion in corpse._contents
|
||||
assert sword.location is corpse
|
||||
assert potion.location is corpse
|
||||
|
||||
def test_sets_decompose_at(self, goblin_mob, test_zone):
|
||||
"""Sets decompose_at = time.monotonic() + ttl."""
|
||||
before = time.monotonic()
|
||||
corpse = create_corpse(goblin_mob, test_zone, ttl=300)
|
||||
after = time.monotonic()
|
||||
|
||||
# decompose_at should be within reasonable range
|
||||
assert corpse.decompose_at >= before + 300
|
||||
assert corpse.decompose_at <= after + 300
|
||||
|
||||
def test_custom_ttl(self, goblin_mob, test_zone):
|
||||
"""create_corpse respects custom ttl parameter."""
|
||||
before = time.monotonic()
|
||||
corpse = create_corpse(goblin_mob, test_zone, ttl=600)
|
||||
after = time.monotonic()
|
||||
|
||||
assert corpse.decompose_at >= before + 600
|
||||
assert corpse.decompose_at <= after + 600
|
||||
|
||||
def test_calls_despawn_mob(self, goblin_mob, test_zone):
|
||||
"""create_corpse calls despawn_mob to remove mob from registry."""
|
||||
assert goblin_mob in mobs
|
||||
assert goblin_mob.alive is True
|
||||
|
||||
create_corpse(goblin_mob, test_zone)
|
||||
|
||||
assert goblin_mob not in mobs
|
||||
assert goblin_mob.alive is False
|
||||
|
||||
def test_returns_corpse(self, goblin_mob, test_zone):
|
||||
"""create_corpse returns the created corpse."""
|
||||
corpse = create_corpse(goblin_mob, test_zone)
|
||||
|
||||
assert isinstance(corpse, Corpse)
|
||||
assert corpse.name == "goblin's corpse"
|
||||
|
||||
def test_empty_inventory(self, goblin_mob, test_zone):
|
||||
"""create_corpse with a mob that has no inventory creates empty corpse."""
|
||||
corpse = create_corpse(goblin_mob, test_zone)
|
||||
|
||||
assert len(corpse._contents) == 0
|
||||
assert corpse.name == "goblin's corpse"
|
||||
|
||||
|
||||
class TestCorpseAsContainer:
|
||||
def test_can_put_things_in_corpse(self, goblin_mob, test_zone, sword):
|
||||
"""Container commands work: Things can be put into corpses."""
|
||||
corpse = create_corpse(goblin_mob, test_zone)
|
||||
|
||||
# Manually move item into corpse (simulating "put" command)
|
||||
sword.move_to(corpse)
|
||||
|
||||
assert sword in corpse._contents
|
||||
assert sword.location is corpse
|
||||
|
||||
def test_can_take_things_from_corpse(self, goblin_mob, test_zone, sword, potion):
|
||||
"""Container commands work: Things can be taken out of corpses."""
|
||||
sword.move_to(goblin_mob)
|
||||
potion.move_to(goblin_mob)
|
||||
|
||||
corpse = create_corpse(goblin_mob, test_zone)
|
||||
|
||||
# Items start in corpse
|
||||
assert sword in corpse._contents
|
||||
assert potion in corpse._contents
|
||||
|
||||
# Take sword out (move to zone)
|
||||
sword.move_to(test_zone, x=5, y=10)
|
||||
|
||||
assert sword not in corpse._contents
|
||||
assert sword.location is test_zone
|
||||
assert potion in corpse._contents
|
||||
|
||||
def test_corpse_can_accept_things(self, goblin_mob, test_zone, sword):
|
||||
"""Corpse.can_accept returns True for Things."""
|
||||
corpse = create_corpse(goblin_mob, test_zone)
|
||||
|
||||
assert corpse.can_accept(sword) is True
|
||||
|
||||
def test_corpse_not_portable(self, goblin_mob, test_zone):
|
||||
"""Corpse cannot be picked up (portable=False)."""
|
||||
corpse = create_corpse(goblin_mob, test_zone)
|
||||
|
||||
# Try to move corpse to a mock entity (simulating "get corpse")
|
||||
mock_entity = MagicMock()
|
||||
mock_entity._contents = []
|
||||
|
||||
# Corpse is not portable, so entity.can_accept would return False
|
||||
# (Entity.can_accept checks obj.portable)
|
||||
from mudlib.entity import Entity
|
||||
|
||||
dummy_entity = Entity(name="dummy", x=0, y=0, location=test_zone)
|
||||
assert dummy_entity.can_accept(corpse) is False
|
||||
Loading…
Reference in a new issue