mud/tests/test_corpse.py

521 lines
16 KiB
Python

"""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"
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