Migrate movement to use player.location (Zone)

Movement commands now access the zone through player.location instead of
a module-level world variable. send_nearby_message uses
zone.contents_near() to find nearby entities, eliminating the need for
the global players dict and manual distance calculations.

Tests updated to create zones and add entities via location assignment.
This commit is contained in:
Jared Miller 2026-02-11 19:28:27 -05:00
parent 66c6e1ebd4
commit 404a1cdf0c
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
9 changed files with 232 additions and 137 deletions

View file

@ -1,13 +1,9 @@
"""Movement commands for navigating the world."""
from typing import Any
from mudlib.commands import CommandDefinition, register
from mudlib.entity import Entity
from mudlib.player import Player, players
# World instance will be injected by the server
world: Any = None
from mudlib.player import Player
from mudlib.zone import Zone
# Direction mappings: command -> (dx, dy)
DIRECTIONS: dict[str, tuple[int, int]] = {
@ -66,10 +62,12 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
dy: Y delta
direction_name: Full name of the direction for messages
"""
target_x, target_y = world.wrap(player.x + dx, player.y + dy)
zone = player.location
assert isinstance(zone, Zone), "Player must be in a zone to move"
target_x, target_y = zone.wrap(player.x + dx, player.y + dy)
# Check if the target is passable
if not world.is_passable(target_x, target_y):
if not zone.is_passable(target_x, target_y):
await player.send("You can't go that way.\r\n")
return
@ -106,17 +104,11 @@ async def send_nearby_message(entity: Entity, x: int, y: int, message: str) -> N
# For now, use a simple viewport range (could be configurable)
viewport_range = 10
for other in players.values():
if other.name == entity.name:
continue
# Check if other player is within viewport range (wrapping)
dx_dist = abs(other.x - x)
dy_dist = abs(other.y - y)
dx_dist = min(dx_dist, world.width - dx_dist)
dy_dist = min(dy_dist, world.height - dy_dist)
if dx_dist <= viewport_range and dy_dist <= viewport_range:
await other.send(message)
zone = entity.location
assert isinstance(zone, Zone), "Entity must be in a zone to send nearby messages"
for obj in zone.contents_near(x, y, viewport_range):
if obj is not entity and isinstance(obj, Entity):
await obj.send(message)
# Define individual movement command handlers

View file

@ -431,7 +431,6 @@ async def run_server() -> None:
# Inject world into command modules
mudlib.commands.fly.world = _world
mudlib.commands.look.world = _world
mudlib.commands.movement.world = _world
mudlib.combat.commands.world = _world
# Load content-defined commands from TOML files

View file

@ -6,11 +6,11 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands
from mudlib.combat.engine import active_encounters, get_encounter
from mudlib.combat.moves import load_moves
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
@ -23,16 +23,19 @@ def clear_state():
players.clear()
@pytest.fixture(autouse=True)
def mock_world():
"""Inject a mock world for send_nearby_message."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old = movement_mod.world
movement_mod.world = fake_world
yield fake_world
movement_mod.world = old
@pytest.fixture
def test_zone():
"""Create a test zone for players."""
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
@ -49,15 +52,19 @@ 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
@pytest.fixture
def target(mock_reader, mock_writer):
def target(mock_reader, mock_writer, test_zone):
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
t.location = test_zone
test_zone._contents.append(t)
players[t.name] = t
return t

View file

@ -9,6 +9,7 @@ from mudlib.commands import CommandDefinition, look, movement
from mudlib.effects import active_effects, add_effect
from mudlib.player import Player
from mudlib.render.ansi import RESET
from mudlib.zone import Zone
@pytest.fixture
@ -25,21 +26,26 @@ def mock_reader():
@pytest.fixture
def mock_world():
world = MagicMock()
world.width = 100
world.height = 100
world.is_passable = MagicMock(return_value=True)
world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100))
# Create a 21x11 viewport filled with "."
viewport = [["." for _ in range(21)] for _ in range(11)]
world.get_viewport = MagicMock(return_value=viewport)
return world
def test_zone():
# Create a 100x100 zone filled with passable terrain
terrain = [["." for _ in range(100)] for _ in range(100)]
zone = Zone(
name="testzone",
width=100,
height=100,
toroidal=True,
terrain=terrain,
impassable=set(), # All terrain is passable for tests
)
return zone
@pytest.fixture
def player(mock_reader, mock_writer):
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
def player(mock_reader, mock_writer, test_zone):
p = Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
return p
# Test command registration
@ -132,11 +138,10 @@ def test_direction_deltas(direction, expected_delta):
@pytest.mark.asyncio
async def test_movement_updates_position(player, mock_world):
async def test_movement_updates_position(player, test_zone):
"""Test that movement updates player position when passable."""
# Inject mock world into both movement and look modules
movement.world = mock_world
look.world = mock_world
# 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
@ -148,14 +153,15 @@ async def test_movement_updates_position(player, mock_world):
assert player.x == original_x
assert player.y == original_y - 1
assert mock_world.is_passable.called
@pytest.mark.asyncio
async def test_movement_blocked_by_impassable_terrain(player, mock_world, mock_writer):
async def test_movement_blocked_by_impassable_terrain(player, test_zone, mock_writer):
"""Test that movement is blocked by impassable terrain."""
mock_world.is_passable.return_value = False
movement.world = mock_world
# Make the target position impassable
target_y = player.y - 1
test_zone.terrain[target_y][player.x] = "^" # mountain
test_zone.impassable = {"^"}
original_x, original_y = player.x, player.y
await movement.move_north(player, "")
@ -171,10 +177,9 @@ async def test_movement_blocked_by_impassable_terrain(player, mock_world, mock_w
@pytest.mark.asyncio
async def test_movement_sends_departure_message(player, mock_world):
async def test_movement_sends_departure_message(player, test_zone):
"""Test that movement sends departure message to nearby players."""
movement.world = mock_world
look.world = mock_world
look.world = test_zone
# Create another player in the area
other_writer = MagicMock()
@ -183,6 +188,8 @@ async def test_movement_sends_departure_message(player, mock_world):
other_player = Player(
name="OtherPlayer", x=5, y=4, reader=MagicMock(), writer=other_writer
)
other_player.location = test_zone
test_zone._contents.append(other_player)
# Register both players
from mudlib.player import players
@ -199,10 +206,9 @@ async def test_movement_sends_departure_message(player, mock_world):
@pytest.mark.asyncio
async def test_arrival_message_uses_opposite_direction(player, mock_world):
async def test_arrival_message_uses_opposite_direction(player, test_zone):
"""Test that arrival messages use the opposite direction."""
movement.world = mock_world
look.world = mock_world
look.world = test_zone
# Create another player at the destination
other_writer = MagicMock()
@ -211,6 +217,8 @@ async def test_arrival_message_uses_opposite_direction(player, mock_world):
other_player = Player(
name="OtherPlayer", x=5, y=3, reader=MagicMock(), writer=other_writer
)
other_player.location = test_zone
test_zone._contents.append(other_player)
from mudlib.player import players
@ -228,20 +236,20 @@ async def test_arrival_message_uses_opposite_direction(player, mock_world):
# Test look command
@pytest.mark.asyncio
async def test_look_command_sends_viewport(player, mock_world):
async def test_look_command_sends_viewport(player, test_zone):
"""Test that look command sends the viewport to the player."""
look.world = mock_world
# look.py still uses module-level world, so inject test_zone
look.world = test_zone
await look.cmd_look(player, "")
assert mock_world.get_viewport.called
assert player.writer.write.called
@pytest.mark.asyncio
async def test_look_command_shows_player_at_center(player, mock_world):
async def test_look_command_shows_player_at_center(player, test_zone):
"""Test that look command shows player @ at center."""
look.world = mock_world
look.world = test_zone
await look.cmd_look(player, "")
@ -251,9 +259,9 @@ async def test_look_command_shows_player_at_center(player, mock_world):
@pytest.mark.asyncio
async def test_look_command_shows_other_players(player, mock_world):
async def test_look_command_shows_other_players(player, test_zone):
"""Test that look command shows other players as *."""
look.world = mock_world
look.world = test_zone
# Create another player in the viewport
other_player = Player(
@ -263,6 +271,8 @@ async def test_look_command_shows_other_players(player, mock_world):
reader=MagicMock(),
writer=MagicMock(),
)
other_player.location = test_zone
test_zone._contents.append(other_player)
from mudlib.player import players
@ -278,9 +288,9 @@ async def test_look_command_shows_other_players(player, mock_world):
@pytest.mark.asyncio
async def test_look_shows_effects_on_viewport(player, mock_world):
async def test_look_shows_effects_on_viewport(player, test_zone):
"""Test that active effects overlay on the viewport."""
look.world = mock_world
look.world = test_zone
from mudlib.player import players
@ -303,9 +313,9 @@ async def test_look_shows_effects_on_viewport(player, mock_world):
@pytest.mark.asyncio
async def test_effects_dont_override_player_marker(player, mock_world):
async def test_effects_dont_override_player_marker(player, test_zone):
"""Effects at the player's position should not hide the @ marker."""
look.world = mock_world
look.world = test_zone
from mudlib.player import players

View file

@ -5,9 +5,10 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import fly, look, movement
from mudlib.commands import fly, look
from mudlib.effects import active_effects
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture
@ -24,27 +25,32 @@ def mock_reader():
@pytest.fixture
def mock_world():
world = MagicMock()
world.width = 100
world.height = 100
world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100))
viewport = [["." for _ in range(21)] for _ in range(11)]
world.get_viewport = MagicMock(return_value=viewport)
return world
def test_zone():
terrain = [["." for _ in range(100)] for _ in range(100)]
zone = Zone(
name="testzone",
width=100,
height=100,
toroidal=True,
terrain=terrain,
impassable=set(),
)
return zone
@pytest.fixture
def player(mock_reader, mock_writer):
return Player(name="shmup", x=50, y=50, reader=mock_reader, writer=mock_writer)
def player(mock_reader, mock_writer, test_zone):
p = Player(name="shmup", x=50, y=50, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
return p
@pytest.fixture(autouse=True)
def clean_state(mock_world):
def clean_state(test_zone):
"""Clean global state before/after each test."""
fly.world = mock_world
look.world = mock_world
movement.world = mock_world
fly.world = test_zone
look.world = test_zone
players.clear()
active_effects.clear()
yield
@ -82,7 +88,7 @@ async def test_fly_toggles_off(player, mock_writer):
@pytest.mark.asyncio
async def test_fly_toggle_on_notifies_nearby(player):
async def test_fly_toggle_on_notifies_nearby(player, test_zone):
"""Others see liftoff message."""
players[player.name] = player
@ -96,6 +102,8 @@ async def test_fly_toggle_on_notifies_nearby(player):
reader=MagicMock(),
writer=other_writer,
)
other.location = test_zone
test_zone._contents.append(other)
players[other.name] = other
await fly.cmd_fly(player, "")
@ -105,7 +113,7 @@ async def test_fly_toggle_on_notifies_nearby(player):
@pytest.mark.asyncio
async def test_fly_toggle_off_notifies_nearby(player):
async def test_fly_toggle_off_notifies_nearby(player, test_zone):
"""Others see landing message."""
players[player.name] = player
player.flying = True
@ -120,6 +128,8 @@ async def test_fly_toggle_off_notifies_nearby(player):
reader=MagicMock(),
writer=other_writer,
)
other.location = test_zone
test_zone._contents.append(other)
players[other.name] = other
await fly.cmd_fly(player, "")
@ -279,13 +289,14 @@ async def test_fly_bad_direction_gives_error(player, mock_writer):
@pytest.mark.asyncio
async def test_fly_triggers_look(player, mock_world):
async def test_fly_triggers_look(player, test_zone):
"""Flying should auto-look at the destination."""
players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "east")
assert mock_world.get_viewport.called
# look was called (check that writer was written to)
assert player.writer.write.called
@pytest.mark.asyncio

View file

@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import (
@ -22,6 +21,7 @@ from mudlib.mobs import (
spawn_mob,
)
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
@ -36,21 +36,49 @@ def clear_state():
players.clear()
@pytest.fixture
def test_zone():
"""Create a test zone for entities."""
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(autouse=True)
def mock_world():
"""Inject a mock world for movement and combat commands."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old_movement = movement_mod.world
def inject_world_for_combat(test_zone):
"""Inject test_zone into combat commands (still uses module-level world)."""
old_combat = combat_commands.world
movement_mod.world = fake_world
combat_commands.world = fake_world
yield fake_world
movement_mod.world = old_movement
combat_commands.world = 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()
@ -65,8 +93,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

View file

@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import active_encounters, get_encounter
@ -20,6 +19,7 @@ from mudlib.mobs import (
spawn_mob,
)
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
@ -34,21 +34,44 @@ def clear_state():
players.clear()
@pytest.fixture
def test_zone():
"""Create a test zone for entities."""
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(autouse=True)
def mock_world():
"""Inject a mock world for movement and combat commands."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old_movement = movement_mod.world
def inject_world_for_combat(test_zone):
"""Inject test_zone into combat commands (still uses module-level world)."""
old_combat = combat_commands.world
movement_mod.world = fake_world
combat_commands.world = fake_world
yield fake_world
movement_mod.world = old_movement
combat_commands.world = 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."""

View file

@ -4,10 +4,10 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.commands.rest import cmd_rest
from mudlib.player import Player, players
from mudlib.resting import process_resting
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
@ -18,16 +18,19 @@ def clear_state():
players.clear()
@pytest.fixture(autouse=True)
def mock_world():
"""Inject a mock world for send_nearby_message."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old = movement_mod.world
movement_mod.world = fake_world
yield fake_world
movement_mod.world = old
@pytest.fixture
def test_zone():
"""Create a test zone for players."""
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
@ -44,15 +47,19 @@ 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
@pytest.fixture
def nearby_player(mock_reader, mock_writer):
def nearby_player(mock_reader, mock_writer, test_zone):
p = Player(name="Vegeta", 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

View file

@ -5,11 +5,11 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands
from mudlib.combat.engine import active_encounters
from mudlib.combat.moves import load_moves
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
@ -22,16 +22,28 @@ def clear_state():
players.clear()
@pytest.fixture
def test_zone():
"""Create a test zone for players."""
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(autouse=True)
def mock_world():
"""Inject a mock world for send_nearby_message."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old = movement_mod.world
movement_mod.world = fake_world
yield fake_world
movement_mod.world = old
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
@ -48,15 +60,19 @@ 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
@pytest.fixture
def target(mock_reader, mock_writer):
def target(mock_reader, mock_writer, test_zone):
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
t.location = test_zone
test_zone._contents.append(t)
players[t.name] = t
return t