From 4f487d51787eb9624ecc30692e7a34fd349920bf Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 09:54:20 -0500 Subject: [PATCH] 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. --- src/mudlib/corpse.py | 55 +++++++++++ tests/test_corpse.py | 218 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 src/mudlib/corpse.py create mode 100644 tests/test_corpse.py diff --git a/src/mudlib/corpse.py b/src/mudlib/corpse.py new file mode 100644 index 0000000..dc1252f --- /dev/null +++ b/src/mudlib/corpse.py @@ -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 diff --git a/tests/test_corpse.py b/tests/test_corpse.py new file mode 100644 index 0000000..54e705c --- /dev/null +++ b/tests/test_corpse.py @@ -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