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