Compare commits
No commits in common. "8f5956df3d0fc5abc47e7bb45e0a8f1cc0a0399a" and "2c75d26d685348f51204a5f7b45b4a79e9241dae" have entirely different histories.
8f5956df3d
...
2c75d26d68
13 changed files with 51 additions and 2549 deletions
|
|
@ -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
|
|
||||||
|
|
@ -1,25 +1,15 @@
|
||||||
command system
|
command system
|
||||||
==============
|
==============
|
||||||
|
|
||||||
commands are registered as CommandDefinition objects with metadata.
|
commands are registered in a simple dict mapping names to async handlers.
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, register
|
|
||||||
|
|
||||||
async def my_command(player: Player, args: str) -> None:
|
async def my_command(player: Player, args: str) -> None:
|
||||||
player.writer.write("hello\r\n")
|
player.writer.write("hello\r\n")
|
||||||
await player.writer.drain()
|
await player.writer.drain()
|
||||||
|
|
||||||
register(CommandDefinition("mycommand", my_command, aliases=["mc"]))
|
register("mycommand", my_command, aliases=["mc"])
|
||||||
|
|
||||||
CommandDefinition fields:
|
dispatch parses input, looks up the handler, calls it:
|
||||||
|
|
||||||
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")
|
await dispatch(player, "mycommand some args")
|
||||||
|
|
||||||
|
|
@ -53,17 +43,16 @@ adding commands
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
1. create src/mudlib/commands/yourcommand.py
|
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)
|
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
|
5. import the module in server.py so registration runs at startup
|
||||||
|
|
||||||
code
|
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/movement.py direction commands
|
||||||
src/mudlib/commands/look.py look/l
|
src/mudlib/commands/look.py look/l
|
||||||
src/mudlib/commands/fly.py fly
|
src/mudlib/commands/quit.py quit/q
|
||||||
src/mudlib/commands/quit.py quit/q (mode="*")
|
|
||||||
src/mudlib/player.py Player dataclass + registry
|
src/mudlib/player.py Player dataclass + registry
|
||||||
|
|
|
||||||
1958
docs/how/dsls.md
1958
docs/how/dsls.md
File diff suppressed because it is too large
Load diff
|
|
@ -28,14 +28,14 @@ packages = ["src/mudlib"]
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
src = ["src"]
|
src = ["src"]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
exclude = ["repos", ".worktrees"]
|
exclude = ["repos"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
pythonVersion = "3.12"
|
pythonVersion = "3.12"
|
||||||
exclude = ["repos", ".worktrees", ".venv"]
|
exclude = ["repos", ".venv"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,31 @@
|
||||||
"""Command registry and dispatcher."""
|
"""Command registry and dispatcher."""
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
||||||
# Type alias for command handlers
|
# Type alias for command handlers
|
||||||
CommandHandler = Callable[[Player, str], Awaitable[None]]
|
CommandHandler = Callable[[Player, str], Awaitable[None]]
|
||||||
|
|
||||||
|
# Registry maps command names to handler functions
|
||||||
@dataclass
|
_registry: dict[str, CommandHandler] = {}
|
||||||
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 definitions
|
def register(
|
||||||
_registry: dict[str, CommandDefinition] = {}
|
name: str, handler: CommandHandler, aliases: list[str] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Register a command handler with optional aliases.
|
||||||
def register(defn: CommandDefinition) -> None:
|
|
||||||
"""Register a command definition with its aliases.
|
|
||||||
|
|
||||||
Args:
|
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
|
_registry[name] = handler
|
||||||
for alias in defn.aliases:
|
|
||||||
_registry[alias] = defn
|
if aliases:
|
||||||
|
for alias in aliases:
|
||||||
|
_registry[alias] = handler
|
||||||
|
|
||||||
|
|
||||||
async def dispatch(player: Player, raw_input: str) -> None:
|
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()
|
command = parts[0].lower()
|
||||||
args = parts[1] if len(parts) > 1 else ""
|
args = parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
# Look up the definition
|
# Look up the handler
|
||||||
defn = _registry.get(command)
|
handler = _registry.get(command)
|
||||||
|
|
||||||
if defn is None:
|
if handler is None:
|
||||||
player.writer.write(f"Unknown command: {command}\r\n")
|
player.writer.write(f"Unknown command: {command}\r\n")
|
||||||
await player.writer.drain()
|
await player.writer.drain()
|
||||||
return
|
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
|
# Execute the handler
|
||||||
await defn.handler(player, args)
|
await handler(player, args)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from typing import Any
|
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.commands.movement import DIRECTIONS, send_nearby_message
|
||||||
from mudlib.effects import add_effect
|
from mudlib.effects import add_effect
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
@ -91,4 +91,4 @@ async def cmd_fly(player: Player, args: str) -> None:
|
||||||
await cmd_look(player, "")
|
await cmd_look(player, "")
|
||||||
|
|
||||||
|
|
||||||
register(CommandDefinition("fly", cmd_fly))
|
register("fly", cmd_fly)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import register
|
||||||
from mudlib.effects import get_effects_at
|
from mudlib.effects import get_effects_at
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.render.ansi import RESET, colorize_terrain
|
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 the look command with its alias
|
||||||
register(CommandDefinition("look", cmd_look, aliases=["l"]))
|
register("look", cmd_look, aliases=["l"])
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import register
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
|
|
||||||
# World instance will be injected by the server
|
# 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 all movement commands with their aliases
|
||||||
register(CommandDefinition("north", move_north, aliases=["n"]))
|
register("north", move_north, aliases=["n"])
|
||||||
register(CommandDefinition("south", move_south, aliases=["s"]))
|
register("south", move_south, aliases=["s"])
|
||||||
register(CommandDefinition("east", move_east, aliases=["e"]))
|
register("east", move_east, aliases=["e"])
|
||||||
register(CommandDefinition("west", move_west, aliases=["w"]))
|
register("west", move_west, aliases=["w"])
|
||||||
register(CommandDefinition("northeast", move_northeast, aliases=["ne"]))
|
register("northeast", move_northeast, aliases=["ne"])
|
||||||
register(CommandDefinition("northwest", move_northwest, aliases=["nw"]))
|
register("northwest", move_northwest, aliases=["nw"])
|
||||||
register(CommandDefinition("southeast", move_southeast, aliases=["se"]))
|
register("southeast", move_southeast, aliases=["se"])
|
||||||
register(CommandDefinition("southwest", move_southwest, aliases=["sw"]))
|
register("southwest", move_southwest, aliases=["sw"])
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Quit command for disconnecting from the server."""
|
"""Quit command for disconnecting from the server."""
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import register
|
||||||
from mudlib.player import Player, players
|
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 the quit command with its aliases
|
||||||
register(CommandDefinition("quit", cmd_quit, aliases=["q"], mode="*"))
|
register("quit", cmd_quit, aliases=["q"])
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Player state and registry."""
|
"""Player state and registry."""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,12 +14,6 @@ class Player:
|
||||||
writer: Any # telnetlib3 TelnetWriter for sending output
|
writer: Any # telnetlib3 TelnetWriter for sending output
|
||||||
reader: Any # telnetlib3 TelnetReader for reading input
|
reader: Any # telnetlib3 TelnetReader for reading input
|
||||||
flying: bool = False
|
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
|
# Global registry of connected players
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import time
|
import time
|
||||||
import tomllib
|
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import telnetlib3
|
import telnetlib3
|
||||||
|
|
@ -15,40 +14,17 @@ import mudlib.commands.fly
|
||||||
import mudlib.commands.look
|
import mudlib.commands.look
|
||||||
import mudlib.commands.movement
|
import mudlib.commands.movement
|
||||||
import mudlib.commands.quit
|
import mudlib.commands.quit
|
||||||
from mudlib.effects import clear_expired
|
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.world.terrain import World
|
from mudlib.world.terrain import World
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
PORT = 6789
|
PORT = 6789
|
||||||
TICK_RATE = 10 # ticks per second
|
|
||||||
TICK_INTERVAL = 1.0 / TICK_RATE
|
|
||||||
|
|
||||||
# Module-level world instance, generated once at startup
|
# Module-level world instance, generated once at startup
|
||||||
_world: World | None = None
|
_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]:
|
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.
|
"""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)
|
# Generate world once at startup (cached to build/ after first run)
|
||||||
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
|
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
|
||||||
config = load_world_config()
|
log.info("loading world (seed=42, 1000x1000)...")
|
||||||
world_cfg = config["world"]
|
|
||||||
log.info(
|
|
||||||
"loading world (seed=%d, %dx%d)...",
|
|
||||||
world_cfg["seed"],
|
|
||||||
world_cfg["width"],
|
|
||||||
world_cfg["height"],
|
|
||||||
)
|
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
_world = World(
|
_world = World(seed=42, width=1000, height=1000, cache_dir=cache_dir)
|
||||||
seed=world_cfg["seed"],
|
|
||||||
width=world_cfg["width"],
|
|
||||||
height=world_cfg["height"],
|
|
||||||
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)
|
||||||
|
|
@ -198,13 +162,10 @@ async def run_server() -> None:
|
||||||
)
|
)
|
||||||
log.info("listening on 127.0.0.1:%d", PORT)
|
log.info("listening on 127.0.0.1:%d", PORT)
|
||||||
|
|
||||||
loop_task = asyncio.create_task(game_loop())
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(3600)
|
await asyncio.sleep(3600)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
log.info("shutting down...")
|
log.info("shutting down...")
|
||||||
loop_task.cancel()
|
|
||||||
server.close()
|
server.close()
|
||||||
await server.wait_closed()
|
await server.wait_closed()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib import commands
|
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.effects import active_effects, add_effect
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
from mudlib.render.ansi import RESET
|
from mudlib.render.ansi import RESET
|
||||||
|
|
@ -49,9 +49,8 @@ def test_register_command():
|
||||||
async def test_handler(player, args):
|
async def test_handler(player, args):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
commands.register(CommandDefinition("test", test_handler))
|
commands.register("test", test_handler)
|
||||||
assert "test" in commands._registry
|
assert "test" in commands._registry
|
||||||
assert commands._registry["test"].handler is test_handler
|
|
||||||
|
|
||||||
|
|
||||||
def test_register_command_with_aliases():
|
def test_register_command_with_aliases():
|
||||||
|
|
@ -60,12 +59,12 @@ def test_register_command_with_aliases():
|
||||||
async def test_handler(player, args):
|
async def test_handler(player, args):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
commands.register(CommandDefinition("testcmd", test_handler, aliases=["tc", "t"]))
|
commands.register("testcmd", test_handler, aliases=["tc", "t"])
|
||||||
assert "testcmd" in commands._registry
|
assert "testcmd" in commands._registry
|
||||||
assert "tc" in commands._registry
|
assert "tc" in commands._registry
|
||||||
assert "t" in commands._registry
|
assert "t" in commands._registry
|
||||||
assert commands._registry["testcmd"] is commands._registry["tc"]
|
assert commands._registry["testcmd"] == commands._registry["tc"]
|
||||||
assert commands._registry["testcmd"] is commands._registry["t"]
|
assert commands._registry["testcmd"] == commands._registry["t"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -79,7 +78,7 @@ async def test_dispatch_routes_to_handler(player):
|
||||||
called = True
|
called = True
|
||||||
received_args = args
|
received_args = args
|
||||||
|
|
||||||
commands.register(CommandDefinition("testcmd", test_handler))
|
commands.register("testcmd", test_handler)
|
||||||
await commands.dispatch(player, "testcmd arg1 arg2")
|
await commands.dispatch(player, "testcmd arg1 arg2")
|
||||||
|
|
||||||
assert called
|
assert called
|
||||||
|
|
@ -318,56 +317,3 @@ async def test_effects_dont_override_player_marker(player, mock_world):
|
||||||
assert "@" in output
|
assert "@" in output
|
||||||
|
|
||||||
active_effects.clear()
|
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,7 +1,6 @@
|
||||||
"""Tests for the server module."""
|
"""Tests for the server module."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -90,42 +89,3 @@ async def test_shell_handles_quit():
|
||||||
calls = [str(call) for call in writer.write.call_args_list]
|
calls = [str(call) for call in writer.write.call_args_list]
|
||||||
assert any("Goodbye" in call for call in calls)
|
assert any("Goodbye" in call for call in calls)
|
||||||
writer.close.assert_called()
|
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