"""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")