"""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 class TestCombatDeathCorpse: """Tests for corpse spawning when a mob dies in combat.""" @pytest.mark.asyncio async def test_mob_death_in_combat_spawns_corpse(self, test_zone): """Mob death in combat spawns a corpse at mob's position.""" from mudlib.combat.encounter import CombatState from mudlib.combat.engine import ( active_encounters, process_combat, start_encounter, ) from mudlib.combat.moves import CombatMove from mudlib.entity import Entity # Clear active encounters active_encounters.clear() # Create a weak mob mob = Mob( name="goblin", x=5, y=10, location=test_zone, pl=1.0, stamina=40.0, ) mobs.append(mob) # Create attacker attacker = Entity( name="hero", x=5, y=10, location=test_zone, pl=100.0, stamina=50.0, ) # Start encounter encounter = start_encounter(attacker, mob) # Set up a lethal move punch = CombatMove( name="punch right", move_type="attack", stamina_cost=5.0, timing_window_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) encounter.attack(punch) encounter.state = CombatState.RESOLVE # Process combat to trigger resolve await process_combat() # Check for corpse at mob's position corpses = [ obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse) ] assert len(corpses) == 1 assert corpses[0].name == "goblin's corpse" @pytest.mark.asyncio async def test_mob_death_transfers_inventory_to_corpse(self, test_zone, sword): """Mob death transfers inventory to corpse.""" from mudlib.combat.encounter import CombatState from mudlib.combat.engine import ( active_encounters, process_combat, start_encounter, ) from mudlib.combat.moves import CombatMove from mudlib.entity import Entity # Clear active encounters active_encounters.clear() # Create a weak mob with inventory mob = Mob( name="goblin", x=5, y=10, location=test_zone, pl=1.0, stamina=40.0, ) mobs.append(mob) sword.move_to(mob) # Create attacker attacker = Entity( name="hero", x=5, y=10, location=test_zone, pl=100.0, stamina=50.0, ) # Start encounter and kill mob encounter = start_encounter(attacker, mob) punch = CombatMove( name="punch right", move_type="attack", stamina_cost=5.0, timing_window_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) encounter.attack(punch) encounter.state = CombatState.RESOLVE # Process combat await process_combat() # Find corpse corpses = [ obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse) ] assert len(corpses) == 1 corpse = corpses[0] # Verify sword is in corpse assert sword in corpse._contents assert sword.location is corpse @pytest.mark.asyncio async def test_corpse_appears_in_zone_contents(self, test_zone): """Corpse appears in zone.contents_at after mob death.""" from mudlib.combat.encounter import CombatState from mudlib.combat.engine import ( active_encounters, process_combat, start_encounter, ) from mudlib.combat.moves import CombatMove from mudlib.entity import Entity # Clear active encounters active_encounters.clear() # Create a weak mob mob = Mob( name="goblin", x=5, y=10, location=test_zone, pl=1.0, stamina=40.0, ) mobs.append(mob) # Create attacker attacker = Entity( name="hero", x=5, y=10, location=test_zone, pl=100.0, stamina=50.0, ) # Start encounter and kill mob encounter = start_encounter(attacker, mob) punch = CombatMove( name="punch right", move_type="attack", stamina_cost=5.0, timing_window_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) encounter.attack(punch) encounter.state = CombatState.RESOLVE # Process combat await process_combat() # Verify corpse is in zone contents contents = list(test_zone.contents_at(5, 10)) corpse_count = sum(1 for obj in contents if isinstance(obj, Corpse)) assert corpse_count == 1 # Verify it's the goblin's corpse corpse = next(obj for obj in contents if isinstance(obj, Corpse)) assert corpse.name == "goblin's corpse"