mud/tests/test_mob_ai.py
Jared Miller f5646589b5
Migrate look to use player.location (Zone)
- Removed world module-level variable from look.py
- look.cmd_look() now uses player.location.get_viewport() instead of world.get_viewport()
- look.cmd_look() uses zone.contents_near() to find nearby entities instead of iterating global players/mobs lists
- Wrapping calculations use zone.width/height/toroidal instead of world properties
- Added type check for player.location being a Zone instance
- Removed look.world injection from server.py
- Updated all tests to remove look.world injection
- spawn_mob() and combat commands also migrated to use Zone (player.location)
- Removed orphaned code from test_mob_ai.py and test_variant_prefix.py
2026-02-11 19:36:46 -05:00

327 lines
9.6 KiB
Python

"""Tests for mob AI behavior."""
import time
from pathlib import Path
from unittest.mock import AsyncMock, 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,
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 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, 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.TELEGRAPH
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
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.TELEGRAPH
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