# Protocolli gestiti Il client parla **IAOC telnet** (RFC 854) con un subset scelto di opzioni e **GMCP** come estensione JSON. Niente MSDP/MCCP/MXP in v0.2 (`WONT` su richiesta server). --- ## Telnet — state machine I byte IAC (`0xFF`) sono interpretati come command o iniziatore di subneg. Tutto il resto è testo che viene accumulato e poi emesso come un unico `TextEvent` per chunk letto. ``` byte new state effetto ───────────────────────────────────────────────────────────────────── 0xFF IAC nessuno 0xFF 0xFF NORMAL literal 0xFF nel testo 0xFF DO|DONT|WILL|WONT → NEGOTIATE parser aspetta option byte 0xFF SB → SB_OPTION parser aspetta option byte 0xFF SE NORMAL drop silenzioso 0xFF EOR NORMAL + emit PromptEvent 0xFF GA NORMAL + emit PromptEvent 0xFF + altro NORMAL drop silent (NOP/DM/AO/AYT…) ``` In subneg: ``` byte new state effetto ───────────────────────────────────────────────────────────────────── SB_OPTION: SB_DATA SB_DATA: non IAC accumula in _sb_buf SB_DATA: IAC SB_IAC SB_IAC : SE NORMAL + emit SubnegEvent(opt, _sb_buf) SB_IAC : IAC SB_DATA + literal 0xFF in _sb_buf SB_IAC : altro NORMAL abort della subneg ``` `EOR` (End Of Record, 239) e `GA` (Go Ahead, 249) sono entrambi interpretati come **prompt boundary**. Dead Souls li usa per delimitare l'output tra una stanza e l'altra. Il client li propaga come `PromptEvent`. Nella v0.2 nessun consumer in `cli.py` lo ascolta (`elif isinstance(ev, PromptEvent): return None` è un no‑op esplicito): è un **forward hook** per future euristiche di stanza basate sul confine EOR/GA. ### Negoziazione opzioni | Server chiede | Client risponde | Side effect | | --- | --- | --- | | `DO ECHO` | `WILL ECHO` | il prompt locale provvede l'eco | | `DO NAWS` (con `naws_size`) | `WILL NAWS` + `SB NAWS w h SE` | dimensione iniziale | | `DO TTYPE` (con `ttype`) | `WILL TTYPE` | set `_expects_ttype_send` | | `DO TTYPE` (SB SEND) | `SB TTYPE "XTERM-256COLOR" SE` | risposta | | `DO GMCP` (con `want_gmcp`) | `WILL GMCP` | abilita dispatch eventi GMCP | | `DO` altro | `WONT` | rifiuto gentile | | `DONT X` | `WONT X` | sempre | | `WILL X` | `DONT X` | server offers non accettati | | `WONT X` | `DONT X` | sempre | ### GMCP — WILL proattivo `TelnetConnection(want_gmcp=True)` invia `IAC WILL GMCP` al `connect()`. Questo è richiesto da alcuni MUD (Dead Souls incluso) che aspettano l'offerta del client invece di un `DO GMCP` server‑driven. --- ## GMCP — wire format GMCP è un subneg con option code `201`. Il payload è testo UTF‑8: ``` IAC SB GMCP [' ' ] IAC SE ``` Esempio: ``` IAC SB GMCP Char.Vitals {"hp":100,"max_hp":120,"sp":42,"mv":80} IAC SE ``` ### Parsing In `gmcp.parse_gmcp`: 1. `data.decode('utf-8', errors='replace')` (tollerante a byte non validi). 2. `lstrip()` per edge case con junk iniziale. 3. Split sul primo spazio: `head, _, rest = text.partition(' ')`. 4. Validazione `head` con `_MODULE_NAME_RE = ^[A-Za-z][A-Za-z0-9_.]*$`: rifiuta `.bad`, `1Room`, `Room..Info`, ecc. 5. `json.loads(rest)` se presente. Decode error → `data = None` (tolleranza: server potrebbe dribblare payload parziali). ### Moduli supportati out‑of‑the‑box | Modulo | Effetto | | --- | --- | | `Char.Vitals` | aggiorna `state.vitals` con sinonimi (`hp`/`health`/`life`) | | `Room.Info` | `mapper.visit()` con `id_override = "vnum{int(num)}"` se `num`/`vnum`/`id` è numerico | | `Char.Login`, `Char.Logout`, `Char.Status`, `Char.Info` | loggati (futuri hook) | | `Room.WalkDir` | loggato (futuro hook per movimento automatico) | Vedi `docs/events.md` per i payload registrati. --- ## Dead Souls — euristiche di stanza Dead Souls (driver FluffOS) usa convenzione di stanza testuale: ``` Inn Hall A warm fire crackles in the hearth. [ Exits: n e ] ``` Il parser `RollingRoomParser` riconosce: 1. **Nome**: prima riga non vuota del blocco corrente. 2. **Body**: tutte le righe tra nome e uscite (incluso l'eventuale riga `[ Exits: ... ]` nel body del `RoomBlock`). 3. **Exits**: l'ultima riga del blocco riconosciuta dal regex `_EXITS_RE` (bracketed/unbracketed, `:` o `-`). 4. **`No exits`**: la regex `_NO_EXITS_RE` rende la stanza "isolata" senza abortire. `parse_exits(line)` è tollerante: ``` Exits: north, south [ Exits: n e ] Exit - [ Exit: none ] ``` Tutti questi formati producono la stessa `list[str]` canonicalizzata. --- ## ANSI — stripper `ansi.strip_ansi(text)` rimuove sequenze CSI, OSC, charset designation e DEC private mode. Sequenze non riconosciute vengono lasciate nel testo (meglio un carattere spurio che un evento perso). Pattern coperti: ```regex \x1B\[[0-9;?]*[ -/]*[@-~] # CSI sequences (SGR, cursor, mode) | \x1B\][^\x07\x1B]*(?:\x07|\x1B\\) # OSC sequences (title, etc.) | \x1B[()][A-Za-z0-9] # Charset designation | \x1B\[?[0-9;]*[hl] # DEC private mode set/reset ``` **Single ESC byte** senza `[` successivo è lasciato nel testo. I log JSON e MD sono protetti da un `try/except` difensivo nel logger che, in caso di crash dello stripper, registra il raw (meglio spurio che event perso). --- ## Error handling sui byte non validi | Input | Comportamento | | --- | --- | | 0xFF in mezzo al testo (literal) | preservato nel testo | | Subneg interrotta | `SubnegEvent` mai emesso, stato torna a NORMAL; ok | | `IAC` con comando sconosciuto | drop silent | | UTF‑8 non valido | `errors='replace'` su tutto il testo | | GMCP JSON malformato | `GmcpEvent.data = None` (non abort) | | GMCP module name malformato | `parse_gmcp` ritorna `None` (non propagato) | | GMCP module name non‑riconosciuto | loggato come evento "gmcp" ma non dispatch | | Stdout errno dopo read | `IncompleteReadError` / `ConnectionResetError` → chiusura pulita |