Phase 2: do_attack now searches the mobs registry after players dict when resolving a target name. Players always take priority over mobs with the same name. World instance injected into combat/commands module for wrapping-aware mob proximity checks.
303 lines
9.1 KiB
Python
303 lines
9.1 KiB
Python
"""Tests for mob templates, registry, spawn/despawn, and combat integration."""
|
|
|
|
import tomllib
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
import mudlib.commands.movement as movement_mod
|
|
from mudlib.combat import commands as combat_commands
|
|
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 (
|
|
MobTemplate,
|
|
despawn_mob,
|
|
get_nearby_mob,
|
|
load_mob_template,
|
|
load_mob_templates,
|
|
mobs,
|
|
spawn_mob,
|
|
)
|
|
from mudlib.player import Player, players
|
|
|
|
|
|
@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(autouse=True)
|
|
def mock_world():
|
|
"""Inject a mock world for movement and combat commands."""
|
|
fake_world = MagicMock()
|
|
fake_world.width = 256
|
|
fake_world.height = 256
|
|
old_movement = movement_mod.world
|
|
old_combat = combat_commands.world
|
|
movement_mod.world = fake_world
|
|
combat_commands.world = fake_world
|
|
yield fake_world
|
|
movement_mod.world = old_movement
|
|
combat_commands.world = old_combat
|
|
|
|
|
|
@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):
|
|
template = load_mob_template(goblin_toml)
|
|
mob = spawn_mob(template, 10, 20)
|
|
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
|
|
|
|
def test_spawn_adds_to_registry(self, goblin_toml):
|
|
template = load_mob_template(goblin_toml)
|
|
spawn_mob(template, 0, 0)
|
|
spawn_mob(template, 5, 5)
|
|
assert len(mobs) == 2
|
|
|
|
def test_despawn_removes_from_list(self, goblin_toml):
|
|
template = load_mob_template(goblin_toml)
|
|
mob = spawn_mob(template, 0, 0)
|
|
despawn_mob(mob)
|
|
assert mob not in mobs
|
|
assert mob.alive is False
|
|
|
|
def test_despawn_sets_alive_false(self, goblin_toml):
|
|
template = load_mob_template(goblin_toml)
|
|
mob = spawn_mob(template, 0, 0)
|
|
despawn_mob(mob)
|
|
assert mob.alive is False
|
|
|
|
|
|
class TestGetNearbyMob:
|
|
@pytest.fixture
|
|
def mock_world(self):
|
|
w = MagicMock()
|
|
w.width = 256
|
|
w.height = 256
|
|
return w
|
|
|
|
def test_finds_by_name_within_range(self, goblin_toml, mock_world):
|
|
template = load_mob_template(goblin_toml)
|
|
mob = spawn_mob(template, 5, 5)
|
|
found = get_nearby_mob("goblin", 3, 3, mock_world)
|
|
assert found is mob
|
|
|
|
def test_returns_none_when_out_of_range(self, goblin_toml, mock_world):
|
|
template = load_mob_template(goblin_toml)
|
|
spawn_mob(template, 100, 100)
|
|
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
|
assert found is None
|
|
|
|
def test_returns_none_for_wrong_name(self, goblin_toml, mock_world):
|
|
template = load_mob_template(goblin_toml)
|
|
spawn_mob(template, 5, 5)
|
|
found = get_nearby_mob("dragon", 3, 3, mock_world)
|
|
assert found is None
|
|
|
|
def test_picks_closest_when_multiple(self, goblin_toml, mock_world):
|
|
template = load_mob_template(goblin_toml)
|
|
far_mob = spawn_mob(template, 8, 8)
|
|
close_mob = spawn_mob(template, 1, 1)
|
|
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
|
assert found is close_mob
|
|
|
|
def test_skips_dead_mobs(self, goblin_toml, mock_world):
|
|
template = load_mob_template(goblin_toml)
|
|
mob = spawn_mob(template, 5, 5)
|
|
mob.alive = False
|
|
found = get_nearby_mob("goblin", 3, 3, mock_world)
|
|
assert found is None
|
|
|
|
def test_wrapping_distance(self, goblin_toml, mock_world):
|
|
"""Mob near world edge is close to player at opposite edge."""
|
|
template = load_mob_template(goblin_toml)
|
|
mob = spawn_mob(template, 254, 254)
|
|
found = get_nearby_mob("goblin", 2, 2, mock_world, 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):
|
|
p = Player(
|
|
name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer
|
|
)
|
|
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
|
|
):
|
|
"""do_attack with mob name finds and engages the mob."""
|
|
template = load_mob_template(goblin_toml)
|
|
mob = spawn_mob(template, 0, 0)
|
|
|
|
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
|
|
):
|
|
"""When a player and mob share a name, player takes priority."""
|
|
template = load_mob_template(goblin_toml)
|
|
spawn_mob(template, 0, 0)
|
|
|
|
# Create a player named "goblin"
|
|
goblin_player = Player(
|
|
name="goblin",
|
|
x=0,
|
|
y=0,
|
|
reader=mock_reader,
|
|
writer=mock_writer,
|
|
)
|
|
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
|
|
):
|
|
"""Mob outside viewport range is not found as target."""
|
|
template = load_mob_template(goblin_toml)
|
|
spawn_mob(template, 100, 100)
|
|
|
|
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
|
|
):
|
|
"""Mob doesn't get mode_stack push (it has no mode_stack)."""
|
|
template = load_mob_template(goblin_toml)
|
|
mob = spawn_mob(template, 0, 0)
|
|
|
|
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")
|