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.
This commit is contained in:
Jared Miller 2026-02-08 22:58:12 -05:00
parent 2bab61ef8c
commit 1fa21e20b0
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 154 additions and 4 deletions

View file

@ -3,6 +3,7 @@
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import Any
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter, start_encounter 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_moves: dict[str, CombatMove] = {}
combat_content_dir: Path | None = None 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: async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
"""Core attack logic with a resolved move. """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() target_name = target_args.strip()
if encounter is None and target_name: if encounter is None and target_name:
target = players.get(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 # Check stamina
if player.stamina < move.stamina_cost: if player.stamina < move.stamina_cost:

View file

@ -1,11 +1,15 @@
"""Tests for mob templates, registry, and spawn/despawn.""" """Tests for mob templates, registry, spawn/despawn, and combat integration."""
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest 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.entity import Mob
from mudlib.mobs import ( from mudlib.mobs import (
MobTemplate, MobTemplate,
@ -16,14 +20,34 @@ from mudlib.mobs import (
mobs, mobs,
spawn_mob, spawn_mob,
) )
from mudlib.player import Player, players
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clear_mobs(): def clear_state():
"""Clear mobs list before and after each test.""" """Clear mobs, encounters, and players before and after each test."""
mobs.clear() mobs.clear()
active_encounters.clear()
players.clear()
yield yield
mobs.clear() 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 @pytest.fixture
@ -159,3 +183,121 @@ class TestGetNearbyMob:
mob = spawn_mob(template, 254, 254) mob = spawn_mob(template, 254, 254)
found = get_nearby_mob("goblin", 2, 2, mock_world, range_=10) found = get_nearby_mob("goblin", 2, 2, mock_world, range_=10)
assert found is mob 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")