Clean up global state, migrate broadcast_to_spectators to Zone

Removes dependency on global players dict for spatial queries by using
Zone.contents_at() for spectator lookup. Makes _world local to run_server()
since it's only used during initialization to create the overworld Zone.
Updates test fixtures to provide zones for spatial query tests.
This commit is contained in:
Jared Miller 2026-02-11 19:42:12 -05:00
parent f5646589b5
commit 957a411601
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 52 additions and 27 deletions

View file

@ -236,8 +236,12 @@ class IFSession:
async def broadcast_to_spectators(player: "Player", message: str) -> None: async def broadcast_to_spectators(player: "Player", message: str) -> None:
"""Send message to all other players at the same location.""" """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(): # Use zone spatial query to find all objects at player's exact coordinates
if other.name != player.name and other.x == player.x and other.y == player.y: assert isinstance(player.location, Zone), "Player must be in a zone"
await other.send(message) 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)

View file

@ -53,8 +53,7 @@ TICK_RATE = 10 # ticks per second
TICK_INTERVAL = 1.0 / TICK_RATE TICK_INTERVAL = 1.0 / TICK_RATE
AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves
# Module-level world instance, generated once at startup # Module-level overworld zone instance, created once at startup
_world: World | None = None
_overworld: Zone | None = None _overworld: Zone | None = None
@ -211,7 +210,6 @@ async def shell(
_reader = cast(telnetlib3.TelnetReaderUnicode, reader) _reader = cast(telnetlib3.TelnetReaderUnicode, reader)
_writer = cast(telnetlib3.TelnetWriterUnicode, writer) _writer = cast(telnetlib3.TelnetWriterUnicode, writer)
assert _world is not None, "World must be initialized before accepting connections"
assert _overworld is not None, ( assert _overworld is not None, (
"Overworld zone must be initialized before accepting connections" "Overworld zone must be initialized before accepting connections"
) )
@ -401,7 +399,7 @@ async def shell(
async def run_server() -> None: async def run_server() -> None:
"""Start the MUD telnet server.""" """Start the MUD telnet server."""
global _world, _overworld global _overworld
# Initialize database # Initialize database
data_dir = pathlib.Path(__file__).resolve().parents[2] / "data" data_dir = pathlib.Path(__file__).resolve().parents[2] / "data"
@ -420,14 +418,14 @@ async def run_server() -> None:
world_cfg["height"], world_cfg["height"],
) )
t0 = time.monotonic() t0 = time.monotonic()
_world = World( world = World(
seed=world_cfg["seed"], seed=world_cfg["seed"],
width=world_cfg["width"], width=world_cfg["width"],
height=world_cfg["height"], height=world_cfg["height"],
cache_dir=cache_dir, cache_dir=cache_dir,
) )
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
if _world.cached: if world.cached:
log.info("world loaded from cache in %.2fs", elapsed) log.info("world loaded from cache in %.2fs", elapsed)
else: else:
log.info("world generated in %.2fs (cached for next run)", elapsed) 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 # Create overworld zone from generated terrain
_overworld = Zone( _overworld = Zone(
name="overworld", name="overworld",
width=_world.width, width=world.width,
height=_world.height, height=world.height,
terrain=_world.terrain, terrain=world.terrain,
toroidal=True, 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 # Load content-defined commands from TOML files
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands" content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"

View file

@ -6,6 +6,7 @@ import pytest
from mudlib.if_session import IFResponse from mudlib.if_session import IFResponse
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture @pytest.fixture
@ -30,33 +31,47 @@ def clear_players():
@pytest.fixture @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.""" """Player A at (5, 5) who will be playing IF."""
writer = MagicMock() writer = MagicMock()
writer.write = MagicMock() writer.write = MagicMock()
writer.drain = AsyncMock() writer.drain = AsyncMock()
reader = MagicMock() 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 @pytest.fixture
def player_b(): def player_b(test_zone):
"""Player B at (5, 5) who will be spectating.""" """Player B at (5, 5) who will be spectating."""
writer = MagicMock() writer = MagicMock()
writer.write = MagicMock() writer.write = MagicMock()
writer.drain = AsyncMock() writer.drain = AsyncMock()
reader = MagicMock() 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 @pytest.fixture
def player_c(): def player_c(test_zone):
"""Player C at different coords (10, 10).""" """Player C at different coords (10, 10)."""
writer = MagicMock() writer = MagicMock()
writer.write = MagicMock() writer.write = MagicMock()
writer.drain = AsyncMock() writer.drain = AsyncMock()
reader = MagicMock() 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 @pytest.mark.asyncio
@ -169,14 +184,16 @@ async def test_broadcast_to_spectators_skips_self(player_a, player_b):
@pytest.mark.asyncio @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.""" """Multiple spectators at same location all see IF output."""
# Create a third player at same location # Create a third player at same location
writer_d = MagicMock() writer_d = MagicMock()
writer_d.write = MagicMock() writer_d.write = MagicMock()
writer_d.drain = AsyncMock() writer_d.drain = AsyncMock()
reader_d = MagicMock() 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 # Register all players
players[player_a.name] = player_a players[player_a.name] = player_a

View file

@ -4,6 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from mudlib.zone import Zone
@pytest.fixture @pytest.fixture
def mock_writer(): def mock_writer():
@ -14,10 +16,17 @@ def mock_writer():
@pytest.fixture @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 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(): def test_play_command_registered():

View file

@ -60,7 +60,6 @@ async def test_shell_greets_and_accepts_commands(temp_db):
zone = Zone( zone = Zone(
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
) )
server._world = world
server._overworld = zone server._overworld = zone
reader = AsyncMock() reader = AsyncMock()
@ -94,7 +93,6 @@ async def test_shell_handles_eof():
zone = Zone( zone = Zone(
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
) )
server._world = world
server._overworld = zone server._overworld = zone
reader = AsyncMock() reader = AsyncMock()
@ -117,7 +115,6 @@ async def test_shell_handles_quit(temp_db):
zone = Zone( zone = Zone(
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
) )
server._world = world
server._overworld = zone server._overworld = zone
reader = AsyncMock() reader = AsyncMock()