diff --git a/src/mudlib/if_session.py b/src/mudlib/if_session.py index c50dce9..9aa0210 100644 --- a/src/mudlib/if_session.py +++ b/src/mudlib/if_session.py @@ -236,8 +236,12 @@ class IFSession: async def broadcast_to_spectators(player: "Player", message: str) -> None: """Send message to all other players at the same location.""" - from mudlib.player import players + from mudlib.entity import Entity + from mudlib.zone import Zone - for other in players.values(): - if other.name != player.name and other.x == player.x and other.y == player.y: - await other.send(message) + # Use zone spatial query to find all objects at player's exact coordinates + assert isinstance(player.location, Zone), "Player must be in a zone" + for obj in player.location.contents_at(player.x, player.y): + # Filter for Entity instances (players/mobs) and exclude self + if obj is not player and isinstance(obj, Entity): + await obj.send(message) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 3e9be20..cfed323 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -53,8 +53,7 @@ TICK_RATE = 10 # ticks per second TICK_INTERVAL = 1.0 / TICK_RATE AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves -# Module-level world instance, generated once at startup -_world: World | None = None +# Module-level overworld zone instance, created once at startup _overworld: Zone | None = None @@ -211,7 +210,6 @@ async def shell( _reader = cast(telnetlib3.TelnetReaderUnicode, reader) _writer = cast(telnetlib3.TelnetWriterUnicode, writer) - assert _world is not None, "World must be initialized before accepting connections" assert _overworld is not None, ( "Overworld zone must be initialized before accepting connections" ) @@ -401,7 +399,7 @@ async def shell( async def run_server() -> None: """Start the MUD telnet server.""" - global _world, _overworld + global _overworld # Initialize database data_dir = pathlib.Path(__file__).resolve().parents[2] / "data" @@ -420,14 +418,14 @@ async def run_server() -> None: world_cfg["height"], ) t0 = time.monotonic() - _world = World( + world = World( seed=world_cfg["seed"], width=world_cfg["width"], height=world_cfg["height"], cache_dir=cache_dir, ) elapsed = time.monotonic() - t0 - if _world.cached: + if world.cached: log.info("world loaded from cache in %.2fs", elapsed) else: log.info("world generated in %.2fs (cached for next run)", elapsed) @@ -435,12 +433,12 @@ async def run_server() -> None: # Create overworld zone from generated terrain _overworld = Zone( name="overworld", - width=_world.width, - height=_world.height, - terrain=_world.terrain, + width=world.width, + height=world.height, + terrain=world.terrain, toroidal=True, ) - log.info("created overworld zone (%dx%d, toroidal)", _world.width, _world.height) + log.info("created overworld zone (%dx%d, toroidal)", world.width, world.height) # Load content-defined commands from TOML files content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands" diff --git a/tests/test_if_spectator.py b/tests/test_if_spectator.py index aceb07f..4235108 100644 --- a/tests/test_if_spectator.py +++ b/tests/test_if_spectator.py @@ -6,6 +6,7 @@ import pytest from mudlib.if_session import IFResponse from mudlib.player import Player, players +from mudlib.zone import Zone @pytest.fixture @@ -30,33 +31,47 @@ def clear_players(): @pytest.fixture -def player_a(): +def test_zone(): + """Create a test zone for spatial queries.""" + # Create a small test zone with passable terrain + terrain = [["."] * 20 for _ in range(20)] + return Zone(name="test", width=20, height=20, terrain=terrain, toroidal=False) + + +@pytest.fixture +def player_a(test_zone): """Player A at (5, 5) who will be playing IF.""" writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() reader = MagicMock() - return Player(name="PlayerA", x=5, y=5, reader=reader, writer=writer) + return Player( + name="PlayerA", location=test_zone, x=5, y=5, reader=reader, writer=writer + ) @pytest.fixture -def player_b(): +def player_b(test_zone): """Player B at (5, 5) who will be spectating.""" writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() reader = MagicMock() - return Player(name="PlayerB", x=5, y=5, reader=reader, writer=writer) + return Player( + name="PlayerB", location=test_zone, x=5, y=5, reader=reader, writer=writer + ) @pytest.fixture -def player_c(): +def player_c(test_zone): """Player C at different coords (10, 10).""" writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() reader = MagicMock() - return Player(name="PlayerC", x=10, y=10, reader=reader, writer=writer) + return Player( + name="PlayerC", location=test_zone, x=10, y=10, reader=reader, writer=writer + ) @pytest.mark.asyncio @@ -169,14 +184,16 @@ async def test_broadcast_to_spectators_skips_self(player_a, player_b): @pytest.mark.asyncio -async def test_multiple_spectators(player_a, player_b): +async def test_multiple_spectators(player_a, player_b, test_zone): """Multiple spectators at same location all see IF output.""" # Create a third player at same location writer_d = MagicMock() writer_d.write = MagicMock() writer_d.drain = AsyncMock() reader_d = MagicMock() - player_d = Player(name="PlayerD", x=5, y=5, reader=reader_d, writer=writer_d) + player_d = Player( + name="PlayerD", location=test_zone, x=5, y=5, reader=reader_d, writer=writer_d + ) # Register all players players[player_a.name] = player_a diff --git a/tests/test_play_command.py b/tests/test_play_command.py index f856a33..3950bf2 100644 --- a/tests/test_play_command.py +++ b/tests/test_play_command.py @@ -4,6 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from mudlib.zone import Zone + @pytest.fixture def mock_writer(): @@ -14,10 +16,17 @@ def mock_writer(): @pytest.fixture -def player(mock_writer): +def test_zone(): + """Create a test zone for spatial queries.""" + terrain = [["."] * 20 for _ in range(20)] + return Zone(name="test", width=20, height=20, terrain=terrain, toroidal=False) + + +@pytest.fixture +def player(mock_writer, test_zone): from mudlib.player import Player - return Player(name="tester", x=5, y=5, writer=mock_writer) + return Player(name="tester", location=test_zone, x=5, y=5, writer=mock_writer) def test_play_command_registered(): diff --git a/tests/test_server.py b/tests/test_server.py index 6b9571f..60f7cdf 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -60,7 +60,6 @@ async def test_shell_greets_and_accepts_commands(temp_db): zone = Zone( name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True ) - server._world = world server._overworld = zone reader = AsyncMock() @@ -94,7 +93,6 @@ async def test_shell_handles_eof(): zone = Zone( name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True ) - server._world = world server._overworld = zone reader = AsyncMock() @@ -117,7 +115,6 @@ async def test_shell_handles_quit(temp_db): zone = Zone( name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True ) - server._world = world server._overworld = zone reader = AsyncMock()