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
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:

View file

@ -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,
)

View file

@ -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")

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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):

View file

@ -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():

View file

@ -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

View file

@ -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()