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
