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:
Jared Miller 2026-02-11 19:35:58 -05:00
parent 1349c2f860
commit f5646589b5
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
10 changed files with 235 additions and 288 deletions

View file

@ -3,7 +3,6 @@
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
@ -15,9 +14,6 @@ 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.
@ -34,10 +30,14 @@ 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: if target is None and player.location is not None:
from mudlib.mobs import get_nearby_mob 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 # Check stamina
if player.stamina < move.stamina_cost: if player.stamina < move.stamina_cost:

View file

@ -1,15 +1,11 @@
"""Look command for viewing the world.""" """Look command for viewing the world."""
from typing import Any
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.effects import get_effects_at from mudlib.effects import get_effects_at
from mudlib.mobs import mobs from mudlib.entity import Entity
from mudlib.player import Player, players from mudlib.player import Player
from mudlib.render.ansi import RESET, colorize_terrain from mudlib.render.ansi import RESET, colorize_terrain
from mudlib.zone import Zone
# World instance will be injected by the server
world: Any = None
# Viewport dimensions # Viewport dimensions
VIEWPORT_WIDTH = 21 VIEWPORT_WIDTH = 21
@ -23,58 +19,53 @@ async def cmd_look(player: Player, args: str) -> None:
player: The player executing the command player: The player executing the command
args: Command arguments (unused for now) args: Command arguments (unused for now)
""" """
# Get the viewport from the world zone = player.location
viewport = world.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT) 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 # Calculate center position
center_x = VIEWPORT_WIDTH // 2 center_x = VIEWPORT_WIDTH // 2
center_y = VIEWPORT_HEIGHT // 2 center_y = VIEWPORT_HEIGHT // 2
# Build a list of (relative_x, relative_y) for other players # Get nearby entities (players and mobs) from the zone
other_player_positions = [] # Viewport half-diagonal distance for range
for other in players.values(): viewport_range = VIEWPORT_WIDTH // 2 + VIEWPORT_HEIGHT // 2
if other.name == player.name: 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 continue
# Calculate relative position (shortest path wrapping) # Calculate relative position (shortest path wrapping)
dx = other.x - player.x dx = obj.x - player.x
dy = other.y - player.y dy = obj.y - player.y
if dx > world.width // 2: if zone.toroidal:
dx -= world.width if dx > zone.width // 2:
elif dx < -(world.width // 2): dx -= zone.width
dx += world.width elif dx < -(zone.width // 2):
if dy > world.height // 2: dx += zone.width
dy -= world.height if dy > zone.height // 2:
elif dy < -(world.height // 2): dy -= zone.height
dy += world.height elif dy < -(zone.height // 2):
dy += zone.height
rel_x = dx + center_x rel_x = dx + center_x
rel_y = dy + center_y rel_y = dy + center_y
# Check if within viewport bounds # Check if within viewport bounds
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT: if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
other_player_positions.append((rel_x, rel_y)) entity_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))
# Build the output with ANSI coloring # Build the output with ANSI coloring
# priority: player @ > other players * > mobs * > effects > terrain # 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 # Check if this is the player's position
if x == center_x and y == center_y: if x == center_x and y == center_y:
line.append(colorize_terrain("@", player.color_depth)) line.append(colorize_terrain("@", player.color_depth))
# Check if this is another player's position # Check if this is another entity's position
elif (x, y) in other_player_positions or (x, y) in mob_positions: elif (x, y) in entity_positions:
line.append(colorize_terrain("*", player.color_depth)) line.append(colorize_terrain("*", player.color_depth))
else: else:
# Check for active effects at this world position # 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.x - half_width + x,
player.y - half_height + y, player.y - half_height + y,
) )

View file

@ -3,6 +3,7 @@
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.mobs import mob_templates, spawn_mob from mudlib.mobs import mob_templates, spawn_mob
from mudlib.player import Player from mudlib.player import Player
from mudlib.zone import Zone
async def cmd_spawn(player: Player, args: str) -> None: 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") await player.send(f"Unknown mob type: {name}\r\nAvailable: {available}\r\n")
return 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") await player.send(f"A {mob.name} appears!\r\n")

View file

@ -3,9 +3,9 @@
import tomllib import tomllib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any
from mudlib.entity import Mob from mudlib.entity import Mob
from mudlib.zone import Zone
@dataclass @dataclass
@ -48,10 +48,21 @@ def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
return templates return templates
def spawn_mob(template: MobTemplate, x: int, y: int) -> Mob: def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
"""Create a Mob instance from a template at the given position.""" """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( mob = Mob(
name=template.name, name=template.name,
location=zone,
x=x, x=x,
y=y, y=y,
pl=template.pl, pl=template.pl,
@ -72,28 +83,44 @@ def despawn_mob(mob: Mob) -> None:
def get_nearby_mob( 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: ) -> Mob | None:
"""Find the closest alive mob matching name within range. """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: Mob | None = None
best_dist = float("inf") best_dist = float("inf")
for mob in mobs: # Get all nearby objects from the zone
if not mob.alive or mob.name != name: 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 continue
dx = abs(mob.x - x) # Calculate wrapping-aware distance to find closest
dy = abs(mob.y - y) dx = abs(obj.x - x)
dx = min(dx, world.width - dx) dy = abs(obj.y - y)
dy = min(dy, world.height - dy) if zone.toroidal:
dx = min(dx, zone.width - dx)
dy = min(dy, zone.height - dy)
if dx <= range_ and dy <= range_:
dist = dx + dy dist = dx + dy
if dist < best_dist: if dist < best_dist:
best = mob best = obj
best_dist = dist best_dist = dist
return best return best

View file

@ -140,9 +140,6 @@ def test_direction_deltas(direction, expected_delta):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_movement_updates_position(player, test_zone): async def test_movement_updates_position(player, test_zone):
"""Test that movement updates player position when passable.""" """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 # Clear players registry to avoid test pollution
from mudlib.player import players 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 @pytest.mark.asyncio
async def test_movement_sends_departure_message(player, test_zone): async def test_movement_sends_departure_message(player, test_zone):
"""Test that movement sends departure message to nearby players.""" """Test that movement sends departure message to nearby players."""
look.world = test_zone
# Create another player in the area # Create another player in the area
other_writer = MagicMock() other_writer = MagicMock()
other_writer.write = MagicMock() other_writer.write = MagicMock()
@ -208,8 +203,6 @@ async def test_movement_sends_departure_message(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_arrival_message_uses_opposite_direction(player, test_zone): async def test_arrival_message_uses_opposite_direction(player, test_zone):
"""Test that arrival messages use the opposite direction.""" """Test that arrival messages use the opposite direction."""
look.world = test_zone
# Create another player at the destination # Create another player at the destination
other_writer = MagicMock() other_writer = MagicMock()
other_writer.write = MagicMock() other_writer.write = MagicMock()
@ -238,9 +231,6 @@ async def test_arrival_message_uses_opposite_direction(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_look_command_sends_viewport(player, test_zone): async def test_look_command_sends_viewport(player, test_zone):
"""Test that look command sends the viewport to the player.""" """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, "") await look.cmd_look(player, "")
assert player.writer.write.called assert player.writer.write.called
@ -249,8 +239,6 @@ async def test_look_command_sends_viewport(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_look_command_shows_player_at_center(player, test_zone): async def test_look_command_shows_player_at_center(player, test_zone):
"""Test that look command shows player @ at center.""" """Test that look command shows player @ at center."""
look.world = test_zone
await look.cmd_look(player, "") await look.cmd_look(player, "")
# Check that the output contains the @ symbol for the 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 @pytest.mark.asyncio
async def test_look_command_shows_other_players(player, test_zone): async def test_look_command_shows_other_players(player, test_zone):
"""Test that look command shows other players as *.""" """Test that look command shows other players as *."""
look.world = test_zone
# Create another player in the viewport # Create another player in the viewport
other_player = Player( other_player = Player(
name="OtherPlayer", name="OtherPlayer",
@ -290,8 +276,6 @@ async def test_look_command_shows_other_players(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_look_shows_effects_on_viewport(player, test_zone): async def test_look_shows_effects_on_viewport(player, test_zone):
"""Test that active effects overlay on the viewport.""" """Test that active effects overlay on the viewport."""
look.world = test_zone
from mudlib.player import players from mudlib.player import players
players.clear() players.clear()
@ -315,8 +299,6 @@ async def test_look_shows_effects_on_viewport(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_effects_dont_override_player_marker(player, test_zone): async def test_effects_dont_override_player_marker(player, test_zone):
"""Effects at the player's position should not hide the @ marker.""" """Effects at the player's position should not hide the @ marker."""
look.world = test_zone
from mudlib.player import players from mudlib.player import players
players.clear() players.clear()

View file

@ -51,34 +51,6 @@ def test_zone():
return 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 @pytest.fixture
def mock_writer(): def mock_writer():
writer = MagicMock() writer = MagicMock()
@ -147,11 +119,11 @@ def dummy_toml(tmp_path):
class TestMobAttackAI: class TestMobAttackAI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_attacks_when_idle_and_cooldown_expired( 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.""" """Mob attacks when encounter is IDLE and cooldown has expired."""
template = load_mob_template(goblin_toml) 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 mob.next_action_at = 0.0 # cooldown expired
encounter = start_encounter(player, mob) encounter = start_encounter(player, mob)
@ -164,10 +136,12 @@ class TestMobAttackAI:
assert encounter.current_move is not None assert encounter.current_move is not None
@pytest.mark.asyncio @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.""" """Mob only picks moves from its moves list."""
template = load_mob_template(goblin_toml) 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 mob.next_action_at = 0.0
encounter = start_encounter(player, mob) encounter = start_encounter(player, mob)
@ -179,10 +153,12 @@ class TestMobAttackAI:
assert encounter.current_move.name in mob.moves assert encounter.current_move.name in mob.moves
@pytest.mark.asyncio @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.""" """Mob skips attack when stamina is too low for any move."""
template = load_mob_template(goblin_toml) 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.stamina = 0.0
mob.next_action_at = 0.0 mob.next_action_at = 0.0
@ -195,10 +171,10 @@ class TestMobAttackAI:
assert encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
@pytest.mark.asyncio @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.""" """Mob doesn't act when cooldown hasn't expired."""
template = load_mob_template(goblin_toml) 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 mob.next_action_at = time.monotonic() + 100.0 # far in the future
encounter = start_encounter(player, mob) encounter = start_encounter(player, mob)
@ -210,10 +186,12 @@ class TestMobAttackAI:
assert encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
@pytest.mark.asyncio @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.""" """Mob swaps attacker/defender roles when it attacks as defender."""
template = load_mob_template(goblin_toml) 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 mob.next_action_at = 0.0
# Player is attacker, mob is defender # Player is attacker, mob is defender
@ -227,10 +205,10 @@ class TestMobAttackAI:
assert encounter.defender is player assert encounter.defender is player
@pytest.mark.asyncio @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.""" """Mob not in combat does nothing."""
template = load_mob_template(goblin_toml) 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 mob.next_action_at = 0.0
await process_mobs(moves) await process_mobs(moves)
@ -239,10 +217,12 @@ class TestMobAttackAI:
assert get_encounter(mob) is None assert get_encounter(mob) is None
@pytest.mark.asyncio @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.""" """Mob sets next_action_at after attacking."""
template = load_mob_template(goblin_toml) 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 mob.next_action_at = 0.0
start_encounter(player, mob) start_encounter(player, mob)
@ -262,12 +242,12 @@ class TestMobDefenseAI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_defends_during_telegraph( 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.""" """Mob attempts defense during TELEGRAPH phase."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
# Give the mob defense moves # 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.moves = ["punch left", "dodge left", "dodge right"]
mob.next_action_at = 0.0 mob.next_action_at = 0.0
@ -285,11 +265,11 @@ class TestMobDefenseAI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_skips_defense_when_already_defending( 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.""" """Mob doesn't double-defend if already has pending_defense."""
template = load_mob_template(goblin_toml) 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.moves = ["dodge left", "dodge right"]
mob.next_action_at = 0.0 mob.next_action_at = 0.0
@ -308,11 +288,11 @@ class TestMobDefenseAI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_no_defense_without_defense_moves( 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.""" """Mob with no defense moves in its list can't defend."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0) mob = spawn_mob(template, 0, 0, test_zone)
# Only attack moves # Only attack moves
mob.moves = ["punch left", "punch right", "sweep"] mob.moves = ["punch left", "punch right", "sweep"]
mob.next_action_at = time.monotonic() + 100.0 # prevent attacking mob.next_action_at = time.monotonic() + 100.0 # prevent attacking
@ -328,11 +308,11 @@ class TestMobDefenseAI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dummy_never_fights_back( 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.""" """Training dummy with empty moves never attacks or defends."""
template = load_mob_template(dummy_toml) 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 mob.next_action_at = 0.0
encounter = start_encounter(player, mob) encounter = start_encounter(player, mob)

View file

@ -49,29 +49,6 @@ def test_zone():
return 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 @pytest.fixture
def goblin_toml(tmp_path): def goblin_toml(tmp_path):
"""Create a goblin TOML file.""" """Create a goblin TOML file."""
@ -125,9 +102,9 @@ class TestLoadTemplate:
class TestSpawnDespawn: 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) 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 isinstance(mob, Mob)
assert mob.name == "goblin" assert mob.name == "goblin"
assert mob.x == 10 assert mob.x == 10
@ -138,72 +115,67 @@ class TestSpawnDespawn:
assert mob.moves == ["punch left", "punch right", "sweep"] assert mob.moves == ["punch left", "punch right", "sweep"]
assert mob.alive is True assert mob.alive is True
assert mob in mobs 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) template = load_mob_template(goblin_toml)
spawn_mob(template, 0, 0) spawn_mob(template, 0, 0, test_zone)
spawn_mob(template, 5, 5) spawn_mob(template, 5, 5, test_zone)
assert len(mobs) == 2 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) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0) mob = spawn_mob(template, 0, 0, test_zone)
despawn_mob(mob) despawn_mob(mob)
assert mob not in mobs assert mob not in mobs
assert mob.alive is False 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) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0) mob = spawn_mob(template, 0, 0, test_zone)
despawn_mob(mob) despawn_mob(mob)
assert mob.alive is False assert mob.alive is False
class TestGetNearbyMob: class TestGetNearbyMob:
@pytest.fixture def test_finds_by_name_within_range(self, goblin_toml, test_zone):
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):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 5, 5) mob = spawn_mob(template, 5, 5, test_zone)
found = get_nearby_mob("goblin", 3, 3, mock_world) found = get_nearby_mob("goblin", 3, 3, test_zone)
assert found is mob 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) template = load_mob_template(goblin_toml)
spawn_mob(template, 100, 100) spawn_mob(template, 100, 100, test_zone)
found = get_nearby_mob("goblin", 0, 0, mock_world) found = get_nearby_mob("goblin", 0, 0, test_zone)
assert found is None 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) template = load_mob_template(goblin_toml)
spawn_mob(template, 5, 5) spawn_mob(template, 5, 5, test_zone)
found = get_nearby_mob("dragon", 3, 3, mock_world) found = get_nearby_mob("dragon", 3, 3, test_zone)
assert found is None 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) template = load_mob_template(goblin_toml)
spawn_mob(template, 8, 8) spawn_mob(template, 8, 8, test_zone)
close_mob = spawn_mob(template, 1, 1) close_mob = spawn_mob(template, 1, 1, test_zone)
found = get_nearby_mob("goblin", 0, 0, mock_world) found = get_nearby_mob("goblin", 0, 0, test_zone)
assert found is close_mob 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) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 5, 5) mob = spawn_mob(template, 5, 5, test_zone)
mob.alive = False 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 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.""" """Mob near world edge is close to player at opposite edge."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 254, 254) mob = spawn_mob(template, 254, 254, test_zone)
found = get_nearby_mob("goblin", 2, 2, mock_world, range_=10) found = get_nearby_mob("goblin", 2, 2, test_zone, range_=10)
assert found is mob assert found is mob
@ -224,8 +196,10 @@ def mock_reader():
@pytest.fixture @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 = 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 players[p.name] = p
return p return p
@ -252,10 +226,12 @@ def punch_right(moves):
class TestTargetResolution: class TestTargetResolution:
@pytest.mark.asyncio @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.""" """do_attack with mob name finds and engages the mob."""
template = load_mob_template(goblin_toml) 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) await combat_commands.do_attack(player, "goblin", punch_right)
@ -266,11 +242,11 @@ class TestTargetResolution:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_attack_prefers_player_over_mob( 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.""" """When a player and mob share a name, player takes priority."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
spawn_mob(template, 0, 0) spawn_mob(template, 0, 0, test_zone)
# Create a player named "goblin" # Create a player named "goblin"
goblin_player = Player( goblin_player = Player(
@ -280,6 +256,8 @@ class TestTargetResolution:
reader=mock_reader, reader=mock_reader,
writer=mock_writer, writer=mock_writer,
) )
goblin_player.location = test_zone
test_zone._contents.append(goblin_player)
players["goblin"] = goblin_player players["goblin"] = goblin_player
await combat_commands.do_attack(player, "goblin", punch_right) await combat_commands.do_attack(player, "goblin", punch_right)
@ -289,10 +267,12 @@ class TestTargetResolution:
assert encounter.defender is goblin_player assert encounter.defender is goblin_player
@pytest.mark.asyncio @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.""" """Mob outside viewport range is not found as target."""
template = load_mob_template(goblin_toml) 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) 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) assert any("need a target" in msg.lower() for msg in messages)
@pytest.mark.asyncio @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).""" """Mob doesn't get mode_stack push (it has no mode_stack)."""
template = load_mob_template(goblin_toml) 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) await combat_commands.do_attack(player, "goblin", punch_right)
@ -337,16 +319,15 @@ class TestViewportRendering:
return w return w
@pytest.mark.asyncio @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.""" """Mob within viewport renders as * in look output."""
import mudlib.commands.look as look_mod import mudlib.commands.look as look_mod
old = look_mod.world
look_mod.world = look_world
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
# Place mob 2 tiles to the right of the player # 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, "") await look_mod.cmd_look(player, "")
@ -355,21 +336,16 @@ class TestViewportRendering:
# Output should contain a * character # Output should contain a * character
assert "*" in output assert "*" in output
look_mod.world = old
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_outside_viewport_not_rendered( 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.""" """Mob outside viewport bounds is not rendered."""
import mudlib.commands.look as look_mod import mudlib.commands.look as look_mod
old = look_mod.world
look_mod.world = look_world
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
# Place mob far away # Place mob far away
spawn_mob(template, 100, 100) spawn_mob(template, 100, 100, test_zone)
await look_mod.cmd_look(player, "") await look_mod.cmd_look(player, "")
@ -382,18 +358,15 @@ class TestViewportRendering:
stripped = re.sub(r"\033\[[0-9;]*m", "", stripped) stripped = re.sub(r"\033\[[0-9;]*m", "", stripped)
assert "*" not in stripped assert "*" not in stripped
look_mod.world = old
@pytest.mark.asyncio @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.""" """Dead mob (alive=False) not rendered in viewport."""
import mudlib.commands.look as look_mod import mudlib.commands.look as look_mod
old = look_mod.world
look_mod.world = look_world
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 2, 0) mob = spawn_mob(template, 2, 0, test_zone)
mob.alive = False mob.alive = False
await look_mod.cmd_look(player, "") await look_mod.cmd_look(player, "")
@ -404,17 +377,15 @@ class TestViewportRendering:
stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace("\r\n", "") stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace("\r\n", "")
assert "*" not in stripped assert "*" not in stripped
look_mod.world = old
# --- Phase 4: mob defeat tests --- # --- Phase 4: mob defeat tests ---
class TestMobDefeat: class TestMobDefeat:
@pytest.fixture @pytest.fixture
def goblin_mob(self, goblin_toml): def goblin_mob(self, goblin_toml, test_zone):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
return spawn_mob(template, 0, 0) return spawn_mob(template, 0, 0, test_zone)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right): async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):

View file

@ -69,13 +69,6 @@ async def test_shell_greets_and_accepts_commands(temp_db):
writer.drain = AsyncMock() writer.drain = AsyncMock()
writer.close = MagicMock() 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" readline = "mudlib.server.readline2"
with patch(readline, new_callable=AsyncMock) as mock_readline: with patch(readline, new_callable=AsyncMock) as mock_readline:
# Simulate: name, create account (y), password, confirm password, look, quit # 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("Welcome" in call for call in calls)
assert any("TestPlayer" in call for call in calls) assert any("TestPlayer" in call for call in calls)
writer.close.assert_called() writer.close.assert_called()
finally:
mudlib.commands.look.world = original_world
@pytest.mark.asyncio @pytest.mark.asyncio
@ -135,13 +126,6 @@ async def test_shell_handles_quit(temp_db):
writer.drain = AsyncMock() writer.drain = AsyncMock()
writer.close = MagicMock() 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" readline = "mudlib.server.readline2"
with patch(readline, new_callable=AsyncMock) as mock_readline: with patch(readline, new_callable=AsyncMock) as mock_readline:
# Simulate: name, create account (y), password, confirm password, quit # 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] calls = [str(call) for call in writer.write.call_args_list]
assert any("Goodbye" in call for call in calls) assert any("Goodbye" in call for call in calls)
writer.close.assert_called() writer.close.assert_called()
finally:
mudlib.commands.look.world = original_world
def test_load_world_config(): def test_load_world_config():

View file

@ -7,6 +7,7 @@ import pytest
from mudlib.commands.spawn import cmd_spawn from mudlib.commands.spawn import cmd_spawn
from mudlib.mobs import MobTemplate, mob_templates, mobs from mudlib.mobs import MobTemplate, mob_templates, mobs
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -35,8 +36,25 @@ def mock_reader():
@pytest.fixture @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 = 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 players[p.name] = p
return p return p

View file

@ -37,15 +37,6 @@ def test_zone():
return 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 @pytest.fixture
def mock_writer(): def mock_writer():
writer = MagicMock() writer = MagicMock()