"""Tests for mob AI behavior.""" import time from pathlib import Path 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, start_encounter, ) from mudlib.combat.moves import load_moves from mudlib.mob_ai import process_mobs from mudlib.mobs import ( load_mob_template, 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 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 goblin_toml(tmp_path): 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): 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 TestMobAttackAI: @pytest.mark.asyncio async def test_mob_attacks_when_idle_and_cooldown_expired( self, player, goblin_toml, moves, test_zone ): """Mob attacks when encounter is IDLE and cooldown has expired.""" template = load_mob_template(goblin_toml) mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 # cooldown expired encounter = start_encounter(player, mob) player.mode_stack.append("combat") await process_mobs(moves) # Mob should have attacked — encounter state should be TELEGRAPH assert encounter.state == CombatState.PENDING assert encounter.current_move is not None @pytest.mark.asyncio async def test_mob_picks_from_its_own_moves( self, player, goblin_toml, moves, test_zone ): """Mob only picks moves from its moves list.""" template = load_mob_template(goblin_toml) mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 encounter = start_encounter(player, mob) player.mode_stack.append("combat") await process_mobs(moves) assert encounter.current_move is not None assert encounter.current_move.name in mob.moves @pytest.mark.asyncio async def test_mob_skips_when_stamina_too_low( self, player, goblin_toml, moves, test_zone ): """Mob skips attack when stamina is too low for any move.""" template = load_mob_template(goblin_toml) mob = spawn_mob(template, 0, 0, test_zone) mob.stamina = 0.0 mob.next_action_at = 0.0 encounter = start_encounter(player, mob) player.mode_stack.append("combat") await process_mobs(moves) # Mob can't afford any move, encounter stays IDLE assert encounter.state == CombatState.IDLE @pytest.mark.asyncio async def test_mob_respects_cooldown(self, player, goblin_toml, moves, test_zone): """Mob doesn't act when cooldown hasn't expired.""" template = load_mob_template(goblin_toml) mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = time.monotonic() + 100.0 # far in the future encounter = start_encounter(player, mob) player.mode_stack.append("combat") await process_mobs(moves) # Mob should not have attacked assert encounter.state == CombatState.IDLE @pytest.mark.asyncio async def test_mob_swaps_roles_when_defending( self, player, goblin_toml, moves, test_zone ): """Mob swaps attacker/defender roles when it attacks as defender.""" template = load_mob_template(goblin_toml) mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 # Player is attacker, mob is defender encounter = start_encounter(player, mob) player.mode_stack.append("combat") await process_mobs(moves) # Mob should now be the attacker assert encounter.attacker is mob assert encounter.defender is player @pytest.mark.asyncio async def test_mob_doesnt_act_outside_combat(self, goblin_toml, moves, test_zone): """Mob not in combat does nothing.""" template = load_mob_template(goblin_toml) mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 await process_mobs(moves) # No encounter exists for mob assert get_encounter(mob) is None @pytest.mark.asyncio async def test_mob_sets_cooldown_after_attack( self, player, goblin_toml, moves, test_zone ): """Mob sets next_action_at after attacking.""" template = load_mob_template(goblin_toml) mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 start_encounter(player, mob) player.mode_stack.append("combat") before = time.monotonic() await process_mobs(moves) # next_action_at should be ~1 second in the future assert mob.next_action_at >= before + 0.9 @pytest.mark.asyncio async def test_mob_sends_telegraph_to_player( self, player, goblin_toml, moves, test_zone ): """When mob attacks, player receives the telegraph message.""" template = load_mob_template(goblin_toml) mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 start_encounter(player, mob) player.mode_stack.append("combat") await process_mobs(moves) # Player's writer should have received the telegraph # The telegraph should contain mob name and actual telegraph content player.writer.write.assert_called() calls = [str(call) for call in player.writer.write.call_args_list] # Check for actual telegraph patterns from the TOML files # Goblin moves: punch left/right ("retracts"), sweep ("drops low") telegraph_patterns = ["retracts", "drops low"] telegraph_sent = any( "goblin" in call.lower() and any(pattern in call.lower() for pattern in telegraph_patterns) for call in calls ) assert telegraph_sent, ( f"No telegraph with mob name and content found in: {calls}" ) class TestMobDefenseAI: @pytest.fixture def punch_right(self, moves): return moves["punch right"] @pytest.mark.asyncio async def test_mob_defends_during_telegraph( self, player, goblin_toml, moves, punch_right, test_zone ): """Mob attempts defense during TELEGRAPH phase.""" template = load_mob_template(goblin_toml) # Give the mob defense moves mob = spawn_mob(template, 0, 0, test_zone) mob.moves = ["punch left", "dodge left", "dodge right"] mob.next_action_at = 0.0 encounter = start_encounter(player, mob) player.mode_stack.append("combat") # Player attacks, putting encounter in TELEGRAPH encounter.attack(punch_right) assert encounter.state == CombatState.PENDING await process_mobs(moves) # Mob should have queued a defense assert encounter.pending_defense is not None @pytest.mark.asyncio async def test_mob_skips_defense_when_already_defending( self, player, goblin_toml, moves, punch_right, test_zone ): """Mob doesn't double-defend if already has pending_defense.""" template = load_mob_template(goblin_toml) mob = spawn_mob(template, 0, 0, test_zone) mob.moves = ["dodge left", "dodge right"] mob.next_action_at = 0.0 encounter = start_encounter(player, mob) player.mode_stack.append("combat") encounter.attack(punch_right) # Pre-set a defense existing_defense = moves["dodge left"] encounter.pending_defense = existing_defense await process_mobs(moves) # Should not have changed assert encounter.pending_defense is existing_defense @pytest.mark.asyncio async def test_mob_no_defense_without_defense_moves( self, player, goblin_toml, moves, punch_right, test_zone ): """Mob with no defense moves in its list can't defend.""" template = load_mob_template(goblin_toml) mob = spawn_mob(template, 0, 0, test_zone) # Only attack moves mob.moves = ["punch left", "punch right", "sweep"] mob.next_action_at = time.monotonic() + 100.0 # prevent attacking encounter = start_encounter(player, mob) player.mode_stack.append("combat") encounter.attack(punch_right) await process_mobs(moves) # No defense queued assert encounter.pending_defense is None @pytest.mark.asyncio async def test_dummy_never_fights_back( self, player, dummy_toml, moves, punch_right, test_zone ): """Training dummy with empty moves never attacks or defends.""" template = load_mob_template(dummy_toml) mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 encounter = start_encounter(player, mob) player.mode_stack.append("combat") # Player attacks encounter.attack(punch_right) await process_mobs(moves) # Dummy should not have defended (empty moves list) assert encounter.pending_defense is None