process_decomposing removes expired corpses and broadcasts messages to entities at the same tile. Registered in game loop.
650 lines
20 KiB
Python
650 lines
20 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
|
|
|
|
|
|
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
|
|
|
|
from mudlib.corpse import active_corpses, process_decomposing
|
|
from mudlib.entity import Entity
|
|
|
|
# Create entity at same position
|
|
entity = Entity(name="hero", x=5, y=10, location=test_zone)
|
|
entity.send = AsyncMock()
|
|
|
|
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()
|
|
|
|
entity.send.assert_called_once_with("goblin's corpse decomposes.\r\n")
|
|
|
|
@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
|
|
|
|
from mudlib.corpse import active_corpses, process_decomposing
|
|
from mudlib.entity import Entity
|
|
|
|
# Entity at different position
|
|
entity = Entity(name="faraway", x=50, y=50, location=test_zone)
|
|
entity.send = AsyncMock()
|
|
|
|
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()
|
|
|
|
entity.send.assert_not_called()
|
|
|
|
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's location is still the corpse (orphaned)
|
|
# This is expected — items rot with the corpse
|