"""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.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_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" class TestCorpseDisplay: """Tests for corpse display in look command.""" @pytest.fixture def player(self, test_zone): """Create a test player.""" from unittest.mock import AsyncMock, MagicMock from mudlib.player import Player, players writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() reader = MagicMock() p = Player( name="TestPlayer", x=5, y=10, reader=reader, writer=writer, ) p.location = test_zone test_zone._contents.append(p) players[p.name] = p yield p players.clear() @pytest.mark.asyncio async def test_corpse_shown_in_look(self, player, test_zone): """Corpse appears as 'X is here.' in look output.""" from mudlib.commands.look import cmd_look # Create a corpse on player's tile Corpse( name="goblin's corpse", location=test_zone, x=5, y=10, decompose_at=time.monotonic() + 300, ) await cmd_look(player, "") # Check output for corpse line output = "".join( call.args[0] for call in player.writer.write.call_args_list if call.args ) assert "goblin's corpse is here." in output @pytest.mark.asyncio async def test_corpse_not_in_ground_items(self, player, test_zone): """Corpse is NOT in 'On the ground:' list, but regular items are.""" from mudlib.commands.look import cmd_look # Create a corpse and a regular item on player's tile Corpse( name="goblin's corpse", location=test_zone, x=5, y=10, decompose_at=time.monotonic() + 300, ) Thing(name="sword", location=test_zone, x=5, y=10) await cmd_look(player, "") output = "".join( call.args[0] for call in player.writer.write.call_args_list if call.args ) # Corpse should be shown as "is here", not in ground items assert "goblin's corpse is here." in output # Sword should be in ground items assert "On the ground:" in output assert "sword" in output # Corpse name should NOT appear in the ground items line lines = output.split("\r\n") ground_line = next((line for line in lines if "On the ground:" in line), None) assert ground_line is not None assert "goblin's corpse" not in ground_line @pytest.mark.asyncio async def test_multiple_corpses(self, player, test_zone): """Multiple corpses each show as separate 'X is here.' lines.""" from mudlib.commands.look import cmd_look # Create two corpses on player's tile Corpse( name="goblin's corpse", location=test_zone, x=5, y=10, decompose_at=time.monotonic() + 300, ) Corpse( name="orc's corpse", location=test_zone, x=5, y=10, decompose_at=time.monotonic() + 300, ) await cmd_look(player, "") output = "".join( call.args[0] for call in player.writer.write.call_args_list if call.args ) # Both corpses should appear assert "goblin'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, MagicMock from mudlib.corpse import active_corpses, process_decomposing from mudlib.player import Player # Create player at same position writer = MagicMock() 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( name="goblin's corpse", location=test_zone, x=5, y=10, decompose_at=time.monotonic() - 1, ) active_corpses.append(corpse) await process_decomposing() # 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 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, MagicMock from mudlib.corpse import active_corpses, process_decomposing from mudlib.player import Player # Player at different position writer = MagicMock() 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( name="goblin's corpse", location=test_zone, x=5, y=10, decompose_at=time.monotonic() - 1, ) active_corpses.append(corpse) await process_decomposing() # 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): """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 should also be removed (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