Compare commits
9 commits
2c75d26d68
...
8f5956df3d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f5956df3d | |||
| 8240265c71 | |||
| 600be4ed95 | |||
| 8fbee01c11 | |||
| 075a6ce303 | |||
| d220835f7d | |||
| dcc8b961bb | |||
| bea2a73c98 | |||
| 0f05302b6e |
13 changed files with 2549 additions and 51 deletions
377
docs/how/architecture-plan.txt
Normal file
377
docs/how/architecture-plan.txt
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
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
|
||||
|
|
@ -1,15 +1,25 @@
|
|||
command system
|
||||
==============
|
||||
|
||||
commands are registered in a simple dict mapping names to async handlers.
|
||||
commands are registered as CommandDefinition objects with metadata.
|
||||
|
||||
from mudlib.commands import CommandDefinition, register
|
||||
|
||||
async def my_command(player: Player, args: str) -> None:
|
||||
player.writer.write("hello\r\n")
|
||||
await player.writer.drain()
|
||||
|
||||
register("mycommand", my_command, aliases=["mc"])
|
||||
register(CommandDefinition("mycommand", my_command, aliases=["mc"]))
|
||||
|
||||
dispatch parses input, looks up the handler, calls it:
|
||||
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:
|
||||
|
||||
await dispatch(player, "mycommand some args")
|
||||
|
||||
|
|
@ -43,16 +53,17 @@ adding commands
|
|||
---------------
|
||||
|
||||
1. create src/mudlib/commands/yourcommand.py
|
||||
2. import register from mudlib.commands
|
||||
2. import CommandDefinition and register from mudlib.commands
|
||||
3. define async handler(player, args)
|
||||
4. call register() with name and aliases
|
||||
4. call register(CommandDefinition(...))
|
||||
5. import the module in server.py so registration runs at startup
|
||||
|
||||
code
|
||||
----
|
||||
|
||||
src/mudlib/commands/__init__.py registry + dispatch
|
||||
src/mudlib/commands/__init__.py registry + dispatch + CommandDefinition
|
||||
src/mudlib/commands/movement.py direction commands
|
||||
src/mudlib/commands/look.py look/l
|
||||
src/mudlib/commands/quit.py quit/q
|
||||
src/mudlib/commands/fly.py fly
|
||||
src/mudlib/commands/quit.py quit/q (mode="*")
|
||||
src/mudlib/player.py Player dataclass + registry
|
||||
|
|
|
|||
1958
docs/how/dsls.md
Normal file
1958
docs/how/dsls.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -28,14 +28,14 @@ packages = ["src/mudlib"]
|
|||
[tool.ruff]
|
||||
src = ["src"]
|
||||
line-length = 88
|
||||
exclude = ["repos"]
|
||||
exclude = ["repos", ".worktrees"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
|
||||
[tool.pyright]
|
||||
pythonVersion = "3.12"
|
||||
exclude = ["repos", ".venv"]
|
||||
exclude = ["repos", ".worktrees", ".venv"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
|
|
|||
|
|
@ -1,31 +1,38 @@
|
|||
"""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]]
|
||||
|
||||
# Registry maps command names to handler functions
|
||||
_registry: dict[str, CommandHandler] = {}
|
||||
|
||||
@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 = ""
|
||||
|
||||
|
||||
def register(
|
||||
name: str, handler: CommandHandler, aliases: list[str] | None = None
|
||||
) -> None:
|
||||
"""Register a command handler with optional aliases.
|
||||
# Registry maps command names to definitions
|
||||
_registry: dict[str, CommandDefinition] = {}
|
||||
|
||||
|
||||
def register(defn: CommandDefinition) -> None:
|
||||
"""Register a command definition with its aliases.
|
||||
|
||||
Args:
|
||||
name: The primary command name
|
||||
handler: Async function that handles the command
|
||||
aliases: Optional list of alternative names for the command
|
||||
defn: The command definition to register
|
||||
"""
|
||||
_registry[name] = handler
|
||||
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
_registry[alias] = handler
|
||||
_registry[defn.name] = defn
|
||||
for alias in defn.aliases:
|
||||
_registry[alias] = defn
|
||||
|
||||
|
||||
async def dispatch(player: Player, raw_input: str) -> None:
|
||||
|
|
@ -45,13 +52,19 @@ async def dispatch(player: Player, raw_input: str) -> None:
|
|||
command = parts[0].lower()
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# Look up the handler
|
||||
handler = _registry.get(command)
|
||||
# Look up the definition
|
||||
defn = _registry.get(command)
|
||||
|
||||
if handler is None:
|
||||
if defn 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 handler(player, args)
|
||||
await defn.handler(player, args)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from typing import Any
|
||||
|
||||
from mudlib.commands import register
|
||||
from mudlib.commands import CommandDefinition, 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("fly", cmd_fly)
|
||||
register(CommandDefinition("fly", cmd_fly))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from typing import Any
|
||||
|
||||
from mudlib.commands import register
|
||||
from mudlib.commands import CommandDefinition, 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("look", cmd_look, aliases=["l"])
|
||||
register(CommandDefinition("look", cmd_look, aliases=["l"]))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from typing import Any
|
||||
|
||||
from mudlib.commands import register
|
||||
from mudlib.commands import CommandDefinition, 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("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"])
|
||||
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"]))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Quit command for disconnecting from the server."""
|
||||
|
||||
from mudlib.commands import register
|
||||
from mudlib.commands import CommandDefinition, 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("quit", cmd_quit, aliases=["q"])
|
||||
register(CommandDefinition("quit", cmd_quit, aliases=["q"], mode="*"))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Player state and registry."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
|
|
@ -14,6 +14,12 @@ 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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import asyncio
|
|||
import logging
|
||||
import pathlib
|
||||
import time
|
||||
import tomllib
|
||||
from typing import cast
|
||||
|
||||
import telnetlib3
|
||||
|
|
@ -14,17 +15,40 @@ 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.
|
||||
|
||||
|
|
@ -139,9 +163,21 @@ 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"
|
||||
log.info("loading world (seed=42, 1000x1000)...")
|
||||
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"],
|
||||
)
|
||||
t0 = time.monotonic()
|
||||
_world = World(seed=42, width=1000, height=1000, cache_dir=cache_dir)
|
||||
_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:
|
||||
log.info("world loaded from cache in %.2fs", elapsed)
|
||||
|
|
@ -162,10 +198,13 @@ 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()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||
import pytest
|
||||
|
||||
from mudlib import commands
|
||||
from mudlib.commands import look, movement
|
||||
from mudlib.commands import CommandDefinition, look, movement
|
||||
from mudlib.effects import active_effects, add_effect
|
||||
from mudlib.player import Player
|
||||
from mudlib.render.ansi import RESET
|
||||
|
|
@ -49,8 +49,9 @@ def test_register_command():
|
|||
async def test_handler(player, args):
|
||||
pass
|
||||
|
||||
commands.register("test", test_handler)
|
||||
commands.register(CommandDefinition("test", test_handler))
|
||||
assert "test" in commands._registry
|
||||
assert commands._registry["test"].handler is test_handler
|
||||
|
||||
|
||||
def test_register_command_with_aliases():
|
||||
|
|
@ -59,12 +60,12 @@ def test_register_command_with_aliases():
|
|||
async def test_handler(player, args):
|
||||
pass
|
||||
|
||||
commands.register("testcmd", test_handler, aliases=["tc", "t"])
|
||||
commands.register(CommandDefinition("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"] == commands._registry["tc"]
|
||||
assert commands._registry["testcmd"] == commands._registry["t"]
|
||||
assert commands._registry["testcmd"] is commands._registry["tc"]
|
||||
assert commands._registry["testcmd"] is commands._registry["t"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -78,7 +79,7 @@ async def test_dispatch_routes_to_handler(player):
|
|||
called = True
|
||||
received_args = args
|
||||
|
||||
commands.register("testcmd", test_handler)
|
||||
commands.register(CommandDefinition("testcmd", test_handler))
|
||||
await commands.dispatch(player, "testcmd arg1 arg2")
|
||||
|
||||
assert called
|
||||
|
|
@ -317,3 +318,56 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for the server module."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -89,3 +90,42 @@ 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue