Initial commit: MUD client v0.2

This commit is contained in:
truetype committed 2026-07-07 15:38:28 +00:00
commit 0030fd7d82
26 files changed
+4421

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
# 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: <opt> 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 <module.path> [' ' <json>] 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 |