Compare commits

..

7 commits

Author SHA1 Message Date
4279186eca
fixup! Add MSDP status bar and protocol negotiation to mud.tin
fixup! Add MSDP status bar and protocol negotiation to mud.tin

Remove manual IAC WILL event handlers that were killing the connection.
TinTin++ auto-negotiates GMCP and MSDP natively (responds DO to WILL).
The raw #send bytes were corrupting the telnet stream.
2026-02-12 12:15:53 -05:00
034e137d22
fixup! Add MSDP status bar and protocol negotiation to mud.tin 2026-02-12 12:15:53 -05:00
5c9c90c990
Add MSDP status bar and protocol negotiation to mud.tin 2026-02-12 12:15:53 -05:00
e0376bbb05
Fix reconnect alias and add comments to mud.tin 2026-02-12 12:15:53 -05:00
9fdc7b9cad
Add client command to show protocol and terminal info 2026-02-12 12:15:53 -05:00
27db31c976
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 12:15:53 -05:00
c3848fe57d
Update telnetlib3 to 2.4.0 2026-02-12 12:15:49 -05:00
10 changed files with 326 additions and 9 deletions

30
mud.tin
View file

@ -1,8 +1,28 @@
#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 MSDP: tintin++ auto-negotiates (responds DO to server's WILL).
#NOP store variables as they arrive and refresh the 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 +32,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

@ -108,6 +108,8 @@ def send_map_data(player: Player) -> None:
def send_msdp_vitals(player: Player) -> None:
"""Send MSDP variable updates for real-time gauges."""
if not player.msdp_enabled:
return
player.send_msdp(
{
"HEALTH": str(round(player.pl, 1)),

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
@ -54,6 +56,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,7 @@ import tomllib
from typing import cast
import telnetlib3
from telnetlib3 import GMCP, MSDP, WILL
from telnetlib3.server_shell import readline2
import mudlib.combat.commands
@ -238,6 +239,10 @@ async def shell(
log.debug("new connection from %s", _writer.get_extra_info("peername"))
# Offer GMCP and MSDP so clients can negotiate
_writer.iac(WILL, GMCP)
_writer.iac(WILL, MSDP)
_writer.write("Welcome to the MUD!\r\n")
_writer.write("What is your name? ")
await _writer.drain()

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
@ -502,3 +507,45 @@ 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

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" },
]