Removed identical local copies from 45 test files. These fixtures are already defined in conftest.py.
454 lines
15 KiB
Python
454 lines
15 KiB
Python
"""Tests for mob templates, registry, spawn/despawn, and combat integration."""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import 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 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
|