Compare commits
11 commits
f418805b78
...
833c0efa3a
| Author | SHA1 | Date | |
|---|---|---|---|
| 833c0efa3a | |||
| 200cc00129 | |||
| fb758c8f36 | |||
| bd5f83e890 | |||
| aafdcdca42 | |||
| 05b377f00a | |||
| 7ba4ab90fb | |||
| bcf8ae28d1 | |||
| aeb3d31702 | |||
| ee0dc839d8 | |||
| c3848fe57d |
13 changed files with 8292 additions and 23 deletions
169
docs/how/protocols.rst
Normal file
169
docs/how/protocols.rst
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
protocol negotiation — GMCP, MSDP, and client detection
|
||||||
|
========================================================
|
||||||
|
|
||||||
|
how the server offers GMCP and MSDP to connecting clients, how clients
|
||||||
|
accept or reject them, and how the game guards against sending data to
|
||||||
|
clients that don't support it.
|
||||||
|
|
||||||
|
see also: ``mud.tin`` (tintin++ client config), ``docs/lessons/charset-vs-mtts.txt``
|
||||||
|
(related telnet negotiation gotcha).
|
||||||
|
|
||||||
|
|
||||||
|
the lifecycle
|
||||||
|
-------------
|
||||||
|
|
||||||
|
1. client connects via telnet
|
||||||
|
2. telnetlib3 calls ``begin_negotiation()`` on our ``MudTelnetServer``
|
||||||
|
3. server sends ``IAC WILL GMCP`` and ``IAC WILL MSDP``
|
||||||
|
4. client replies ``IAC DO <option>`` (accept) or ``IAC DONT <option>`` (reject)
|
||||||
|
5. telnetlib3 updates its internal option state tables
|
||||||
|
6. ``Player.gmcp_enabled`` / ``Player.msdp_enabled`` check those tables
|
||||||
|
7. every send function guards on the relevant property before transmitting
|
||||||
|
|
||||||
|
all of this happens during the telnet handshake, before the player sees the
|
||||||
|
name prompt. by the time game code runs, the protocol state is settled.
|
||||||
|
|
||||||
|
|
||||||
|
server side — MudTelnetServer
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
``server.py`` subclasses telnetlib3's ``TelnetServer``::
|
||||||
|
|
||||||
|
class MudTelnetServer(TelnetServer):
|
||||||
|
def begin_negotiation(self) -> None:
|
||||||
|
super().begin_negotiation()
|
||||||
|
self.writer.iac(WILL, GMCP)
|
||||||
|
self.writer.iac(WILL, MSDP)
|
||||||
|
|
||||||
|
``begin_negotiation()`` is called once by telnetlib3 right after the TCP
|
||||||
|
connection is established. the parent handles baseline telnet options (TTYPE,
|
||||||
|
NAWS, etc). we add GMCP and MSDP offers on top.
|
||||||
|
|
||||||
|
there's no ``begin_advanced_negotiation()`` override — GMCP/MSDP offers go
|
||||||
|
in the initial phase because clients need to know about them before the game
|
||||||
|
starts sending data.
|
||||||
|
|
||||||
|
|
||||||
|
player properties
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
``player.py`` exposes two boolean properties::
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gmcp_enabled(self) -> bool:
|
||||||
|
if self.writer is None:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
self.writer.local_option.enabled(GMCP)
|
||||||
|
or self.writer.remote_option.enabled(GMCP)
|
||||||
|
)
|
||||||
|
|
||||||
|
checks both ``local_option`` and ``remote_option`` because telnet negotiation
|
||||||
|
is symmetric — either side can initiate. ``local_option`` reflects our ``WILL``
|
||||||
|
offer being accepted (client sent ``DO``). ``remote_option`` would reflect a
|
||||||
|
client-initiated ``DO`` that we accepted with ``WILL``. in practice the server
|
||||||
|
always offers first, but checking both is correct per the telnet spec.
|
||||||
|
|
||||||
|
same pattern for ``msdp_enabled``.
|
||||||
|
|
||||||
|
|
||||||
|
the guard pattern
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
every function in ``gmcp.py`` that sends data checks the property first::
|
||||||
|
|
||||||
|
def send_char_vitals(player: Player) -> None:
|
||||||
|
if not player.gmcp_enabled:
|
||||||
|
return
|
||||||
|
player.send_gmcp("Char.Vitals", { ... })
|
||||||
|
|
||||||
|
def send_msdp_vitals(player: Player) -> None:
|
||||||
|
if not player.msdp_enabled:
|
||||||
|
return
|
||||||
|
player.send_msdp("HEALTH", ...)
|
||||||
|
|
||||||
|
this is the only guard needed. the game loop calls these functions
|
||||||
|
unconditionally for all players; the functions themselves decide whether to
|
||||||
|
actually send. simple clients that don't negotiate GMCP/MSDP just never
|
||||||
|
receive the data.
|
||||||
|
|
||||||
|
|
||||||
|
what gets sent over each protocol
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
**GMCP** (Generic MUD Communication Protocol) — structured JSON packages:
|
||||||
|
|
||||||
|
- ``Char.Vitals`` — hp, max_hp, stamina, max_stamina
|
||||||
|
- ``Char.Status`` — name, level, location name
|
||||||
|
- ``Room.Info`` — room id, coordinates, exits
|
||||||
|
- ``Room.Map`` — ASCII map fragment for client-side rendering
|
||||||
|
|
||||||
|
sent on-demand when state changes (room enter, damage taken, etc).
|
||||||
|
|
||||||
|
**MSDP** (MUD Server Data Protocol) — key/value variable updates:
|
||||||
|
|
||||||
|
- ``HEALTH``, ``HEALTH_MAX``, ``STAMINA``, ``STAMINA_MAX``
|
||||||
|
|
||||||
|
sent on a timer, once per second (every ``TICK_RATE`` ticks in the game loop).
|
||||||
|
intended for status bar gauges.
|
||||||
|
|
||||||
|
|
||||||
|
client side — tintin++ example
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
tintin++ does NOT auto-negotiate GMCP or MSDP. it sends ``IAC DONT`` by
|
||||||
|
default, rejecting the server's offer. the ``CATCH`` event intercepts the
|
||||||
|
incoming ``IAC WILL`` before tintin's default handler runs::
|
||||||
|
|
||||||
|
#EVENT {CATCH IAC WILL GMCP} {#send {\xFF\xFD\xC9\}}
|
||||||
|
#EVENT {CATCH IAC WILL MSDP} {#send {\xFF\xFD\x45\}}
|
||||||
|
|
||||||
|
the hex bytes:
|
||||||
|
|
||||||
|
- ``\xFF\xFD\xC9`` = ``IAC DO GMCP`` (255, 253, 201)
|
||||||
|
- ``\xFF\xFD\x45`` = ``IAC DO MSDP`` (255, 253, 69)
|
||||||
|
|
||||||
|
MSDP variables arrive as subnegotiation events and get stored in tintin
|
||||||
|
variables for the status bar::
|
||||||
|
|
||||||
|
#EVENT {IAC SB MSDP} {
|
||||||
|
#variable {MSDP_%0} {%1};
|
||||||
|
#showme {[HP: $MSDP_HEALTH/$MSDP_HEALTH_MAX] ...} {-2}
|
||||||
|
}
|
||||||
|
|
||||||
|
``{-2}`` puts the status bar on the split line at the bottom of the terminal.
|
||||||
|
|
||||||
|
this was the trickiest part of the integration — the CATCH mechanism is
|
||||||
|
not well-documented in tintin++ and the raw byte sending syntax changed
|
||||||
|
across versions.
|
||||||
|
|
||||||
|
|
||||||
|
the client command
|
||||||
|
------------------
|
||||||
|
|
||||||
|
players can type ``client`` in-game to see what got negotiated::
|
||||||
|
|
||||||
|
client protocols
|
||||||
|
GMCP: active
|
||||||
|
MSDP: active
|
||||||
|
terminal
|
||||||
|
type: xterm-256color
|
||||||
|
size: 120x40
|
||||||
|
color: 256
|
||||||
|
...
|
||||||
|
|
||||||
|
useful for debugging connection issues — if a player reports missing status
|
||||||
|
bars, ``client`` shows whether MSDP actually negotiated.
|
||||||
|
|
||||||
|
|
||||||
|
adding a protocol
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
to add support for another telnet option:
|
||||||
|
|
||||||
|
1. import the option byte constant (or define it)
|
||||||
|
2. add ``self.writer.iac(WILL, NEW_OPTION)`` in ``MudTelnetServer.begin_negotiation()``
|
||||||
|
3. add a ``new_option_enabled`` property on ``Player``
|
||||||
|
4. guard all send functions on that property
|
||||||
|
5. add it to the ``client`` command output
|
||||||
|
6. add CATCH handler in ``mud.tin`` if tintin++ doesn't auto-negotiate it
|
||||||
7719
docs/how/tintin.rst
Normal file
7719
docs/how/tintin.rst
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -27,6 +27,7 @@ mostly standalone subsystems.
|
||||||
- ``docs/how/terrain-generation.txt`` — perlin noise, elevation thresholds, river tracing
|
- ``docs/how/terrain-generation.txt`` — perlin noise, elevation thresholds, river tracing
|
||||||
- ``docs/how/persistence.txt`` — SQLite storage, what's persisted vs runtime
|
- ``docs/how/persistence.txt`` — SQLite storage, what's persisted vs runtime
|
||||||
- ``docs/how/prompt-system.txt`` — modal prompts, color markup, per-player customization
|
- ``docs/how/prompt-system.txt`` — modal prompts, color markup, per-player customization
|
||||||
|
- ``docs/how/protocols.rst`` — GMCP/MSDP negotiation, client detection, guard pattern
|
||||||
|
|
||||||
combat
|
combat
|
||||||
------
|
------
|
||||||
|
|
|
||||||
34
mud.tin
34
mud.tin
|
|
@ -1,8 +1,32 @@
|
||||||
#NOP TinTin++ config for connecting to the MUD server
|
#NOP TinTin++ config for the MUD server
|
||||||
|
#NOP usage: tt++ mud.tin
|
||||||
#split 0 1
|
#split 0 1
|
||||||
|
|
||||||
|
#NOP initialize MSDP variables for status bar (before connection)
|
||||||
|
#variable {MSDP_HEALTH} {-}
|
||||||
|
#variable {MSDP_HEALTH_MAX} {-}
|
||||||
|
#variable {MSDP_STAMINA} {-}
|
||||||
|
#variable {MSDP_STAMINA_MAX} {-}
|
||||||
|
|
||||||
|
#NOP protocol negotiation: tintin++ does NOT auto-negotiate MSDP or
|
||||||
|
#NOP GMCP. CATCH intercepts before the default DONT response, and
|
||||||
|
#NOP #send handles hex escapes natively (no raw option needed).
|
||||||
|
#EVENT {CATCH IAC WILL GMCP} {#send {\xFF\xFD\xC9\}}
|
||||||
|
#EVENT {CATCH IAC WILL MSDP} {#send {\xFF\xFD\x45\}}
|
||||||
|
|
||||||
|
#NOP store incoming MSDP variables and refresh status bar
|
||||||
|
#EVENT {IAC SB MSDP} {
|
||||||
|
#variable {MSDP_%0} {%1};
|
||||||
|
#showme {[HP: $MSDP_HEALTH/$MSDP_HEALTH_MAX] [ST: $MSDP_STAMINA/$MSDP_STAMINA_MAX]} {-2}
|
||||||
|
}
|
||||||
|
|
||||||
|
#NOP reconnect after disconnect. defined before #session so it
|
||||||
|
#NOP persists in the startup session when the mud session dies.
|
||||||
|
#alias {reconnect} {#session mud localhost 6789}
|
||||||
|
|
||||||
#session mud localhost 6789
|
#session mud localhost 6789
|
||||||
|
|
||||||
#NOP fly aliases: f<direction> = fly 5 in that direction
|
#NOP fly shortcuts: f + direction
|
||||||
#alias {fn} {fly north}
|
#alias {fn} {fly north}
|
||||||
#alias {fs} {fly south}
|
#alias {fs} {fly south}
|
||||||
#alias {fe} {fly east}
|
#alias {fe} {fly east}
|
||||||
|
|
@ -12,10 +36,6 @@
|
||||||
#alias {fse} {fly southeast}
|
#alias {fse} {fly southeast}
|
||||||
#alias {fsw} {fly southwest}
|
#alias {fsw} {fly southwest}
|
||||||
|
|
||||||
|
#NOP combat shortcuts
|
||||||
#alias {o} {sweep}
|
#alias {o} {sweep}
|
||||||
#alias {r} {roundhouse}
|
#alias {r} {roundhouse}
|
||||||
|
|
||||||
#alias {reconnect} {
|
|
||||||
#zap mud;
|
|
||||||
#session mud localhost 6789;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,69 @@ async def cmd_skills(player: Player, args: str) -> None:
|
||||||
await player.send(" ".join(names) + "\r\n")
|
await player.send(" ".join(names) + "\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_client(player: Player, args: str) -> None:
|
||||||
|
"""Show client protocol negotiation and terminal capabilities."""
|
||||||
|
lines = ["client protocols"]
|
||||||
|
|
||||||
|
# Protocol status
|
||||||
|
gmcp = "active" if player.gmcp_enabled else "not active"
|
||||||
|
msdp = "active" if player.msdp_enabled else "not active"
|
||||||
|
lines.append(f" GMCP: {gmcp}")
|
||||||
|
lines.append(f" MSDP: {msdp}")
|
||||||
|
|
||||||
|
# Terminal info
|
||||||
|
lines.append("terminal")
|
||||||
|
|
||||||
|
# Terminal type from TTYPE negotiation
|
||||||
|
ttype = None
|
||||||
|
if player.writer is not None:
|
||||||
|
ttype = player.writer.get_extra_info("TERM") or None
|
||||||
|
lines.append(f" type: {ttype or 'unknown'}")
|
||||||
|
|
||||||
|
# Terminal size from NAWS
|
||||||
|
cols, rows = 80, 24
|
||||||
|
if player.writer is not None:
|
||||||
|
cols = player.writer.get_extra_info("cols") or 80
|
||||||
|
rows = player.writer.get_extra_info("rows") or 24
|
||||||
|
lines.append(f" size: {cols}x{rows}")
|
||||||
|
|
||||||
|
# Color depth
|
||||||
|
lines.append(f" colors: {player.color_depth}")
|
||||||
|
|
||||||
|
# MTTS capabilities
|
||||||
|
caps = player.caps
|
||||||
|
mtts_flags = []
|
||||||
|
if caps.ansi:
|
||||||
|
mtts_flags.append("ANSI")
|
||||||
|
if caps.vt100:
|
||||||
|
mtts_flags.append("VT100")
|
||||||
|
if caps.utf8:
|
||||||
|
mtts_flags.append("UTF-8")
|
||||||
|
if caps.colors_256:
|
||||||
|
mtts_flags.append("256 colors")
|
||||||
|
if caps.truecolor:
|
||||||
|
mtts_flags.append("truecolor")
|
||||||
|
if caps.mouse_tracking:
|
||||||
|
mtts_flags.append("mouse tracking")
|
||||||
|
if caps.screen_reader:
|
||||||
|
mtts_flags.append("screen reader")
|
||||||
|
if caps.proxy:
|
||||||
|
mtts_flags.append("proxy")
|
||||||
|
if caps.mnes:
|
||||||
|
mtts_flags.append("MNES")
|
||||||
|
if caps.mslp:
|
||||||
|
mtts_flags.append("MSLP")
|
||||||
|
if caps.ssl:
|
||||||
|
mtts_flags.append("SSL")
|
||||||
|
|
||||||
|
if mtts_flags:
|
||||||
|
lines.append(f" MTTS: {', '.join(mtts_flags)}")
|
||||||
|
else:
|
||||||
|
lines.append(" MTTS: none detected")
|
||||||
|
|
||||||
|
await player.send("\r\n".join(lines) + "\r\n")
|
||||||
|
|
||||||
|
|
||||||
# Register the commands command
|
# Register the commands command
|
||||||
register(
|
register(
|
||||||
CommandDefinition(
|
CommandDefinition(
|
||||||
|
|
@ -282,6 +345,17 @@ register(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Register the client command
|
||||||
|
register(
|
||||||
|
CommandDefinition(
|
||||||
|
"client",
|
||||||
|
cmd_client,
|
||||||
|
aliases=[],
|
||||||
|
mode="*",
|
||||||
|
help="show client protocol and terminal info",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cmd_help(player: Player, args: str) -> None:
|
async def cmd_help(player: Player, args: str) -> None:
|
||||||
"""Show help for a command or skill.
|
"""Show help for a command or skill.
|
||||||
|
|
@ -293,7 +367,7 @@ async def cmd_help(player: Player, args: str) -> None:
|
||||||
args = args.strip()
|
args = args.strip()
|
||||||
if not args:
|
if not args:
|
||||||
await player.send(
|
await player.send(
|
||||||
"type help <command> for details. see also: commands, skills\r\n"
|
"type help <command> for details. see also: commands, skills, client\r\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
await _show_command_detail(player, args)
|
await _show_command_detail(player, args)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
def send_char_vitals(player: Player) -> None:
|
def send_char_vitals(player: Player) -> None:
|
||||||
"""Send Char.Vitals — pl, max_pl, stamina, max_stamina."""
|
"""Send Char.Vitals — pl, max_pl, stamina, max_stamina."""
|
||||||
|
if not player.gmcp_enabled:
|
||||||
|
return
|
||||||
player.send_gmcp(
|
player.send_gmcp(
|
||||||
"Char.Vitals",
|
"Char.Vitals",
|
||||||
{
|
{
|
||||||
|
|
@ -26,6 +28,8 @@ def send_char_vitals(player: Player) -> None:
|
||||||
|
|
||||||
def send_char_status(player: Player) -> None:
|
def send_char_status(player: Player) -> None:
|
||||||
"""Send Char.Status — flying, resting, mode, in_combat."""
|
"""Send Char.Status — flying, resting, mode, in_combat."""
|
||||||
|
if not player.gmcp_enabled:
|
||||||
|
return
|
||||||
player.send_gmcp(
|
player.send_gmcp(
|
||||||
"Char.Status",
|
"Char.Status",
|
||||||
{
|
{
|
||||||
|
|
@ -39,6 +43,8 @@ def send_char_status(player: Player) -> None:
|
||||||
|
|
||||||
def send_room_info(player: Player) -> None:
|
def send_room_info(player: Player) -> None:
|
||||||
"""Send Room.Info — zone, coordinates, terrain, exits."""
|
"""Send Room.Info — zone, coordinates, terrain, exits."""
|
||||||
|
if not player.gmcp_enabled:
|
||||||
|
return
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
zone = player.location
|
zone = player.location
|
||||||
|
|
@ -78,6 +84,8 @@ def send_room_info(player: Player) -> None:
|
||||||
|
|
||||||
def send_map_data(player: Player) -> None:
|
def send_map_data(player: Player) -> None:
|
||||||
"""Send Room.Map — terrain viewport around player for client rendering."""
|
"""Send Room.Map — terrain viewport around player for client rendering."""
|
||||||
|
if not player.gmcp_enabled:
|
||||||
|
return
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
zone = player.location
|
zone = player.location
|
||||||
|
|
@ -107,12 +115,19 @@ def send_map_data(player: Player) -> None:
|
||||||
|
|
||||||
|
|
||||||
def send_msdp_vitals(player: Player) -> None:
|
def send_msdp_vitals(player: Player) -> None:
|
||||||
"""Send MSDP variable updates for real-time gauges."""
|
"""Send MSDP variable updates for real-time gauges.
|
||||||
player.send_msdp(
|
|
||||||
{
|
Skips sending if values haven't changed since last send.
|
||||||
"HEALTH": str(round(player.pl, 1)),
|
"""
|
||||||
"HEALTH_MAX": str(round(player.max_pl, 1)),
|
if not player.msdp_enabled:
|
||||||
"STAMINA": str(round(player.stamina, 1)),
|
return
|
||||||
"STAMINA_MAX": str(round(player.max_stamina, 1)),
|
data = {
|
||||||
}
|
"HEALTH": str(round(player.pl, 1)),
|
||||||
)
|
"HEALTH_MAX": str(round(player.max_pl, 1)),
|
||||||
|
"STAMINA": str(round(player.stamina, 1)),
|
||||||
|
"STAMINA_MAX": str(round(player.max_stamina, 1)),
|
||||||
|
}
|
||||||
|
if data == player._last_msdp:
|
||||||
|
return
|
||||||
|
player._last_msdp = data
|
||||||
|
player.send_msdp(data)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from __future__ import annotations
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from telnetlib3 import GMCP, MSDP
|
||||||
|
|
||||||
from mudlib.caps import ClientCaps
|
from mudlib.caps import ClientCaps
|
||||||
from mudlib.entity import Entity
|
from mudlib.entity import Entity
|
||||||
|
|
||||||
|
|
@ -28,6 +30,7 @@ class Player(Entity):
|
||||||
paint_mode: bool = False
|
paint_mode: bool = False
|
||||||
painting: bool = False
|
painting: bool = False
|
||||||
paint_brush: str = "."
|
paint_brush: str = "."
|
||||||
|
_last_msdp: dict = field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
|
|
@ -54,6 +57,26 @@ class Player(Entity):
|
||||||
if self.writer is not None:
|
if self.writer is not None:
|
||||||
self.writer.send_msdp(variables)
|
self.writer.send_msdp(variables)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gmcp_enabled(self) -> bool:
|
||||||
|
"""Whether this client has GMCP negotiated."""
|
||||||
|
if self.writer is None:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
self.writer.local_option.enabled(GMCP)
|
||||||
|
or self.writer.remote_option.enabled(GMCP)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def msdp_enabled(self) -> bool:
|
||||||
|
"""Whether this client has MSDP negotiated."""
|
||||||
|
if self.writer is None:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
self.writer.local_option.enabled(MSDP)
|
||||||
|
or self.writer.remote_option.enabled(MSDP)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global registry of connected players
|
# Global registry of connected players
|
||||||
players: dict[str, Player] = {}
|
players: dict[str, Player] = {}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import tomllib
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import telnetlib3
|
import telnetlib3
|
||||||
|
from telnetlib3 import GMCP, MSDP, WILL
|
||||||
|
from telnetlib3.server import TelnetServer
|
||||||
from telnetlib3.server_shell import readline2
|
from telnetlib3.server_shell import readline2
|
||||||
|
|
||||||
import mudlib.combat.commands
|
import mudlib.combat.commands
|
||||||
|
|
@ -224,6 +226,18 @@ async def handle_login(
|
||||||
return {"success": False, "player_data": None}
|
return {"success": False, "player_data": None}
|
||||||
|
|
||||||
|
|
||||||
|
class MudTelnetServer(TelnetServer):
|
||||||
|
"""Telnet server that offers GMCP and MSDP during negotiation."""
|
||||||
|
|
||||||
|
def begin_negotiation(self) -> None:
|
||||||
|
"""Offer GMCP and MSDP as part of initial negotiation."""
|
||||||
|
super().begin_negotiation()
|
||||||
|
assert self.writer is not None
|
||||||
|
gmcp_ok = self.writer.iac(WILL, GMCP)
|
||||||
|
msdp_ok = self.writer.iac(WILL, MSDP)
|
||||||
|
log.debug("offered GMCP=%s MSDP=%s", gmcp_ok, msdp_ok)
|
||||||
|
|
||||||
|
|
||||||
async def shell(
|
async def shell(
|
||||||
reader: telnetlib3.TelnetReader | telnetlib3.TelnetReaderUnicode,
|
reader: telnetlib3.TelnetReader | telnetlib3.TelnetReaderUnicode,
|
||||||
writer: telnetlib3.TelnetWriter | telnetlib3.TelnetWriterUnicode,
|
writer: telnetlib3.TelnetWriter | telnetlib3.TelnetWriterUnicode,
|
||||||
|
|
@ -242,10 +256,14 @@ async def shell(
|
||||||
_writer.write("What is your name? ")
|
_writer.write("What is your name? ")
|
||||||
await _writer.drain()
|
await _writer.drain()
|
||||||
|
|
||||||
name_input = await readline2(_reader, _writer)
|
# Skip empty lines from client negotiation bytes, only close on actual disconnect
|
||||||
if name_input is None or not name_input.strip():
|
while True:
|
||||||
_writer.close()
|
name_input = await readline2(_reader, _writer)
|
||||||
return
|
if name_input is None:
|
||||||
|
_writer.close()
|
||||||
|
return
|
||||||
|
if name_input.strip():
|
||||||
|
break
|
||||||
|
|
||||||
player_name = name_input.strip()
|
player_name = name_input.strip()
|
||||||
|
|
||||||
|
|
@ -527,7 +545,11 @@ async def run_server() -> None:
|
||||||
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
||||||
# telnetlib3 still waits for the full timeout. 0.5s is plenty.
|
# telnetlib3 still waits for the full timeout. 0.5s is plenty.
|
||||||
server = await telnetlib3.create_server(
|
server = await telnetlib3.create_server(
|
||||||
host=HOST, port=PORT, shell=shell, connect_maxwait=0.5
|
host=HOST,
|
||||||
|
port=PORT,
|
||||||
|
shell=shell,
|
||||||
|
connect_maxwait=0.5,
|
||||||
|
protocol_factory=MudTelnetServer,
|
||||||
)
|
)
|
||||||
log.info("listening on %s:%d", HOST, PORT)
|
log.info("listening on %s:%d", HOST, PORT)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,15 @@ requires_zork = pytest.mark.skipif(not ZORK_PATH.exists(), reason="zork1.z3 not
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MockWriter:
|
class MockWriter:
|
||||||
|
def __post_init__(self):
|
||||||
|
# Mock option negotiation state
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
self.local_option = MagicMock()
|
||||||
|
self.local_option.enabled = MagicMock(return_value=False)
|
||||||
|
self.remote_option = MagicMock()
|
||||||
|
self.remote_option.enabled = MagicMock(return_value=False)
|
||||||
|
|
||||||
def write(self, data):
|
def write(self, data):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ def mock_writer():
|
||||||
writer.drain = AsyncMock()
|
writer.drain = AsyncMock()
|
||||||
writer.send_gmcp = MagicMock()
|
writer.send_gmcp = MagicMock()
|
||||||
writer.send_msdp = MagicMock()
|
writer.send_msdp = MagicMock()
|
||||||
|
# Option negotiation state (for gmcp_enabled/msdp_enabled checks)
|
||||||
|
writer.local_option = MagicMock()
|
||||||
|
writer.local_option.enabled = MagicMock(return_value=True)
|
||||||
|
writer.remote_option = MagicMock()
|
||||||
|
writer.remote_option.enabled = MagicMock(return_value=True)
|
||||||
return writer
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -226,6 +231,21 @@ def test_send_msdp_vitals(player):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_msdp_vitals_dedup(player):
|
||||||
|
"""Test MSDP skips sending when values haven't changed."""
|
||||||
|
send_msdp_vitals(player)
|
||||||
|
assert player.writer.send_msdp.call_count == 1
|
||||||
|
|
||||||
|
# second call with same values — should be suppressed
|
||||||
|
send_msdp_vitals(player)
|
||||||
|
assert player.writer.send_msdp.call_count == 1
|
||||||
|
|
||||||
|
# change a value — should send again
|
||||||
|
player.pl = 80.0
|
||||||
|
send_msdp_vitals(player)
|
||||||
|
assert player.writer.send_msdp.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
def test_player_send_gmcp(player):
|
def test_player_send_gmcp(player):
|
||||||
"""Test Player.send_gmcp convenience method."""
|
"""Test Player.send_gmcp convenience method."""
|
||||||
player.send_gmcp("Test.Package", {"key": "value"})
|
player.send_gmcp("Test.Package", {"key": "value"})
|
||||||
|
|
@ -502,3 +522,58 @@ async def test_char_status_sent_on_rest_complete(player):
|
||||||
]
|
]
|
||||||
assert len(status_calls) == 1
|
assert len(status_calls) == 1
|
||||||
assert status_calls[0][0][1]["resting"] is False
|
assert status_calls[0][0][1]["resting"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_msdp_vitals_skipped_when_not_negotiated(player):
|
||||||
|
"""Test send_msdp_vitals skips when MSDP is not negotiated."""
|
||||||
|
player.writer.local_option.enabled.return_value = False
|
||||||
|
player.writer.remote_option.enabled.return_value = False
|
||||||
|
|
||||||
|
send_msdp_vitals(player)
|
||||||
|
|
||||||
|
player.writer.send_msdp.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_msdp_enabled_property(player):
|
||||||
|
"""Test msdp_enabled reflects negotiation state."""
|
||||||
|
player.writer.local_option.enabled.return_value = False
|
||||||
|
player.writer.remote_option.enabled.return_value = False
|
||||||
|
assert not player.msdp_enabled
|
||||||
|
|
||||||
|
player.writer.local_option.enabled.return_value = True
|
||||||
|
assert player.msdp_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def test_gmcp_enabled_property(player):
|
||||||
|
"""Test gmcp_enabled reflects negotiation state."""
|
||||||
|
player.writer.local_option.enabled.return_value = False
|
||||||
|
player.writer.remote_option.enabled.return_value = False
|
||||||
|
assert not player.gmcp_enabled
|
||||||
|
|
||||||
|
player.writer.remote_option.enabled.return_value = True
|
||||||
|
assert player.gmcp_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def test_msdp_enabled_no_writer():
|
||||||
|
"""Test msdp_enabled with no writer returns False."""
|
||||||
|
p = Player(name="NoWriter", writer=None)
|
||||||
|
assert not p.msdp_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def test_gmcp_enabled_no_writer():
|
||||||
|
"""Test gmcp_enabled with no writer returns False."""
|
||||||
|
p = Player(name="NoWriter", writer=None)
|
||||||
|
assert not p.gmcp_enabled
|
||||||
|
|
||||||
|
|
||||||
|
def test_gmcp_sends_skipped_when_not_negotiated(player):
|
||||||
|
"""Test all GMCP send functions skip when GMCP is not negotiated."""
|
||||||
|
player.writer.local_option.enabled.return_value = False
|
||||||
|
player.writer.remote_option.enabled.return_value = False
|
||||||
|
|
||||||
|
send_char_vitals(player)
|
||||||
|
send_char_status(player)
|
||||||
|
send_room_info(player)
|
||||||
|
send_map_data(player)
|
||||||
|
|
||||||
|
player.writer.send_gmcp.assert_not_called()
|
||||||
|
|
|
||||||
122
tests/test_help.py
Normal file
122
tests/test_help.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""Tests for client command."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.caps import ClientCaps
|
||||||
|
from mudlib.commands.help import cmd_client
|
||||||
|
from mudlib.player import Player, players
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_state():
|
||||||
|
players.clear()
|
||||||
|
yield
|
||||||
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
writer.send_gmcp = MagicMock()
|
||||||
|
writer.send_msdp = MagicMock()
|
||||||
|
writer.local_option = MagicMock()
|
||||||
|
writer.local_option.enabled = MagicMock(return_value=True)
|
||||||
|
writer.remote_option = MagicMock()
|
||||||
|
writer.remote_option.enabled = MagicMock(return_value=True)
|
||||||
|
writer.get_extra_info = MagicMock(
|
||||||
|
side_effect=lambda key: {
|
||||||
|
"TERM": "TINTIN",
|
||||||
|
"cols": 191,
|
||||||
|
"rows": 54,
|
||||||
|
}.get(key)
|
||||||
|
)
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_shows_protocols(mock_writer):
|
||||||
|
"""Test client command shows GMCP and MSDP status."""
|
||||||
|
p = Player(name="Test", writer=mock_writer)
|
||||||
|
p.caps = ClientCaps(ansi=True, truecolor=True)
|
||||||
|
await cmd_client(p, "")
|
||||||
|
|
||||||
|
output = mock_writer.write.call_args[0][0]
|
||||||
|
assert "GMCP: active" in output
|
||||||
|
assert "MSDP: active" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_shows_terminal_info(mock_writer):
|
||||||
|
"""Test client command shows terminal type and size."""
|
||||||
|
p = Player(name="Test", writer=mock_writer)
|
||||||
|
p.caps = ClientCaps(ansi=True)
|
||||||
|
await cmd_client(p, "")
|
||||||
|
|
||||||
|
output = mock_writer.write.call_args[0][0]
|
||||||
|
assert "type: TINTIN" in output
|
||||||
|
assert "size: 191x54" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_shows_color_depth(mock_writer):
|
||||||
|
"""Test client command shows color depth."""
|
||||||
|
p = Player(name="Test", writer=mock_writer)
|
||||||
|
p.caps = ClientCaps(ansi=True, truecolor=True, colors_256=True)
|
||||||
|
await cmd_client(p, "")
|
||||||
|
|
||||||
|
output = mock_writer.write.call_args[0][0]
|
||||||
|
assert "colors: truecolor" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_shows_mtts_flags(mock_writer):
|
||||||
|
"""Test client command shows MTTS capabilities."""
|
||||||
|
p = Player(name="Test", writer=mock_writer)
|
||||||
|
p.caps = ClientCaps(ansi=True, vt100=True, utf8=True, colors_256=True)
|
||||||
|
await cmd_client(p, "")
|
||||||
|
|
||||||
|
output = mock_writer.write.call_args[0][0]
|
||||||
|
assert "ANSI" in output
|
||||||
|
assert "VT100" in output
|
||||||
|
assert "UTF-8" in output
|
||||||
|
assert "256 colors" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_not_negotiated(mock_writer):
|
||||||
|
"""Test client command when protocols not negotiated."""
|
||||||
|
mock_writer.local_option.enabled.return_value = False
|
||||||
|
mock_writer.remote_option.enabled.return_value = False
|
||||||
|
|
||||||
|
p = Player(name="Test", writer=mock_writer)
|
||||||
|
p.caps = ClientCaps()
|
||||||
|
await cmd_client(p, "")
|
||||||
|
|
||||||
|
output = mock_writer.write.call_args[0][0]
|
||||||
|
assert "GMCP: not active" in output
|
||||||
|
assert "MSDP: not active" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_no_mtts():
|
||||||
|
"""Test client command with no MTTS capabilities."""
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
writer.local_option = MagicMock()
|
||||||
|
writer.local_option.enabled = MagicMock(return_value=False)
|
||||||
|
writer.remote_option = MagicMock()
|
||||||
|
writer.remote_option.enabled = MagicMock(return_value=False)
|
||||||
|
writer.get_extra_info = MagicMock(return_value=None)
|
||||||
|
|
||||||
|
p = Player(name="Test", writer=writer)
|
||||||
|
p.caps = ClientCaps()
|
||||||
|
await cmd_client(p, "")
|
||||||
|
|
||||||
|
output = writer.write.call_args[0][0]
|
||||||
|
assert "type: unknown" in output
|
||||||
|
assert "MTTS: none detected" in output
|
||||||
|
|
@ -35,6 +35,13 @@ class MockWriter:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.messages = []
|
self.messages = []
|
||||||
|
# Mock option negotiation state
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
self.local_option = MagicMock()
|
||||||
|
self.local_option.enabled = MagicMock(return_value=False)
|
||||||
|
self.remote_option = MagicMock()
|
||||||
|
self.remote_option.enabled = MagicMock(return_value=False)
|
||||||
|
|
||||||
def write(self, message: str):
|
def write(self, message: str):
|
||||||
self.messages.append(message)
|
self.messages.append(message)
|
||||||
|
|
|
||||||
15
uv.lock
15
uv.lock
|
|
@ -245,8 +245,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "telnetlib3"
|
name = "telnetlib3"
|
||||||
version = "2.3.0"
|
version = "2.4.0"
|
||||||
source = { directory = "../../src/telnetlib3" }
|
source = { directory = "../../src/telnetlib3" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "wcwidth" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
|
@ -255,6 +258,7 @@ requires-dist = [
|
||||||
{ name = "sphinx-autodoc-typehints", marker = "extra == 'docs'" },
|
{ name = "sphinx-autodoc-typehints", marker = "extra == 'docs'" },
|
||||||
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'" },
|
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'" },
|
||||||
{ name = "ucs-detect", marker = "extra == 'extras'", specifier = ">=2" },
|
{ name = "ucs-detect", marker = "extra == 'extras'", specifier = ">=2" },
|
||||||
|
{ name = "wcwidth", specifier = ">=0.2.13" },
|
||||||
]
|
]
|
||||||
provides-extras = ["docs", "extras"]
|
provides-extras = ["docs", "extras"]
|
||||||
|
|
||||||
|
|
@ -266,3 +270,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wcwidth"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
|
||||||
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue