Removed 32 tests that only verified constructor args are stored as properties. Type annotations and behavioral tests already cover this.
694 lines
21 KiB
Python
694 lines
21 KiB
Python
"""Tests for Corpse class and create_corpse factory."""
|
|
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
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 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:
|
|
"""Knockouts do not create corpses until a finisher is used."""
|
|
|
|
@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_knockout_in_combat_does_not_spawn_corpse(self, test_zone):
|
|
"""KO in combat should not create a corpse by itself."""
|
|
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,
|
|
hit_time_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 no corpse spawned yet
|
|
corpses = [
|
|
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
|
|
]
|
|
assert len(corpses) == 0
|
|
assert mob in mobs
|
|
assert mob.alive is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mob_knockout_keeps_inventory_on_mob(self, test_zone, sword):
|
|
"""KO should not transfer inventory to a corpse until finished."""
|
|
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,
|
|
hit_time_ms=800,
|
|
damage_pct=0.15,
|
|
countered_by=["dodge left"],
|
|
)
|
|
encounter.attack(punch)
|
|
encounter.state = CombatState.RESOLVE
|
|
|
|
# Process combat
|
|
await process_combat()
|
|
|
|
# No corpse yet
|
|
corpses = [
|
|
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
|
|
]
|
|
assert len(corpses) == 0
|
|
assert sword in mob._contents
|
|
assert sword.location is mob
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_corpse_in_zone_contents_after_ko(self, test_zone):
|
|
"""Zone should not contain corpse from a plain KO."""
|
|
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,
|
|
hit_time_ms=800,
|
|
damage_pct=0.15,
|
|
countered_by=["dodge left"],
|
|
)
|
|
encounter.attack(punch)
|
|
encounter.state = CombatState.RESOLVE
|
|
|
|
# Process combat
|
|
await process_combat()
|
|
|
|
# Verify no corpse 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 == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapneck_finisher_spawns_corpse(self, test_zone):
|
|
"""Explicit finisher kill should create a corpse."""
|
|
from unittest.mock import AsyncMock
|
|
|
|
from mudlib.commands.snapneck import cmd_snap_neck
|
|
from mudlib.player import Player, players
|
|
|
|
writer = MagicMock()
|
|
writer.write = MagicMock()
|
|
writer.drain = AsyncMock()
|
|
reader = MagicMock()
|
|
attacker = Player(name="hero", x=5, y=10, reader=reader, writer=writer)
|
|
attacker.location = test_zone
|
|
test_zone._contents.append(attacker)
|
|
players[attacker.name] = attacker
|
|
|
|
mob = Mob(
|
|
name="goblin",
|
|
x=5,
|
|
y=10,
|
|
location=test_zone,
|
|
pl=0.0,
|
|
stamina=0.0,
|
|
)
|
|
mobs.append(mob)
|
|
|
|
from mudlib.combat.engine import start_encounter
|
|
|
|
start_encounter(attacker, mob)
|
|
attacker.mode_stack.append("combat")
|
|
await cmd_snap_neck(attacker, "goblin")
|
|
|
|
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"
|
|
players.clear()
|
|
|
|
|
|
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
|