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.
This commit is contained in:
parent
c3848fe57d
commit
ee0dc839d8
6 changed files with 126 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
@ -108,6 +116,8 @@ 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."""
|
||||||
|
if not player.msdp_enabled:
|
||||||
|
return
|
||||||
player.send_msdp(
|
player.send_msdp(
|
||||||
{
|
{
|
||||||
"HEALTH": str(round(player.pl, 1)),
|
"HEALTH": str(round(player.pl, 1)),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -54,6 +56,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,17 @@ 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_advanced_negotiation(self) -> None:
|
||||||
|
"""Offer GMCP and MSDP alongside the standard options."""
|
||||||
|
super().begin_advanced_negotiation()
|
||||||
|
assert self.writer is not None
|
||||||
|
self.writer.iac(WILL, GMCP)
|
||||||
|
self.writer.iac(WILL, MSDP)
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -527,7 +540,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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -502,3 +507,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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue