mud/tests/test_mobs.py

467 lines
15 KiB
Python

"""Tests for mob templates, registry, spawn/despawn, and combat integration."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.combat import commands as combat_commands
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import active_encounters, get_encounter
from mudlib.combat.moves import load_moves
from mudlib.entity import Mob
from mudlib.mobs import (
despawn_mob,
get_nearby_mob,
load_mob_template,
load_mob_templates,
mobs,
spawn_mob,
)
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_state():
"""Clear mobs, encounters, and players before and after each test."""
mobs.clear()
active_encounters.clear()
players.clear()
yield
mobs.clear()
active_encounters.clear()
players.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_toml(tmp_path):
"""Create a goblin TOML file."""
path = tmp_path / "goblin.toml"
path.write_text(
'name = "goblin"\n'
'description = "a snarling goblin with a crude club"\n'
"pl = 50.0\n"
"stamina = 40.0\n"
"max_stamina = 40.0\n"
'moves = ["punch left", "punch right", "sweep"]\n'
)
return path
@pytest.fixture
def dummy_toml(tmp_path):
"""Create a training dummy TOML file."""
path = tmp_path / "training_dummy.toml"
path.write_text(
'name = "training dummy"\n'
'description = "a battered wooden training dummy"\n'
"pl = 200.0\n"
"stamina = 100.0\n"
"max_stamina = 100.0\n"
"moves = []\n"
)
return path
class TestLoadTemplate:
def test_load_single_template(self, goblin_toml):
template = load_mob_template(goblin_toml)
assert template.name == "goblin"
assert template.description == "a snarling goblin with a crude club"
assert template.pl == 50.0
assert template.stamina == 40.0
assert template.max_stamina == 40.0
assert template.moves == ["punch left", "punch right", "sweep"]
def test_load_template_no_moves(self, dummy_toml):
template = load_mob_template(dummy_toml)
assert template.name == "training dummy"
assert template.moves == []
def test_load_all_templates(self, goblin_toml, dummy_toml):
templates = load_mob_templates(goblin_toml.parent)
assert "goblin" in templates
assert "training dummy" in templates
assert len(templates) == 2
class TestSpawnDespawn:
def test_spawn_creates_mob(self, goblin_toml, test_zone):
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 10, 20, test_zone)
assert isinstance(mob, Mob)
assert mob.name == "goblin"
assert mob.x == 10
assert mob.y == 20
assert mob.pl == 50.0
assert mob.stamina == 40.0
assert mob.max_stamina == 40.0
assert mob.moves == ["punch left", "punch right", "sweep"]
assert mob.alive is True
assert mob in mobs
assert mob.location is test_zone
assert mob in test_zone._contents
def test_spawn_adds_to_registry(self, goblin_toml, test_zone):
template = load_mob_template(goblin_toml)
spawn_mob(template, 0, 0, test_zone)
spawn_mob(template, 5, 5, test_zone)
assert len(mobs) == 2
def test_despawn_removes_from_list(self, goblin_toml, test_zone):
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone)
despawn_mob(mob)
assert mob not in mobs
assert mob.alive is False
def test_despawn_sets_alive_false(self, goblin_toml, test_zone):
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone)
despawn_mob(mob)
assert mob.alive is False
class TestGetNearbyMob:
def test_finds_by_name_within_range(self, goblin_toml, test_zone):
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 5, 5, test_zone)
found = get_nearby_mob("goblin", 3, 3, test_zone)
assert found is mob
def test_returns_none_when_out_of_range(self, goblin_toml, test_zone):
template = load_mob_template(goblin_toml)
spawn_mob(template, 100, 100, test_zone)
found = get_nearby_mob("goblin", 0, 0, test_zone)
assert found is None
def test_returns_none_for_wrong_name(self, goblin_toml, test_zone):
template = load_mob_template(goblin_toml)
spawn_mob(template, 5, 5, test_zone)
found = get_nearby_mob("dragon", 3, 3, test_zone)
assert found is None
def test_picks_closest_when_multiple(self, goblin_toml, test_zone):
template = load_mob_template(goblin_toml)
spawn_mob(template, 8, 8, test_zone)
close_mob = spawn_mob(template, 1, 1, test_zone)
found = get_nearby_mob("goblin", 0, 0, test_zone)
assert found is close_mob
def test_skips_dead_mobs(self, goblin_toml, test_zone):
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 5, 5, test_zone)
mob.alive = False
found = get_nearby_mob("goblin", 3, 3, test_zone)
assert found is None
def test_wrapping_distance(self, goblin_toml, test_zone):
"""Mob near world edge is close to player at opposite edge."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 254, 254, test_zone)
found = get_nearby_mob("goblin", 2, 2, test_zone, range_=10)
assert found is mob
# --- Phase 2: target resolution tests ---
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p
return p
@pytest.fixture
def moves():
"""Load combat moves from content directory."""
content_dir = Path(__file__).parent.parent / "content" / "combat"
return load_moves(content_dir)
@pytest.fixture(autouse=True)
def inject_moves(moves):
"""Inject loaded moves into combat commands module."""
combat_commands.combat_moves = moves
yield
combat_commands.combat_moves = {}
@pytest.fixture
def punch_right(moves):
return moves["punch right"]
class TestTargetResolution:
@pytest.mark.asyncio
async def test_attack_mob_by_name(
self, player, punch_right, goblin_toml, test_zone
):
"""do_attack with mob name finds and engages the mob."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone)
await combat_commands.do_attack(player, "goblin", punch_right)
encounter = get_encounter(player)
assert encounter is not None
assert encounter.attacker is player
assert encounter.defender is mob
@pytest.mark.asyncio
async def test_attack_prefers_player_over_mob(
self, player, punch_right, goblin_toml, mock_reader, mock_writer, test_zone
):
"""When a player and mob share a name, player takes priority."""
template = load_mob_template(goblin_toml)
spawn_mob(template, 0, 0, test_zone)
# Create a player named "goblin"
goblin_player = Player(
name="goblin",
x=0,
y=0,
reader=mock_reader,
writer=mock_writer,
)
goblin_player.location = test_zone
test_zone._contents.append(goblin_player)
players["goblin"] = goblin_player
await combat_commands.do_attack(player, "goblin", punch_right)
encounter = get_encounter(player)
assert encounter is not None
assert encounter.defender is goblin_player
@pytest.mark.asyncio
async def test_attack_mob_out_of_range(
self, player, punch_right, goblin_toml, test_zone
):
"""Mob outside viewport range is not found as target."""
template = load_mob_template(goblin_toml)
spawn_mob(template, 100, 100, test_zone)
await combat_commands.do_attack(player, "goblin", punch_right)
encounter = get_encounter(player)
assert encounter is None
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("need a target" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_encounter_mob_no_mode_push(
self, player, punch_right, goblin_toml, test_zone
):
"""Mob doesn't get mode_stack push (it has no mode_stack)."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone)
await combat_commands.do_attack(player, "goblin", punch_right)
# Player should be in combat mode
assert player.mode == "combat"
# Mob has no mode_stack attribute
assert not hasattr(mob, "mode_stack")
# --- Phase 3: viewport rendering tests ---
class TestViewportRendering:
@pytest.fixture
def look_world(self):
"""A mock world that returns a flat viewport of '.' tiles."""
from mudlib.commands.look import VIEWPORT_HEIGHT, VIEWPORT_WIDTH
w = MagicMock()
w.width = 256
w.height = 256
w.get_viewport = MagicMock(
return_value=[
["." for _ in range(VIEWPORT_WIDTH)] for _ in range(VIEWPORT_HEIGHT)
]
)
w.wrap = lambda x, y: (x % 256, y % 256)
w.is_passable = MagicMock(return_value=True)
return w
@pytest.mark.asyncio
async def test_mob_renders_as_star(
self, player, goblin_toml, look_world, test_zone
):
"""Mob within viewport renders as * in look output."""
import mudlib.commands.look as look_mod
template = load_mob_template(goblin_toml)
# Place mob 2 tiles to the right of the player
spawn_mob(template, 2, 0, test_zone)
await look_mod.cmd_look(player, "")
output = "".join(call[0][0] for call in player.writer.write.call_args_list)
# The center is at (10, 5), mob at relative (12, 5)
# Output should contain a * character
assert "*" in output
@pytest.mark.asyncio
async def test_mob_outside_viewport_not_rendered(
self, player, goblin_toml, look_world, test_zone
):
"""Mob outside viewport bounds is not rendered."""
import mudlib.commands.look as look_mod
template = load_mob_template(goblin_toml)
# Place mob far away
spawn_mob(template, 100, 100, test_zone)
await look_mod.cmd_look(player, "")
output = "".join(call[0][0] for call in player.writer.write.call_args_list)
# Should only have @ (player) and . (terrain), no *
stripped = output.replace("\033[0m", "").replace("\r\n", "")
# Remove ANSI codes for terrain colors
import re
stripped = re.sub(r"\033\[[0-9;]*m", "", stripped)
assert "*" not in stripped
@pytest.mark.asyncio
async def test_dead_mob_not_rendered(
self, player, goblin_toml, look_world, test_zone
):
"""Dead mob (alive=False) not rendered in viewport."""
import mudlib.commands.look as look_mod
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 2, 0, test_zone)
mob.alive = False
await look_mod.cmd_look(player, "")
output = "".join(call[0][0] for call in player.writer.write.call_args_list)
import re
stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace("\r\n", "")
assert "*" not in stripped
# --- Phase 4: mob defeat tests ---
class TestMobDefeat:
@pytest.fixture
def goblin_mob(self, goblin_toml, test_zone):
template = load_mob_template(goblin_toml)
return spawn_mob(template, 0, 0, test_zone)
@pytest.mark.asyncio
async def test_mob_not_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
"""KO does not despawn mob without an explicit finisher."""
from mudlib.combat.engine import process_combat, start_encounter
encounter = start_encounter(player, goblin_mob)
player.mode_stack.append("combat")
# Set mob PL very low so attack kills it
goblin_mob.pl = 1.0
# Attack and force resolution
encounter.attack(punch_right)
encounter.state = CombatState.RESOLVE
await process_combat()
assert goblin_mob in mobs
assert goblin_mob.alive is True
@pytest.mark.asyncio
async def test_player_gets_no_victory_message_on_ko(
self, player, goblin_mob, punch_right
):
"""KO should not be treated as a defeat/kill message."""
from mudlib.combat.engine import process_combat, start_encounter
encounter = start_encounter(player, goblin_mob)
player.mode_stack.append("combat")
goblin_mob.pl = 1.0
encounter.attack(punch_right)
encounter.state = CombatState.RESOLVE
await process_combat()
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert not any("defeated" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_exhaustion_does_not_end_encounter(
self, player, goblin_mob, punch_right
):
"""Attacker exhaustion does not auto-end combat."""
from mudlib.combat.engine import process_combat, start_encounter
encounter = start_encounter(player, goblin_mob)
player.mode_stack.append("combat")
# Drain player stamina before resolve
player.stamina = 0.0
encounter.attack(punch_right)
encounter.state = CombatState.RESOLVE
await process_combat()
assert get_encounter(player) is encounter
@pytest.mark.asyncio
async def test_player_ko_not_despawned(self, player, goblin_mob, punch_right):
"""When player is KO'd, player remains present."""
from mudlib.combat.engine import process_combat, start_encounter
# Mob attacks player — mob is attacker, player is defender
encounter = start_encounter(goblin_mob, player)
player.mode_stack.append("combat")
player.pl = 1.0
encounter.attack(punch_right)
encounter.state = CombatState.RESOLVE
await process_combat()
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert len(messages) > 0
assert not any("defeated" in msg.lower() for msg in messages)
# Player is still in players dict (not removed)
assert player.name in players