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

View file

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

View file

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

View file

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

View file

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