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:
parent
f5646589b5
commit
957a411601
5 changed files with 52 additions and 27 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue