Compare commits

..

No commits in common. "8f5956df3d0fc5abc47e7bb45e0a8f1cc0a0399a" and "2c75d26d685348f51204a5f7b45b4a79e9241dae" have entirely different histories.

13 changed files with 51 additions and 2549 deletions

View file

@ -1,377 +0,0 @@
architecture plan — designing for programmability
====================================================
where we are and where we're going. we have a working telnet MUD with
procedural terrain, movement, viewport rendering, effects. combat, persistence,
session modes, editor, in-world programming — all ahead of us. this doc
captures what we should get right in the foundation so those features don't
require rewrites.
the core lesson from studying the ecosystem: the things that lasted (LPC
mudlibs, MOO worlds, evennia typeclasses) all share one trait — game content
is data that can be inspected and modified at runtime, not hardcoded logic.
the things that didn't last hardcoded everything.
the engine/content boundary
============================
the single most important architectural decision. everything downstream
depends on this.
what the engine provides (python code, changes require restart):
- telnet server, protocol handling, I/O
- the game loop (tick processing)
- terrain generation and world loading
- rendering pipeline (viewport, ANSI, effects)
- the command dispatcher (not the commands themselves)
- the mode stack mechanism
- the DSL interpreter/runtime
- persistence layer (sqlite)
- player session management
what content provides (data files + DSL, hot-reloadable):
- individual commands (move, look, attack, build...)
- combat moves, timing windows, damage formulas
- NPC behaviors and dialogue
- room/area descriptions and triggers
- IF games and puzzles
- item definitions and interactions
- channel definitions
- help text
the boundary: the engine loads and executes content. content never imports
engine internals directly — it uses a stable API surface. this is the LPC
model adapted for python.
why this matters now: our current commands are python functions that import
player.py and world/terrain.py directly. that's fine for bootstrapping but it
means every command is hardcoded. we need to decide: do commands stay as
python modules (evennia model) or become data-defined (MOO/LPC model)?
recommendation: HYBRID. core commands (movement, look, system admin) stay as
python. game commands (combat moves, social commands, builder tools, IF verbs)
are defined in a DSL or data format. the command registry already exists — we
just need it to accept both python handlers AND data-defined handlers.
the content definition format
==============================
commands, combat moves, NPC behaviors, room triggers — they all need a
definition format. options:
YAML/TOML (pure data):
works for: item stats, terrain configs, channel definitions
doesn't work for: anything with logic (combat timing, triggers, conditionals)
python modules (current approach):
works for: anything
doesn't work for: in-MUD editing, hot-reload without restart, non-programmer
builders
a DSL (domain-specific language):
works for: logic + data in a safe sandbox
this is what the dreambook calls for
the DSL doesn't need to exist yet. but the content format should be designed
so that when the DSL arrives, it can express the same things that data files
express now. that means:
- commands defined as declarations (name, aliases, help text) + a body
(what happens)
- combat moves as declarations (name, stamina cost, timing window, telegraph
message) + resolution logic
- triggers as event + condition + action triples
- descriptions as templates with conditional sections
for now: use YAML for declarations, python callables for bodies. the YAML
says WHAT something is, python says WHAT IT DOES. when the DSL arrives, the
python callables get replaced with DSL scripts. the YAML stays.
example of what a command definition might look like:
name: shout
aliases: [yell]
help: shout a message to nearby players
mode: normal
body: python:commands.social.shout
# later: body: dsl:shout.mud
example combat move:
name: roundhouse
type: attack
stamina_cost: 15
telegraph: "{attacker} shifts their weight to one leg"
timing_window_ms: 800
damage_pct: 12
countered_by: [duck, jump]
body: python:combat.moves.roundhouse
# later: body: dsl:combat/roundhouse.mud
command registry evolution
===========================
current: dict mapping strings to async python functions. simple, works.
next step: registry accepts CommandDefinition objects that carry metadata
(help text, required mode, permissions) alongside the handler. the handler
can be a python callable OR (later) a DSL script reference.
this is NOT a rewrite. it's wrapping the existing register() call:
instead of: register("shout", cmd_shout, aliases=["yell"])
become: register(CommandDefinition(name="shout", aliases=["yell"],
mode="normal", handler=cmd_shout))
the dispatcher checks the current session mode before executing. commands with
mode="combat" only work in combat mode. this gives us the mode stack for free.
loading: at startup, scan a commands/ directory for YAML definitions. each one
creates a CommandDefinition. python-backed commands register themselves as
they do now. both end up in the same registry.
the mode stack
==============
from the dreambook: normal, combat, editor, IF modes. modes filter what events
reach the player and what commands are available.
implementation: each player session has a stack of modes. the top mode
determines:
- which commands are available (by filtering the registry)
- which world events reach the player (chat, movement, combat, ambient)
- how output is formatted/routed
pushing a mode: entering combat pushes "combat" mode. commands tagged
mode="combat" become available. ambient chat gets buffered.
popping a mode: leaving combat pops back to normal. buffered messages get
summarized.
this maps directly to evennia's cmdset merging, but simpler. we don't need
set-theoretic operations — just a stack with mode tags on commands.
the editor mode is special: it captures ALL input (no command dispatch) and
routes it to an editor buffer. the editor is its own subsystem that sits on
top of the mode stack. jared's friend is building one with syntax highlighting
for telnetlib3 — we'll study that implementation when it's available.
what lives in files vs what lives in the database
==================================================
world definitions (terrain, rooms, area layouts): files (YAML/TOML, version
controlled)
command definitions: files (YAML + python/DSL, version controlled)
combat move definitions: files (YAML + python/DSL, version controlled)
NPC templates: files
item templates: files
player accounts and state: sqlite
player-created content (IF games, custom rooms): sqlite (but exportable to
files)
runtime state (positions, combat encounters, mob instances): memory only
the key insight: files are the source of truth for world content. sqlite is
the source of truth for player data. player-created content starts in sqlite
but can be exported to files (and potentially committed to a repo by admins).
working from the repo: edit YAML files, restart server (or hot-reload).
standard dev workflow.
working from in-MUD: edit via the editor mode, changes go to sqlite. admin
command to "publish" player content to files.
both workflows produce the same content format. the engine doesn't care where
a definition came from.
the entity model
================
current: Player is a dataclass. no mobs, no items, no NPCs yet.
the ecosystem shows two paths:
- deep inheritance (evennia: Object > Character > NPC, most traditional MUDs)
- composition/ECS (graphicmud: entity + components)
recommendation: start with simple classes, but design for composition. a
character (player or NPC) is a bag of:
- identity (name, description)
- position (x, y, current map)
- stats (PL, stamina, whatever the combat system needs)
- inventory (list of item refs)
- mode stack (for players)
- behavior (for NPCs — patrol, aggro, dialogue)
these could be separate objects composed together rather than a deep class
hierarchy. we don't need a full ECS framework — just don't put everything in
one god class.
Player and Mob should share a common interface (something that has a position,
can receive messages, can be targeted). whether that's a base class, a
protocol, or duck typing — keep it flexible.
the game loop
=============
from the dreambook: tick-based, 10 ticks/second.
current: no game loop yet. commands execute synchronously in the shell
coroutine.
the game loop is an async task that runs alongside the telnet server:
every tick (100ms):
1. drain input queues (player commands waiting to execute)
2. process combat (check timing windows, resolve hits)
3. run NPC AI (behavior tree ticks)
4. process effects (expire old ones, add new ones)
5. flush output buffers (send pending text to players)
player input goes into a queue, not directly to dispatch. this decouples input
from execution and gives combat its timing windows. "you typed dodge but the
timing window closed 50ms ago" — that's the tick checking the timestamp.
this is a significant change from the current direct-dispatch model. it should
happen before combat gets built, because combat depends on it.
making combat programmable
==========================
the dreambook describes DBZ-inspired timing combat. the ecosystem research
shows most MUDs hardcode combat. we want combat moves to be content, not
engine code.
a combat move is:
- metadata (name, type, stamina cost, damage formula)
- telegraph (what the opponent sees)
- timing window (how long the opponent has to react)
- valid counters (which defensive moves work)
- resolution (what happens on hit/miss/counter)
all of this can be data. the resolution logic is the only part that might need
scripting (DSL), and even that can start as a formula:
damage = attacker_pl * damage_pct * (1 - defense_modifier)
the combat ENGINE processes the state machine (idle > telegraph > window >
resolve). the combat CONTENT defines what moves exist and how they interact.
builders can create new moves by writing YAML (and later DSL scripts in the
editor).
timing windows, stamina costs, damage curves — all tunable from data files.
you can balance combat without touching python.
what to do now (priority order)
================================
1. CommandDefinition object
wrap the existing registry with metadata. this is small and unlocks
mode filtering, help text, permissions. do it before adding more commands.
2. game loop as async task
input queues + tick processing. do it before building combat.
effects system already exists — move its expiry into the tick.
3. mode stack on player sessions
simple stack of mode strings. command dispatcher checks mode.
do it before building editor or combat modes.
4. content loading from YAML
scan directories for definition files, create CommandDefinition objects.
python handlers still work alongside. do it before there are too many
hardcoded commands to migrate.
5. entity model
shared interface for Player and Mob. do it before building NPCs.
6. combat as content
moves defined in YAML, combat engine as a state machine.
depends on game loop and mode stack.
7. persistence
sqlite for player data. depends on entity model.
8. editor mode
depends on mode stack. study the telnetlib3 editor implementation.
9. DSL
replace python callables in content definitions.
depends on everything above being stable.
items 1-4 are foundation work. they're small individually but they shape
everything that follows. getting them right means combat, editor, persistence,
and eventually the DSL all slot in cleanly. getting them wrong means
rewriting the command system when we realize commands need metadata, rewriting
the game loop when we realize combat needs ticks, rewriting session handling
when we realize modes need a stack.
what we'll wish we did differently (predictions)
=================================================
things the ecosystem learned the hard way:
"we should have separated engine from content earlier"
every MUD that survived long-term has this separation. dikumud's zone files,
LPC's mudlib, MOO's verb system. we should establish the boundary now.
"we should have made the command registry richer"
help text, permissions, mode requirements, argument parsing — these all get
bolted on later if not designed in. CommandDefinition solves this.
"we should have had a game loop from the start"
direct command execution is simple but it doesn't compose with timing-based
combat, NPC AI, or periodic world events. the tick loop is fundamental.
note: when combat/NPC AI gets added to the game loop, add tick health
monitoring — log warnings when tick processing exceeds TICK_INTERVAL. the
skeleton loop handles overruns correctly (skips sleep, catches up) but
sustained overruns mean the tick rate is too ambitious for the workload.
"we should have designed entities for composition"
inheritance hierarchies (Item > Weapon > Sword > MagicSword) get brittle.
composition (entity with damage_component + magic_component) stays flexible.
"global mutable state is fine until it isn't"
our current global players dict and active_effects list work now. as the
system grows, these should migrate into a World or GameState object that
owns all runtime state. not urgent, but worth planning for.
"the DSL is the hardest part"
every MUD that tried in-world programming struggled with the language
design. MOO's language is powerful but complex. LPC requires learning
C-like syntax. MUSH softcode is cryptic. our DSL should be simple enough to
learn from a help file, powerful enough to express puzzles and combat moves.
that's the hardest design challenge in this project. defer it until we know
exactly what it needs to express.
closing
=======
the theme: make things data before making them code. if combat moves are data,
they can be edited from the MUD. if commands carry metadata, the mode stack
works. if the engine/content boundary is clean, the DSL can replace python
callables without touching the engine.
the architecture should grow by adding content types, not by adding engine
complexity. new combat moves, new commands, new NPC behaviors, new IF puzzles
— these should all be content that loads from definitions, not python modules
that get imported.
code
----
this document docs/how/architecture-plan.txt
dreambook DREAMBOOK.md
ecosystem research docs/how/mud-ecosystem.txt
landscape research docs/how/mudlib-landscape.txt

View file

@ -1,25 +1,15 @@
command system
==============
commands are registered as CommandDefinition objects with metadata.
from mudlib.commands import CommandDefinition, register
commands are registered in a simple dict mapping names to async handlers.
async def my_command(player: Player, args: str) -> None:
player.writer.write("hello\r\n")
await player.writer.drain()
register(CommandDefinition("mycommand", my_command, aliases=["mc"]))
register("mycommand", my_command, aliases=["mc"])
CommandDefinition fields:
name primary command name
handler async function(player, args)
aliases alternative names (default: [])
mode required player mode (default: "normal", "*" = any mode)
help help text (default: "")
dispatch parses input, looks up the definition, calls its handler:
dispatch parses input, looks up the handler, calls it:
await dispatch(player, "mycommand some args")
@ -53,17 +43,16 @@ adding commands
---------------
1. create src/mudlib/commands/yourcommand.py
2. import CommandDefinition and register from mudlib.commands
2. import register from mudlib.commands
3. define async handler(player, args)
4. call register(CommandDefinition(...))
4. call register() with name and aliases
5. import the module in server.py so registration runs at startup
code
----
src/mudlib/commands/__init__.py registry + dispatch + CommandDefinition
src/mudlib/commands/__init__.py registry + dispatch
src/mudlib/commands/movement.py direction commands
src/mudlib/commands/look.py look/l
src/mudlib/commands/fly.py fly
src/mudlib/commands/quit.py quit/q (mode="*")
src/mudlib/commands/quit.py quit/q
src/mudlib/player.py Player dataclass + registry

File diff suppressed because it is too large Load diff

View file

@ -28,14 +28,14 @@ packages = ["src/mudlib"]
[tool.ruff]
src = ["src"]
line-length = 88
exclude = ["repos", ".worktrees"]
exclude = ["repos"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pyright]
pythonVersion = "3.12"
exclude = ["repos", ".worktrees", ".venv"]
exclude = ["repos", ".venv"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View file

@ -1,38 +1,31 @@
"""Command registry and dispatcher."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from mudlib.player import Player
# Type alias for command handlers
CommandHandler = Callable[[Player, str], Awaitable[None]]
@dataclass
class CommandDefinition:
"""Metadata wrapper for a registered command."""
name: str
handler: CommandHandler
aliases: list[str] = field(default_factory=list)
mode: str = "normal"
help: str = ""
# Registry maps command names to handler functions
_registry: dict[str, CommandHandler] = {}
# Registry maps command names to definitions
_registry: dict[str, CommandDefinition] = {}
def register(defn: CommandDefinition) -> None:
"""Register a command definition with its aliases.
def register(
name: str, handler: CommandHandler, aliases: list[str] | None = None
) -> None:
"""Register a command handler with optional aliases.
Args:
defn: The command definition to register
name: The primary command name
handler: Async function that handles the command
aliases: Optional list of alternative names for the command
"""
_registry[defn.name] = defn
for alias in defn.aliases:
_registry[alias] = defn
_registry[name] = handler
if aliases:
for alias in aliases:
_registry[alias] = handler
async def dispatch(player: Player, raw_input: str) -> None:
@ -52,19 +45,13 @@ async def dispatch(player: Player, raw_input: str) -> None:
command = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
# Look up the definition
defn = _registry.get(command)
# Look up the handler
handler = _registry.get(command)
if defn is None:
if handler is None:
player.writer.write(f"Unknown command: {command}\r\n")
await player.writer.drain()
return
# Check mode restriction
if defn.mode != "*" and defn.mode != player.mode:
player.writer.write("You can't do that right now.\r\n")
await player.writer.drain()
return
# Execute the handler
await defn.handler(player, args)
await handler(player, args)

View file

@ -2,7 +2,7 @@
from typing import Any
from mudlib.commands import CommandDefinition, register
from mudlib.commands import register
from mudlib.commands.movement import DIRECTIONS, send_nearby_message
from mudlib.effects import add_effect
from mudlib.player import Player
@ -91,4 +91,4 @@ async def cmd_fly(player: Player, args: str) -> None:
await cmd_look(player, "")
register(CommandDefinition("fly", cmd_fly))
register("fly", cmd_fly)

View file

@ -2,7 +2,7 @@
from typing import Any
from mudlib.commands import CommandDefinition, register
from mudlib.commands import register
from mudlib.effects import get_effects_at
from mudlib.player import Player, players
from mudlib.render.ansi import RESET, colorize_terrain
@ -89,4 +89,4 @@ async def cmd_look(player: Player, args: str) -> None:
# Register the look command with its alias
register(CommandDefinition("look", cmd_look, aliases=["l"]))
register("look", cmd_look, aliases=["l"])

View file

@ -2,7 +2,7 @@
from typing import Any
from mudlib.commands import CommandDefinition, register
from mudlib.commands import register
from mudlib.player import Player, players
# World instance will be injected by the server
@ -154,11 +154,11 @@ async def move_southwest(player: Player, args: str) -> None:
# Register all movement commands with their aliases
register(CommandDefinition("north", move_north, aliases=["n"]))
register(CommandDefinition("south", move_south, aliases=["s"]))
register(CommandDefinition("east", move_east, aliases=["e"]))
register(CommandDefinition("west", move_west, aliases=["w"]))
register(CommandDefinition("northeast", move_northeast, aliases=["ne"]))
register(CommandDefinition("northwest", move_northwest, aliases=["nw"]))
register(CommandDefinition("southeast", move_southeast, aliases=["se"]))
register(CommandDefinition("southwest", move_southwest, aliases=["sw"]))
register("north", move_north, aliases=["n"])
register("south", move_south, aliases=["s"])
register("east", move_east, aliases=["e"])
register("west", move_west, aliases=["w"])
register("northeast", move_northeast, aliases=["ne"])
register("northwest", move_northwest, aliases=["nw"])
register("southeast", move_southeast, aliases=["se"])
register("southwest", move_southwest, aliases=["sw"])

View file

@ -1,6 +1,6 @@
"""Quit command for disconnecting from the server."""
from mudlib.commands import CommandDefinition, register
from mudlib.commands import register
from mudlib.player import Player, players
@ -21,4 +21,4 @@ async def cmd_quit(player: Player, args: str) -> None:
# Register the quit command with its aliases
register(CommandDefinition("quit", cmd_quit, aliases=["q"], mode="*"))
register("quit", cmd_quit, aliases=["q"])

View file

@ -1,6 +1,6 @@
"""Player state and registry."""
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Any
@ -14,12 +14,6 @@ class Player:
writer: Any # telnetlib3 TelnetWriter for sending output
reader: Any # telnetlib3 TelnetReader for reading input
flying: bool = False
mode_stack: list[str] = field(default_factory=lambda: ["normal"])
@property
def mode(self) -> str:
"""Current mode is the top of the stack."""
return self.mode_stack[-1]
# Global registry of connected players

View file

@ -4,7 +4,6 @@ import asyncio
import logging
import pathlib
import time
import tomllib
from typing import cast
import telnetlib3
@ -15,40 +14,17 @@ import mudlib.commands.fly
import mudlib.commands.look
import mudlib.commands.movement
import mudlib.commands.quit
from mudlib.effects import clear_expired
from mudlib.player import Player, players
from mudlib.world.terrain import World
log = logging.getLogger(__name__)
PORT = 6789
TICK_RATE = 10 # ticks per second
TICK_INTERVAL = 1.0 / TICK_RATE
# Module-level world instance, generated once at startup
_world: World | None = None
def load_world_config(world_name: str = "earth") -> dict:
"""Load world configuration from TOML file."""
worlds_dir = pathlib.Path(__file__).resolve().parents[2] / "worlds"
config_path = worlds_dir / world_name / "config.toml"
with open(config_path, "rb") as f:
return tomllib.load(f)
async def game_loop() -> None:
"""Run periodic game tasks at TICK_RATE ticks per second."""
log.info("game loop started (%d ticks/sec)", TICK_RATE)
while True:
t0 = asyncio.get_event_loop().time()
clear_expired()
elapsed = asyncio.get_event_loop().time() - t0
sleep_time = TICK_INTERVAL - elapsed
if sleep_time > 0:
await asyncio.sleep(sleep_time)
def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int, int]:
"""Find a passable tile starting from (start_x, start_y) and searching outward.
@ -163,21 +139,9 @@ async def run_server() -> None:
# Generate world once at startup (cached to build/ after first run)
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
config = load_world_config()
world_cfg = config["world"]
log.info(
"loading world (seed=%d, %dx%d)...",
world_cfg["seed"],
world_cfg["width"],
world_cfg["height"],
)
log.info("loading world (seed=42, 1000x1000)...")
t0 = time.monotonic()
_world = World(
seed=world_cfg["seed"],
width=world_cfg["width"],
height=world_cfg["height"],
cache_dir=cache_dir,
)
_world = World(seed=42, width=1000, height=1000, cache_dir=cache_dir)
elapsed = time.monotonic() - t0
if _world.cached:
log.info("world loaded from cache in %.2fs", elapsed)
@ -198,13 +162,10 @@ async def run_server() -> None:
)
log.info("listening on 127.0.0.1:%d", PORT)
loop_task = asyncio.create_task(game_loop())
try:
while True:
await asyncio.sleep(3600)
except KeyboardInterrupt:
log.info("shutting down...")
loop_task.cancel()
server.close()
await server.wait_closed()

View file

@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib import commands
from mudlib.commands import CommandDefinition, look, movement
from mudlib.commands import look, movement
from mudlib.effects import active_effects, add_effect
from mudlib.player import Player
from mudlib.render.ansi import RESET
@ -49,9 +49,8 @@ def test_register_command():
async def test_handler(player, args):
pass
commands.register(CommandDefinition("test", test_handler))
commands.register("test", test_handler)
assert "test" in commands._registry
assert commands._registry["test"].handler is test_handler
def test_register_command_with_aliases():
@ -60,12 +59,12 @@ def test_register_command_with_aliases():
async def test_handler(player, args):
pass
commands.register(CommandDefinition("testcmd", test_handler, aliases=["tc", "t"]))
commands.register("testcmd", test_handler, aliases=["tc", "t"])
assert "testcmd" in commands._registry
assert "tc" in commands._registry
assert "t" in commands._registry
assert commands._registry["testcmd"] is commands._registry["tc"]
assert commands._registry["testcmd"] is commands._registry["t"]
assert commands._registry["testcmd"] == commands._registry["tc"]
assert commands._registry["testcmd"] == commands._registry["t"]
@pytest.mark.asyncio
@ -79,7 +78,7 @@ async def test_dispatch_routes_to_handler(player):
called = True
received_args = args
commands.register(CommandDefinition("testcmd", test_handler))
commands.register("testcmd", test_handler)
await commands.dispatch(player, "testcmd arg1 arg2")
assert called
@ -318,56 +317,3 @@ async def test_effects_dont_override_player_marker(player, mock_world):
assert "@" in output
active_effects.clear()
# Test mode stack
def test_mode_stack_default(player):
"""Player starts in normal mode."""
assert player.mode == "normal"
assert player.mode_stack == ["normal"]
@pytest.mark.asyncio
async def test_dispatch_blocks_wrong_mode(player, mock_writer):
"""Commands with wrong mode get rejected."""
async def combat_handler(p, args):
pass
commands.register(CommandDefinition("strike", combat_handler, mode="combat"))
await commands.dispatch(player, "strike")
assert mock_writer.write.called
written = mock_writer.write.call_args[0][0]
assert "can't" in written.lower()
@pytest.mark.asyncio
async def test_dispatch_allows_wildcard_mode(player):
"""Commands with mode='*' work from any mode."""
called = False
async def any_handler(p, args):
nonlocal called
called = True
commands.register(CommandDefinition("universal", any_handler, mode="*"))
await commands.dispatch(player, "universal")
assert called
@pytest.mark.asyncio
async def test_dispatch_allows_matching_mode(player):
"""Commands work when player mode matches command mode."""
called = False
async def combat_handler(p, args):
nonlocal called
called = True
commands.register(CommandDefinition("strike", combat_handler, mode="combat"))
player.mode_stack.append("combat")
await commands.dispatch(player, "strike")
assert called

View file

@ -1,7 +1,6 @@
"""Tests for the server module."""
import asyncio
import contextlib
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -90,42 +89,3 @@ async def test_shell_handles_quit():
calls = [str(call) for call in writer.write.call_args_list]
assert any("Goodbye" in call for call in calls)
writer.close.assert_called()
def test_load_world_config():
"""Config loader returns expected values from worlds/earth/config.toml."""
config = server.load_world_config()
assert config["world"]["seed"] == 42
assert config["world"]["width"] == 1000
assert config["world"]["height"] == 1000
def test_load_world_config_missing():
"""Config loader raises FileNotFoundError for nonexistent world."""
with pytest.raises(FileNotFoundError):
server.load_world_config("nonexistent")
def test_tick_constants():
"""Tick rate and interval are configured correctly."""
assert server.TICK_RATE == 10
assert pytest.approx(0.1) == server.TICK_INTERVAL
def test_game_loop_exists():
"""Game loop is an async callable."""
assert callable(server.game_loop)
assert asyncio.iscoroutinefunction(server.game_loop)
@pytest.mark.asyncio
async def test_game_loop_calls_clear_expired():
"""Game loop calls clear_expired each tick."""
with patch("mudlib.server.clear_expired") as mock_clear:
task = asyncio.create_task(server.game_loop())
await asyncio.sleep(0.25)
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
assert mock_clear.call_count >= 1