Compare commits

...

11 commits

Author SHA1 Message Date
833c0efa3a
Add a thorough tintin document 2026-02-12 16:31:34 -05:00
200cc00129
Dedupe MSDP vitals to avoid spamming idle clients
Cache last-sent values on Player and skip send_msdp() when
nothing changed. Idle players no longer get a packet every second.
2026-02-12 16:31:17 -05:00
fb758c8f36
Add protocol negotiation doc 2026-02-12 16:24:37 -05:00
bd5f83e890
Skip empty lines during name prompt and fix tintin send syntax
The server now skips spurious empty lines from client negotiation bytes
during the name prompt, only closing on actual connection loss. This
makes the login flow robust against clients that send IAC bytes with
trailing CRLF during negotiation.

Also fixed tintin++ CATCH handlers to use proper \} syntax matching the
documented examples.
2026-02-12 16:24:37 -05:00
aafdcdca42
Move GMCP/MSDP offers to initial negotiation
The old begin_advanced_negotiation relied on a telnetlib3 hook that races with the negotiation timer — by the time it fires, the timer has already declared negotiation complete. Moving to begin_negotiation sends the offers alongside standard options (TTYPE, NAWS, ECHO) so clients see them immediately.
2026-02-12 16:24:37 -05:00
05b377f00a
Add debug logging for GMCP/MSDP negotiation offers 2026-02-12 16:24:37 -05:00
7ba4ab90fb
Add MSDP status bar and protocol negotiation to mud.tin 2026-02-12 16:24:37 -05:00
bcf8ae28d1
Fix reconnect alias and add comments to mud.tin 2026-02-12 15:58:54 -05:00
aeb3d31702
Add client command to show protocol and terminal info 2026-02-12 15:58:54 -05:00
ee0dc839d8
Offer GMCP/MSDP during connection and guard tick sends
The server never proactively offered GMCP or MSDP to clients, so
telnetlib3 logged "cannot send MSDP without negotiation" every second.
Now the server sends WILL GMCP and WILL MSDP on connection, and
send_msdp_vitals checks negotiation state before attempting to send.
2026-02-12 15:58:54 -05:00
c3848fe57d
Update telnetlib3 to 2.4.0 2026-02-12 12:15:49 -05:00
13 changed files with 8292 additions and 23 deletions

169
docs/how/protocols.rst Normal file
View 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

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@ mostly standalone subsystems.
- ``docs/how/terrain-generation.txt`` — perlin noise, elevation thresholds, river tracing
- ``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/protocols.rst`` — GMCP/MSDP negotiation, client detection, guard pattern
combat
------

34
mud.tin
View file

@ -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
#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
#NOP fly aliases: f<direction> = fly 5 in that direction
#NOP fly shortcuts: f + direction
#alias {fn} {fly north}
#alias {fs} {fly south}
#alias {fe} {fly east}
@ -12,10 +36,6 @@
#alias {fse} {fly southeast}
#alias {fsw} {fly southwest}
#NOP combat shortcuts
#alias {o} {sweep}
#alias {r} {roundhouse}
#alias {reconnect} {
#zap mud;
#session mud localhost 6789;
}

View file

@ -260,6 +260,69 @@ async def cmd_skills(player: Player, args: str) -> None:
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(
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:
"""Show help for a command or skill.
@ -293,7 +367,7 @@ async def cmd_help(player: Player, args: str) -> None:
args = args.strip()
if not args:
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
await _show_command_detail(player, args)

View file

@ -13,6 +13,8 @@ log = logging.getLogger(__name__)
def send_char_vitals(player: Player) -> None:
"""Send Char.Vitals — pl, max_pl, stamina, max_stamina."""
if not player.gmcp_enabled:
return
player.send_gmcp(
"Char.Vitals",
{
@ -26,6 +28,8 @@ def send_char_vitals(player: Player) -> None:
def send_char_status(player: Player) -> None:
"""Send Char.Status — flying, resting, mode, in_combat."""
if not player.gmcp_enabled:
return
player.send_gmcp(
"Char.Status",
{
@ -39,6 +43,8 @@ def send_char_status(player: Player) -> None:
def send_room_info(player: Player) -> None:
"""Send Room.Info — zone, coordinates, terrain, exits."""
if not player.gmcp_enabled:
return
from mudlib.zone import Zone
zone = player.location
@ -78,6 +84,8 @@ def send_room_info(player: Player) -> None:
def send_map_data(player: Player) -> None:
"""Send Room.Map — terrain viewport around player for client rendering."""
if not player.gmcp_enabled:
return
from mudlib.zone import Zone
zone = player.location
@ -107,12 +115,19 @@ def send_map_data(player: Player) -> None:
def send_msdp_vitals(player: Player) -> None:
"""Send MSDP variable updates for real-time gauges."""
player.send_msdp(
{
"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)),
}
)
"""Send MSDP variable updates for real-time gauges.
Skips sending if values haven't changed since last send.
"""
if not player.msdp_enabled:
return
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)

View file

@ -5,6 +5,8 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from telnetlib3 import GMCP, MSDP
from mudlib.caps import ClientCaps
from mudlib.entity import Entity
@ -28,6 +30,7 @@ class Player(Entity):
paint_mode: bool = False
painting: bool = False
paint_brush: str = "."
_last_msdp: dict = field(default_factory=dict, repr=False)
@property
def mode(self) -> str:
@ -54,6 +57,26 @@ class Player(Entity):
if self.writer is not None:
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
players: dict[str, Player] = {}

View file

@ -9,6 +9,8 @@ import tomllib
from typing import cast
import telnetlib3
from telnetlib3 import GMCP, MSDP, WILL
from telnetlib3.server import TelnetServer
from telnetlib3.server_shell import readline2
import mudlib.combat.commands
@ -224,6 +226,18 @@ async def handle_login(
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(
reader: telnetlib3.TelnetReader | telnetlib3.TelnetReaderUnicode,
writer: telnetlib3.TelnetWriter | telnetlib3.TelnetWriterUnicode,
@ -242,10 +256,14 @@ async def shell(
_writer.write("What is your name? ")
await _writer.drain()
name_input = await readline2(_reader, _writer)
if name_input is None or not name_input.strip():
_writer.close()
return
# Skip empty lines from client negotiation bytes, only close on actual disconnect
while True:
name_input = await readline2(_reader, _writer)
if name_input is None:
_writer.close()
return
if name_input.strip():
break
player_name = name_input.strip()
@ -527,7 +545,11 @@ async def run_server() -> None:
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
# telnetlib3 still waits for the full timeout. 0.5s is plenty.
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)

View file

@ -17,6 +17,15 @@ requires_zork = pytest.mark.skipif(not ZORK_PATH.exists(), reason="zork1.z3 not
@dataclass
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):
pass

View file

@ -47,6 +47,11 @@ def mock_writer():
writer.drain = AsyncMock()
writer.send_gmcp = 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
@ -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):
"""Test Player.send_gmcp convenience method."""
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 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
View 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

View file

@ -35,6 +35,13 @@ class MockWriter:
def __init__(self):
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):
self.messages.append(message)

15
uv.lock
View file

@ -245,8 +245,11 @@ wheels = [
[[package]]
name = "telnetlib3"
version = "2.3.0"
version = "2.4.0"
source = { directory = "../../src/telnetlib3" }
dependencies = [
{ name = "wcwidth" },
]
[package.metadata]
requires-dist = [
@ -255,6 +258,7 @@ requires-dist = [
{ name = "sphinx-autodoc-typehints", marker = "extra == 'docs'" },
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'" },
{ name = "ucs-detect", marker = "extra == 'extras'", specifier = ">=2" },
{ name = "wcwidth", specifier = ">=0.2.13" },
]
provides-extras = ["docs", "extras"]
@ -266,3 +270,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
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" },
]
[[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" },
]