diff --git a/src/mudlib/commands/movement.py b/src/mudlib/commands/movement.py index 59a5ac4..4842737 100644 --- a/src/mudlib/commands/movement.py +++ b/src/mudlib/commands/movement.py @@ -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 diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 5693c23..e9a57d3 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -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 diff --git a/tests/test_combat_commands.py b/tests/test_combat_commands.py index 2dd2f5f..2f9d01e 100644 --- a/tests/test_combat_commands.py +++ b/tests/test_combat_commands.py @@ -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 diff --git a/tests/test_commands.py b/tests/test_commands.py index 0c6d86b..61df505 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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 diff --git a/tests/test_fly.py b/tests/test_fly.py index e68c600..53c61a3 100644 --- a/tests/test_fly.py +++ b/tests/test_fly.py @@ -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 diff --git a/tests/test_mob_ai.py b/tests/test_mob_ai.py index f8382f7..1fab528 100644 --- a/tests/test_mob_ai.py +++ b/tests/test_mob_ai.py @@ -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 diff --git a/tests/test_mobs.py b/tests/test_mobs.py index b4a5ed4..aaf52b9 100644 --- a/tests/test_mobs.py +++ b/tests/test_mobs.py @@ -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.""" diff --git a/tests/test_rest.py b/tests/test_rest.py index 40a5407..f5a8c6d 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -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 diff --git a/tests/test_variant_prefix.py b/tests/test_variant_prefix.py index 1ad890a..775430f 100644 --- a/tests/test_variant_prefix.py +++ b/tests/test_variant_prefix.py @@ -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