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:
parent
2bab61ef8c
commit
1fa21e20b0
2 changed files with 154 additions and 4 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue