From e6bfd774640462ab81ada118ac1866884a481819 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sun, 8 Feb 2026 22:58:12 -0500 Subject: [PATCH] Add mob target resolution in combat commands 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. --- src/mudlib/combat/commands.py | 8 ++ tests/test_mobs.py | 150 +++++++++++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 4 deletions(-) diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index db212a8..f469d68 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -3,6 +3,7 @@ import asyncio from collections import defaultdict from pathlib import Path +from typing import Any from mudlib.combat.encounter import CombatState from mudlib.combat.engine import get_encounter, start_encounter @@ -14,6 +15,9 @@ from mudlib.player import Player, players combat_moves: dict[str, CombatMove] = {} combat_content_dir: Path | None = None +# World instance will be injected by the server +world: Any = None + async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: """Core attack logic with a resolved move. @@ -30,6 +34,10 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: target_name = target_args.strip() if encounter is None and target_name: target = players.get(target_name) + if target is None and world is not None: + from mudlib.mobs import get_nearby_mob + + target = get_nearby_mob(target_name, player.x, player.y, world) # Check stamina if player.stamina < move.stamina_cost: diff --git a/tests/test_mobs.py b/tests/test_mobs.py index c5250af..a4f5602 100644 --- a/tests/test_mobs.py +++ b/tests/test_mobs.py @@ -1,11 +1,15 @@ -"""Tests for mob templates, registry, and spawn/despawn.""" +"""Tests for mob templates, registry, spawn/despawn, and combat integration.""" import tomllib from pathlib import Path -from unittest.mock import MagicMock +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, @@ -16,14 +20,34 @@ from mudlib.mobs import ( mobs, spawn_mob, ) +from mudlib.player import Player, players @pytest.fixture(autouse=True) -def clear_mobs(): - """Clear mobs list before and after each test.""" +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 @@ -159,3 +183,121 @@ class TestGetNearbyMob: 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")