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
This commit is contained in:
parent
1349c2f860
commit
f5646589b5
10 changed files with 235 additions and 288 deletions
|
|
@ -3,7 +3,6 @@
|
|||
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
|
||||
|
|
@ -15,9 +14,6 @@ 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.
|
||||
|
|
@ -34,10 +30,14 @@ 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:
|
||||
if target is None and player.location is not None:
|
||||
from mudlib.mobs import get_nearby_mob
|
||||
from mudlib.zone import Zone
|
||||
|
||||
target = get_nearby_mob(target_name, player.x, player.y, world)
|
||||
if isinstance(player.location, Zone):
|
||||
target = get_nearby_mob(
|
||||
target_name, player.x, player.y, player.location
|
||||
)
|
||||
|
||||
# Check stamina
|
||||
if player.stamina < move.stamina_cost:
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
"""Look command for viewing the world."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mudlib.commands import CommandDefinition, register
|
||||
from mudlib.effects import get_effects_at
|
||||
from mudlib.mobs import mobs
|
||||
from mudlib.player import Player, players
|
||||
from mudlib.entity import Entity
|
||||
from mudlib.player import Player
|
||||
from mudlib.render.ansi import RESET, colorize_terrain
|
||||
|
||||
# World instance will be injected by the server
|
||||
world: Any = None
|
||||
from mudlib.zone import Zone
|
||||
|
||||
# Viewport dimensions
|
||||
VIEWPORT_WIDTH = 21
|
||||
|
|
@ -23,58 +19,53 @@ async def cmd_look(player: Player, args: str) -> None:
|
|||
player: The player executing the command
|
||||
args: Command arguments (unused for now)
|
||||
"""
|
||||
# Get the viewport from the world
|
||||
viewport = world.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT)
|
||||
zone = player.location
|
||||
if zone is None or not isinstance(zone, Zone):
|
||||
player.writer.write("You are nowhere.\r\n")
|
||||
await player.writer.drain()
|
||||
return
|
||||
|
||||
# Get the viewport from the zone
|
||||
viewport = zone.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT)
|
||||
|
||||
# Calculate center position
|
||||
center_x = VIEWPORT_WIDTH // 2
|
||||
center_y = VIEWPORT_HEIGHT // 2
|
||||
|
||||
# Build a list of (relative_x, relative_y) for other players
|
||||
other_player_positions = []
|
||||
for other in players.values():
|
||||
if other.name == player.name:
|
||||
# Get nearby entities (players and mobs) from the zone
|
||||
# Viewport half-diagonal distance for range
|
||||
viewport_range = VIEWPORT_WIDTH // 2 + VIEWPORT_HEIGHT // 2
|
||||
nearby = zone.contents_near(player.x, player.y, viewport_range)
|
||||
|
||||
# Build a list of (relative_x, relative_y) for other entities
|
||||
entity_positions = []
|
||||
for obj in nearby:
|
||||
# Only show entities (players/mobs), not the current player
|
||||
if not isinstance(obj, Entity) or obj is player:
|
||||
continue
|
||||
|
||||
# Skip dead mobs
|
||||
if hasattr(obj, "alive") and not obj.alive:
|
||||
continue
|
||||
|
||||
# Calculate relative position (shortest path wrapping)
|
||||
dx = other.x - player.x
|
||||
dy = other.y - player.y
|
||||
if dx > world.width // 2:
|
||||
dx -= world.width
|
||||
elif dx < -(world.width // 2):
|
||||
dx += world.width
|
||||
if dy > world.height // 2:
|
||||
dy -= world.height
|
||||
elif dy < -(world.height // 2):
|
||||
dy += world.height
|
||||
dx = obj.x - player.x
|
||||
dy = obj.y - player.y
|
||||
if zone.toroidal:
|
||||
if dx > zone.width // 2:
|
||||
dx -= zone.width
|
||||
elif dx < -(zone.width // 2):
|
||||
dx += zone.width
|
||||
if dy > zone.height // 2:
|
||||
dy -= zone.height
|
||||
elif dy < -(zone.height // 2):
|
||||
dy += zone.height
|
||||
rel_x = dx + center_x
|
||||
rel_y = dy + center_y
|
||||
|
||||
# Check if within viewport bounds
|
||||
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
||||
other_player_positions.append((rel_x, rel_y))
|
||||
|
||||
# Build a list of (relative_x, relative_y) for alive mobs
|
||||
mob_positions = []
|
||||
for mob in mobs:
|
||||
if not mob.alive:
|
||||
continue
|
||||
|
||||
dx = mob.x - player.x
|
||||
dy = mob.y - player.y
|
||||
if dx > world.width // 2:
|
||||
dx -= world.width
|
||||
elif dx < -(world.width // 2):
|
||||
dx += world.width
|
||||
if dy > world.height // 2:
|
||||
dy -= world.height
|
||||
elif dy < -(world.height // 2):
|
||||
dy += world.height
|
||||
rel_x = dx + center_x
|
||||
rel_y = dy + center_y
|
||||
|
||||
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
||||
mob_positions.append((rel_x, rel_y))
|
||||
entity_positions.append((rel_x, rel_y))
|
||||
|
||||
# Build the output with ANSI coloring
|
||||
# priority: player @ > other players * > mobs * > effects > terrain
|
||||
|
|
@ -88,12 +79,12 @@ async def cmd_look(player: Player, args: str) -> None:
|
|||
# Check if this is the player's position
|
||||
if x == center_x and y == center_y:
|
||||
line.append(colorize_terrain("@", player.color_depth))
|
||||
# Check if this is another player's position
|
||||
elif (x, y) in other_player_positions or (x, y) in mob_positions:
|
||||
# Check if this is another entity's position
|
||||
elif (x, y) in entity_positions:
|
||||
line.append(colorize_terrain("*", player.color_depth))
|
||||
else:
|
||||
# Check for active effects at this world position
|
||||
world_x, world_y = world.wrap(
|
||||
world_x, world_y = zone.wrap(
|
||||
player.x - half_width + x,
|
||||
player.y - half_height + y,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from mudlib.commands import CommandDefinition, register
|
||||
from mudlib.mobs import mob_templates, spawn_mob
|
||||
from mudlib.player import Player
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
async def cmd_spawn(player: Player, args: str) -> None:
|
||||
|
|
@ -17,7 +18,11 @@ async def cmd_spawn(player: Player, args: str) -> None:
|
|||
await player.send(f"Unknown mob type: {name}\r\nAvailable: {available}\r\n")
|
||||
return
|
||||
|
||||
mob = spawn_mob(mob_templates[name], player.x, player.y)
|
||||
if player.location is None or not isinstance(player.location, Zone):
|
||||
await player.send("Cannot spawn mob: you are not in a zone.\r\n")
|
||||
return
|
||||
|
||||
mob = spawn_mob(mob_templates[name], player.x, player.y, player.location)
|
||||
await player.send(f"A {mob.name} appears!\r\n")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mudlib.entity import Mob
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -48,10 +48,21 @@ def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
|
|||
return templates
|
||||
|
||||
|
||||
def spawn_mob(template: MobTemplate, x: int, y: int) -> Mob:
|
||||
"""Create a Mob instance from a template at the given position."""
|
||||
def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
|
||||
"""Create a Mob instance from a template at the given position.
|
||||
|
||||
Args:
|
||||
template: The mob template to spawn from
|
||||
x: X coordinate in the zone
|
||||
y: Y coordinate in the zone
|
||||
zone: The zone where the mob will be spawned
|
||||
|
||||
Returns:
|
||||
The spawned Mob instance
|
||||
"""
|
||||
mob = Mob(
|
||||
name=template.name,
|
||||
location=zone,
|
||||
x=x,
|
||||
y=y,
|
||||
pl=template.pl,
|
||||
|
|
@ -72,28 +83,44 @@ def despawn_mob(mob: Mob) -> None:
|
|||
|
||||
|
||||
def get_nearby_mob(
|
||||
name: str, x: int, y: int, world: Any, range_: int = 10
|
||||
name: str, x: int, y: int, zone: Zone, range_: int = 10
|
||||
) -> Mob | None:
|
||||
"""Find the closest alive mob matching name within range.
|
||||
|
||||
Uses wrapping-aware distance (same pattern as send_nearby_message).
|
||||
Uses zone.contents_near() to find all nearby objects, then filters
|
||||
for alive mobs matching the name and picks the closest.
|
||||
|
||||
Args:
|
||||
name: Name of the mob to find
|
||||
x: X coordinate of the search center
|
||||
y: Y coordinate of the search center
|
||||
zone: The zone to search in
|
||||
range_: Maximum Manhattan distance (default 10)
|
||||
|
||||
Returns:
|
||||
The closest matching mob, or None if none found
|
||||
"""
|
||||
best: Mob | None = None
|
||||
best_dist = float("inf")
|
||||
|
||||
for mob in mobs:
|
||||
if not mob.alive or mob.name != name:
|
||||
# Get all nearby objects from the zone
|
||||
nearby = zone.contents_near(x, y, range_)
|
||||
|
||||
for obj in nearby:
|
||||
# Filter for alive mobs matching the name
|
||||
if not isinstance(obj, Mob) or not obj.alive or obj.name != name:
|
||||
continue
|
||||
|
||||
dx = abs(mob.x - x)
|
||||
dy = abs(mob.y - y)
|
||||
dx = min(dx, world.width - dx)
|
||||
dy = min(dy, world.height - dy)
|
||||
# Calculate wrapping-aware distance to find closest
|
||||
dx = abs(obj.x - x)
|
||||
dy = abs(obj.y - y)
|
||||
if zone.toroidal:
|
||||
dx = min(dx, zone.width - dx)
|
||||
dy = min(dy, zone.height - dy)
|
||||
|
||||
if dx <= range_ and dy <= range_:
|
||||
dist = dx + dy
|
||||
if dist < best_dist:
|
||||
best = mob
|
||||
best = obj
|
||||
best_dist = dist
|
||||
|
||||
return best
|
||||
|
|
|
|||
|
|
@ -140,9 +140,6 @@ def test_direction_deltas(direction, expected_delta):
|
|||
@pytest.mark.asyncio
|
||||
async def test_movement_updates_position(player, test_zone):
|
||||
"""Test that movement updates player position when passable."""
|
||||
# Inject test_zone into look command (still uses module-level world)
|
||||
look.world = test_zone
|
||||
|
||||
# Clear players registry to avoid test pollution
|
||||
from mudlib.player import players
|
||||
|
||||
|
|
@ -179,8 +176,6 @@ async def test_movement_blocked_by_impassable_terrain(player, test_zone, mock_wr
|
|||
@pytest.mark.asyncio
|
||||
async def test_movement_sends_departure_message(player, test_zone):
|
||||
"""Test that movement sends departure message to nearby players."""
|
||||
look.world = test_zone
|
||||
|
||||
# Create another player in the area
|
||||
other_writer = MagicMock()
|
||||
other_writer.write = MagicMock()
|
||||
|
|
@ -208,8 +203,6 @@ async def test_movement_sends_departure_message(player, test_zone):
|
|||
@pytest.mark.asyncio
|
||||
async def test_arrival_message_uses_opposite_direction(player, test_zone):
|
||||
"""Test that arrival messages use the opposite direction."""
|
||||
look.world = test_zone
|
||||
|
||||
# Create another player at the destination
|
||||
other_writer = MagicMock()
|
||||
other_writer.write = MagicMock()
|
||||
|
|
@ -238,9 +231,6 @@ async def test_arrival_message_uses_opposite_direction(player, test_zone):
|
|||
@pytest.mark.asyncio
|
||||
async def test_look_command_sends_viewport(player, test_zone):
|
||||
"""Test that look command sends the viewport to the player."""
|
||||
# look.py still uses module-level world, so inject test_zone
|
||||
look.world = test_zone
|
||||
|
||||
await look.cmd_look(player, "")
|
||||
|
||||
assert player.writer.write.called
|
||||
|
|
@ -249,8 +239,6 @@ async def test_look_command_sends_viewport(player, test_zone):
|
|||
@pytest.mark.asyncio
|
||||
async def test_look_command_shows_player_at_center(player, test_zone):
|
||||
"""Test that look command shows player @ at center."""
|
||||
look.world = test_zone
|
||||
|
||||
await look.cmd_look(player, "")
|
||||
|
||||
# Check that the output contains the @ symbol for the player
|
||||
|
|
@ -261,8 +249,6 @@ async def test_look_command_shows_player_at_center(player, test_zone):
|
|||
@pytest.mark.asyncio
|
||||
async def test_look_command_shows_other_players(player, test_zone):
|
||||
"""Test that look command shows other players as *."""
|
||||
look.world = test_zone
|
||||
|
||||
# Create another player in the viewport
|
||||
other_player = Player(
|
||||
name="OtherPlayer",
|
||||
|
|
@ -290,8 +276,6 @@ async def test_look_command_shows_other_players(player, test_zone):
|
|||
@pytest.mark.asyncio
|
||||
async def test_look_shows_effects_on_viewport(player, test_zone):
|
||||
"""Test that active effects overlay on the viewport."""
|
||||
look.world = test_zone
|
||||
|
||||
from mudlib.player import players
|
||||
|
||||
players.clear()
|
||||
|
|
@ -315,8 +299,6 @@ async def test_look_shows_effects_on_viewport(player, test_zone):
|
|||
@pytest.mark.asyncio
|
||||
async def test_effects_dont_override_player_marker(player, test_zone):
|
||||
"""Effects at the player's position should not hide the @ marker."""
|
||||
look.world = test_zone
|
||||
|
||||
from mudlib.player import players
|
||||
|
||||
players.clear()
|
||||
|
|
|
|||
|
|
@ -51,34 +51,6 @@ def test_zone():
|
|||
return zone
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def inject_world_for_combat(test_zone):
|
||||
"""Inject test_zone into combat commands (still uses module-level world)."""
|
||||
old_combat = combat_commands.world
|
||||
combat_commands.world = test_zone
|
||||
yield test_zone
|
||||
combat_commands.world = old_combat
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def auto_zone_mobs(test_zone, monkeypatch):
|
||||
"""Monkeypatch spawn_mob to automatically add mobs to test_zone."""
|
||||
original_spawn = spawn_mob
|
||||
|
||||
def spawn_and_zone(template, x, y):
|
||||
mob = original_spawn(template, x, y)
|
||||
mob.location = test_zone
|
||||
test_zone._contents.append(mob)
|
||||
return mob
|
||||
|
||||
monkeypatch.setattr("mudlib.mobs.spawn_mob", spawn_and_zone)
|
||||
# Also patch it in the test module's import
|
||||
import mudlib.mob_ai as mob_ai_mod
|
||||
|
||||
if hasattr(mob_ai_mod, "spawn_mob"):
|
||||
monkeypatch.setattr(mob_ai_mod, "spawn_mob", spawn_and_zone)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
|
|
@ -147,11 +119,11 @@ def dummy_toml(tmp_path):
|
|||
class TestMobAttackAI:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_attacks_when_idle_and_cooldown_expired(
|
||||
self, player, goblin_toml, moves
|
||||
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)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob.next_action_at = 0.0 # cooldown expired
|
||||
|
||||
encounter = start_encounter(player, mob)
|
||||
|
|
@ -164,10 +136,12 @@ class TestMobAttackAI:
|
|||
assert encounter.current_move is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_picks_from_its_own_moves(self, player, goblin_toml, moves):
|
||||
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)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob.next_action_at = 0.0
|
||||
|
||||
encounter = start_encounter(player, mob)
|
||||
|
|
@ -179,10 +153,12 @@ class TestMobAttackAI:
|
|||
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):
|
||||
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)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob.stamina = 0.0
|
||||
mob.next_action_at = 0.0
|
||||
|
||||
|
|
@ -195,10 +171,10 @@ class TestMobAttackAI:
|
|||
assert encounter.state == CombatState.IDLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_respects_cooldown(self, player, goblin_toml, moves):
|
||||
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)
|
||||
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)
|
||||
|
|
@ -210,10 +186,12 @@ class TestMobAttackAI:
|
|||
assert encounter.state == CombatState.IDLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_swaps_roles_when_defending(self, player, goblin_toml, moves):
|
||||
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)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob.next_action_at = 0.0
|
||||
|
||||
# Player is attacker, mob is defender
|
||||
|
|
@ -227,10 +205,10 @@ class TestMobAttackAI:
|
|||
assert encounter.defender is player
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_doesnt_act_outside_combat(self, goblin_toml, moves):
|
||||
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)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob.next_action_at = 0.0
|
||||
|
||||
await process_mobs(moves)
|
||||
|
|
@ -239,10 +217,12 @@ class TestMobAttackAI:
|
|||
assert get_encounter(mob) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_sets_cooldown_after_attack(self, player, goblin_toml, moves):
|
||||
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)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob.next_action_at = 0.0
|
||||
|
||||
start_encounter(player, mob)
|
||||
|
|
@ -262,12 +242,12 @@ class TestMobDefenseAI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_defends_during_telegraph(
|
||||
self, player, goblin_toml, moves, punch_right
|
||||
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)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob.moves = ["punch left", "dodge left", "dodge right"]
|
||||
mob.next_action_at = 0.0
|
||||
|
||||
|
|
@ -285,11 +265,11 @@ class TestMobDefenseAI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_skips_defense_when_already_defending(
|
||||
self, player, goblin_toml, moves, punch_right
|
||||
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)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob.moves = ["dodge left", "dodge right"]
|
||||
mob.next_action_at = 0.0
|
||||
|
||||
|
|
@ -308,11 +288,11 @@ class TestMobDefenseAI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_no_defense_without_defense_moves(
|
||||
self, player, goblin_toml, moves, punch_right
|
||||
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)
|
||||
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
|
||||
|
|
@ -328,11 +308,11 @@ class TestMobDefenseAI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dummy_never_fights_back(
|
||||
self, player, dummy_toml, moves, punch_right
|
||||
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)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob.next_action_at = 0.0
|
||||
|
||||
encounter = start_encounter(player, mob)
|
||||
|
|
|
|||
|
|
@ -49,29 +49,6 @@ def test_zone():
|
|||
return zone
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def inject_world_for_combat(test_zone):
|
||||
"""Inject test_zone into combat commands (still uses module-level world)."""
|
||||
old_combat = combat_commands.world
|
||||
combat_commands.world = test_zone
|
||||
yield test_zone
|
||||
combat_commands.world = old_combat
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def auto_zone_mobs(test_zone, monkeypatch):
|
||||
"""Monkeypatch spawn_mob to automatically add mobs to test_zone."""
|
||||
original_spawn = spawn_mob
|
||||
|
||||
def spawn_and_zone(template, x, y):
|
||||
mob = original_spawn(template, x, y)
|
||||
mob.location = test_zone
|
||||
test_zone._contents.append(mob)
|
||||
return mob
|
||||
|
||||
monkeypatch.setattr("mudlib.mobs.spawn_mob", spawn_and_zone)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def goblin_toml(tmp_path):
|
||||
"""Create a goblin TOML file."""
|
||||
|
|
@ -125,9 +102,9 @@ class TestLoadTemplate:
|
|||
|
||||
|
||||
class TestSpawnDespawn:
|
||||
def test_spawn_creates_mob(self, goblin_toml):
|
||||
def test_spawn_creates_mob(self, goblin_toml, test_zone):
|
||||
template = load_mob_template(goblin_toml)
|
||||
mob = spawn_mob(template, 10, 20)
|
||||
mob = spawn_mob(template, 10, 20, test_zone)
|
||||
assert isinstance(mob, Mob)
|
||||
assert mob.name == "goblin"
|
||||
assert mob.x == 10
|
||||
|
|
@ -138,72 +115,67 @@ class TestSpawnDespawn:
|
|||
assert mob.moves == ["punch left", "punch right", "sweep"]
|
||||
assert mob.alive is True
|
||||
assert mob in mobs
|
||||
assert mob.location is test_zone
|
||||
assert mob in test_zone._contents
|
||||
|
||||
def test_spawn_adds_to_registry(self, goblin_toml):
|
||||
def test_spawn_adds_to_registry(self, goblin_toml, test_zone):
|
||||
template = load_mob_template(goblin_toml)
|
||||
spawn_mob(template, 0, 0)
|
||||
spawn_mob(template, 5, 5)
|
||||
spawn_mob(template, 0, 0, test_zone)
|
||||
spawn_mob(template, 5, 5, test_zone)
|
||||
assert len(mobs) == 2
|
||||
|
||||
def test_despawn_removes_from_list(self, goblin_toml):
|
||||
def test_despawn_removes_from_list(self, goblin_toml, test_zone):
|
||||
template = load_mob_template(goblin_toml)
|
||||
mob = spawn_mob(template, 0, 0)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
despawn_mob(mob)
|
||||
assert mob not in mobs
|
||||
assert mob.alive is False
|
||||
|
||||
def test_despawn_sets_alive_false(self, goblin_toml):
|
||||
def test_despawn_sets_alive_false(self, goblin_toml, test_zone):
|
||||
template = load_mob_template(goblin_toml)
|
||||
mob = spawn_mob(template, 0, 0)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
despawn_mob(mob)
|
||||
assert mob.alive is False
|
||||
|
||||
|
||||
class TestGetNearbyMob:
|
||||
@pytest.fixture
|
||||
def mock_world(self):
|
||||
w = MagicMock()
|
||||
w.width = 256
|
||||
w.height = 256
|
||||
return w
|
||||
|
||||
def test_finds_by_name_within_range(self, goblin_toml, mock_world):
|
||||
def test_finds_by_name_within_range(self, goblin_toml, test_zone):
|
||||
template = load_mob_template(goblin_toml)
|
||||
mob = spawn_mob(template, 5, 5)
|
||||
found = get_nearby_mob("goblin", 3, 3, mock_world)
|
||||
mob = spawn_mob(template, 5, 5, test_zone)
|
||||
found = get_nearby_mob("goblin", 3, 3, test_zone)
|
||||
assert found is mob
|
||||
|
||||
def test_returns_none_when_out_of_range(self, goblin_toml, mock_world):
|
||||
def test_returns_none_when_out_of_range(self, goblin_toml, test_zone):
|
||||
template = load_mob_template(goblin_toml)
|
||||
spawn_mob(template, 100, 100)
|
||||
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
||||
spawn_mob(template, 100, 100, test_zone)
|
||||
found = get_nearby_mob("goblin", 0, 0, test_zone)
|
||||
assert found is None
|
||||
|
||||
def test_returns_none_for_wrong_name(self, goblin_toml, mock_world):
|
||||
def test_returns_none_for_wrong_name(self, goblin_toml, test_zone):
|
||||
template = load_mob_template(goblin_toml)
|
||||
spawn_mob(template, 5, 5)
|
||||
found = get_nearby_mob("dragon", 3, 3, mock_world)
|
||||
spawn_mob(template, 5, 5, test_zone)
|
||||
found = get_nearby_mob("dragon", 3, 3, test_zone)
|
||||
assert found is None
|
||||
|
||||
def test_picks_closest_when_multiple(self, goblin_toml, mock_world):
|
||||
def test_picks_closest_when_multiple(self, goblin_toml, test_zone):
|
||||
template = load_mob_template(goblin_toml)
|
||||
spawn_mob(template, 8, 8)
|
||||
close_mob = spawn_mob(template, 1, 1)
|
||||
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
||||
spawn_mob(template, 8, 8, test_zone)
|
||||
close_mob = spawn_mob(template, 1, 1, test_zone)
|
||||
found = get_nearby_mob("goblin", 0, 0, test_zone)
|
||||
assert found is close_mob
|
||||
|
||||
def test_skips_dead_mobs(self, goblin_toml, mock_world):
|
||||
def test_skips_dead_mobs(self, goblin_toml, test_zone):
|
||||
template = load_mob_template(goblin_toml)
|
||||
mob = spawn_mob(template, 5, 5)
|
||||
mob = spawn_mob(template, 5, 5, test_zone)
|
||||
mob.alive = False
|
||||
found = get_nearby_mob("goblin", 3, 3, mock_world)
|
||||
found = get_nearby_mob("goblin", 3, 3, test_zone)
|
||||
assert found is None
|
||||
|
||||
def test_wrapping_distance(self, goblin_toml, mock_world):
|
||||
def test_wrapping_distance(self, goblin_toml, test_zone):
|
||||
"""Mob near world edge is close to player at opposite edge."""
|
||||
template = load_mob_template(goblin_toml)
|
||||
mob = spawn_mob(template, 254, 254)
|
||||
found = get_nearby_mob("goblin", 2, 2, mock_world, range_=10)
|
||||
mob = spawn_mob(template, 254, 254, test_zone)
|
||||
found = get_nearby_mob("goblin", 2, 2, test_zone, range_=10)
|
||||
assert found is mob
|
||||
|
||||
|
||||
|
|
@ -224,8 +196,10 @@ def mock_reader():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer):
|
||||
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
|
||||
|
||||
|
|
@ -252,10 +226,12 @@ def punch_right(moves):
|
|||
|
||||
class TestTargetResolution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_attack_mob_by_name(self, player, punch_right, goblin_toml):
|
||||
async def test_attack_mob_by_name(
|
||||
self, player, punch_right, goblin_toml, test_zone
|
||||
):
|
||||
"""do_attack with mob name finds and engages the mob."""
|
||||
template = load_mob_template(goblin_toml)
|
||||
mob = spawn_mob(template, 0, 0)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
|
||||
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||
|
||||
|
|
@ -266,11 +242,11 @@ class TestTargetResolution:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attack_prefers_player_over_mob(
|
||||
self, player, punch_right, goblin_toml, mock_reader, mock_writer
|
||||
self, player, punch_right, goblin_toml, mock_reader, mock_writer, test_zone
|
||||
):
|
||||
"""When a player and mob share a name, player takes priority."""
|
||||
template = load_mob_template(goblin_toml)
|
||||
spawn_mob(template, 0, 0)
|
||||
spawn_mob(template, 0, 0, test_zone)
|
||||
|
||||
# Create a player named "goblin"
|
||||
goblin_player = Player(
|
||||
|
|
@ -280,6 +256,8 @@ class TestTargetResolution:
|
|||
reader=mock_reader,
|
||||
writer=mock_writer,
|
||||
)
|
||||
goblin_player.location = test_zone
|
||||
test_zone._contents.append(goblin_player)
|
||||
players["goblin"] = goblin_player
|
||||
|
||||
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||
|
|
@ -289,10 +267,12 @@ class TestTargetResolution:
|
|||
assert encounter.defender is goblin_player
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attack_mob_out_of_range(self, player, punch_right, goblin_toml):
|
||||
async def test_attack_mob_out_of_range(
|
||||
self, player, punch_right, goblin_toml, test_zone
|
||||
):
|
||||
"""Mob outside viewport range is not found as target."""
|
||||
template = load_mob_template(goblin_toml)
|
||||
spawn_mob(template, 100, 100)
|
||||
spawn_mob(template, 100, 100, test_zone)
|
||||
|
||||
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||
|
||||
|
|
@ -302,10 +282,12 @@ class TestTargetResolution:
|
|||
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):
|
||||
async def test_encounter_mob_no_mode_push(
|
||||
self, player, punch_right, goblin_toml, test_zone
|
||||
):
|
||||
"""Mob doesn't get mode_stack push (it has no mode_stack)."""
|
||||
template = load_mob_template(goblin_toml)
|
||||
mob = spawn_mob(template, 0, 0)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
|
||||
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||
|
||||
|
|
@ -337,16 +319,15 @@ class TestViewportRendering:
|
|||
return w
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_renders_as_star(self, player, goblin_toml, look_world):
|
||||
async def test_mob_renders_as_star(
|
||||
self, player, goblin_toml, look_world, test_zone
|
||||
):
|
||||
"""Mob within viewport renders as * in look output."""
|
||||
import mudlib.commands.look as look_mod
|
||||
|
||||
old = look_mod.world
|
||||
look_mod.world = look_world
|
||||
|
||||
template = load_mob_template(goblin_toml)
|
||||
# Place mob 2 tiles to the right of the player
|
||||
spawn_mob(template, 2, 0)
|
||||
spawn_mob(template, 2, 0, test_zone)
|
||||
|
||||
await look_mod.cmd_look(player, "")
|
||||
|
||||
|
|
@ -355,21 +336,16 @@ class TestViewportRendering:
|
|||
# Output should contain a * character
|
||||
assert "*" in output
|
||||
|
||||
look_mod.world = old
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_outside_viewport_not_rendered(
|
||||
self, player, goblin_toml, look_world
|
||||
self, player, goblin_toml, look_world, test_zone
|
||||
):
|
||||
"""Mob outside viewport bounds is not rendered."""
|
||||
import mudlib.commands.look as look_mod
|
||||
|
||||
old = look_mod.world
|
||||
look_mod.world = look_world
|
||||
|
||||
template = load_mob_template(goblin_toml)
|
||||
# Place mob far away
|
||||
spawn_mob(template, 100, 100)
|
||||
spawn_mob(template, 100, 100, test_zone)
|
||||
|
||||
await look_mod.cmd_look(player, "")
|
||||
|
||||
|
|
@ -382,18 +358,15 @@ class TestViewportRendering:
|
|||
stripped = re.sub(r"\033\[[0-9;]*m", "", stripped)
|
||||
assert "*" not in stripped
|
||||
|
||||
look_mod.world = old
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dead_mob_not_rendered(self, player, goblin_toml, look_world):
|
||||
async def test_dead_mob_not_rendered(
|
||||
self, player, goblin_toml, look_world, test_zone
|
||||
):
|
||||
"""Dead mob (alive=False) not rendered in viewport."""
|
||||
import mudlib.commands.look as look_mod
|
||||
|
||||
old = look_mod.world
|
||||
look_mod.world = look_world
|
||||
|
||||
template = load_mob_template(goblin_toml)
|
||||
mob = spawn_mob(template, 2, 0)
|
||||
mob = spawn_mob(template, 2, 0, test_zone)
|
||||
mob.alive = False
|
||||
|
||||
await look_mod.cmd_look(player, "")
|
||||
|
|
@ -404,17 +377,15 @@ class TestViewportRendering:
|
|||
stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace("\r\n", "")
|
||||
assert "*" not in stripped
|
||||
|
||||
look_mod.world = old
|
||||
|
||||
|
||||
# --- Phase 4: mob defeat tests ---
|
||||
|
||||
|
||||
class TestMobDefeat:
|
||||
@pytest.fixture
|
||||
def goblin_mob(self, goblin_toml):
|
||||
def goblin_mob(self, goblin_toml, test_zone):
|
||||
template = load_mob_template(goblin_toml)
|
||||
return spawn_mob(template, 0, 0)
|
||||
return spawn_mob(template, 0, 0, test_zone)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
|
||||
|
|
|
|||
|
|
@ -69,13 +69,6 @@ async def test_shell_greets_and_accepts_commands(temp_db):
|
|||
writer.drain = AsyncMock()
|
||||
writer.close = MagicMock()
|
||||
|
||||
# Need to mock the look command's world reference too
|
||||
import mudlib.commands.look
|
||||
|
||||
original_world = mudlib.commands.look.world
|
||||
mudlib.commands.look.world = world
|
||||
|
||||
try:
|
||||
readline = "mudlib.server.readline2"
|
||||
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
||||
# Simulate: name, create account (y), password, confirm password, look, quit
|
||||
|
|
@ -93,8 +86,6 @@ async def test_shell_greets_and_accepts_commands(temp_db):
|
|||
assert any("Welcome" in call for call in calls)
|
||||
assert any("TestPlayer" in call for call in calls)
|
||||
writer.close.assert_called()
|
||||
finally:
|
||||
mudlib.commands.look.world = original_world
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -135,13 +126,6 @@ async def test_shell_handles_quit(temp_db):
|
|||
writer.drain = AsyncMock()
|
||||
writer.close = MagicMock()
|
||||
|
||||
# Need to mock the look command's world reference too
|
||||
import mudlib.commands.look
|
||||
|
||||
original_world = mudlib.commands.look.world
|
||||
mudlib.commands.look.world = world
|
||||
|
||||
try:
|
||||
readline = "mudlib.server.readline2"
|
||||
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
||||
# Simulate: name, create account (y), password, confirm password, quit
|
||||
|
|
@ -157,8 +141,6 @@ async def test_shell_handles_quit(temp_db):
|
|||
calls = [str(call) for call in writer.write.call_args_list]
|
||||
assert any("Goodbye" in call for call in calls)
|
||||
writer.close.assert_called()
|
||||
finally:
|
||||
mudlib.commands.look.world = original_world
|
||||
|
||||
|
||||
def test_load_world_config():
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import pytest
|
|||
from mudlib.commands.spawn import cmd_spawn
|
||||
from mudlib.mobs import MobTemplate, mob_templates, mobs
|
||||
from mudlib.player import Player, players
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -35,8 +36,25 @@ def mock_reader():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer):
|
||||
def test_zone():
|
||||
"""Create a test zone for spawning."""
|
||||
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=10, y=20, reader=mock_reader, writer=mock_writer)
|
||||
p.location = test_zone
|
||||
test_zone._contents.append(p)
|
||||
players[p.name] = p
|
||||
return p
|
||||
|
||||
|
|
|
|||
|
|
@ -37,15 +37,6 @@ def test_zone():
|
|||
return zone
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def inject_world_for_combat(test_zone):
|
||||
"""Inject test_zone into combat commands (still uses module-level world)."""
|
||||
old_combat = combat_commands.world
|
||||
combat_commands.world = test_zone
|
||||
yield test_zone
|
||||
combat_commands.world = old_combat
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
|
|
|
|||
Loading…
Reference in a new issue