10 KiB
Architettura di viandante
Versione: v0.2.x — client MUD CLI asincrono in Python ≥ 3.10 basato su
prompt_toolkit. Niente dipendenze pesanti: solo la libreria del prompt +pytest/ruffper la dev.
Visione d'insieme
Il client è organizzato in cinque pilastri indipendenti, composti
da cli.py che fa da orchestratore asincrono:
| Pilastro | Modulo | Ruolo |
|---|---|---|
| Trasporto | telnet.py |
State machine IAC + asyncio TCP + negoziazione opzioni (ECHO/NAWS/TTYPE/GMCP a livello di byte‑option; non parsa i payload). Nessuna conoscenza del MUD. |
| Euristiche di testo | deadsouls.py |
Parsing delle stanze Dead Souls / LP‑MUD: canonicalizzazione direzioni, rilevazione blocchi stanza, regex uscite. |
| Payload semantici | gmcp.py |
Decoder GMCP: validazione di forma del module name + estrazione tollerante Char.Vitals / Room.Info. |
| Grafo & layout | mapper.py |
Database stanze (id‑stable), BFS multi‑source per coordinate, render ASCII del grafo. |
| Osservabilità | logger.py |
Sink JSONL append‑only + Markdown streamed; redazione password; lock difensivo. |
| UI | cli.py |
Orchestrazione: prompt async, status bar, dispatcher telnet, auto‑login, comandi slash, terminazione pulita. |
A parte ansi.py (foglia pura: solo regex + re.sub), tutti gli altri
moduli sono foglie funzionali tranne cli.py.
Grafo delle dipendenze
┌─────────────────────┐
│ cli.py │ (orchestratore)
└────────┬────────────┘
│ importa
┌───────────────┬─────┴──────┬───────────────┬──────────────┐
│ │ │ │ │
┌──▼─────┐ ┌─────▼─────┐ ┌───▼──────┐ ┌───────▼──────┐ ┌─────▼──────┐
│ logger │ │ telnet │ │ gmcp │ │ mapper │ │ deadsouls │
└────────┘ └─────┬─────┘ └─────┬────┘ └───────┬──────┘ └─────┬──────┘
│ │ │ │
│ SubnegEvent │ │ import │
│ (OPT_GMCP) │ │ EXIT_ALIASES │
│ │ EXIT_ALIASES│ + offsets │
│ ├──┐ ├──────────────┘
│ │ └───────────┘
│ │
│ ascii parser (IACParser)
└─ nessuna dipendenza da altri moduli MUD
│
┌───▼──────┐
│ ansi.py │ (foglia pura)
└──────────┘
Regola di import: gmcp.py e mapper.py non dovrebbero
importare da cli.py (e non lo fanno). telnet.py non sa nulla di
GMCP: emette solo SubnegEvent(OPT_GMCP, data) e lascia che cli.py
lo smisti alla callback di _handle_gmcp.
Data flow di un frame in ingresso
server TCP
│
│ bytes grezzi
▼
┌──────────────────────────────────────────┐
│ TelnetConnection.read() │ chunk = 4096 byte
└──────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ IACParser.feed(chunk) │ state machine
│ • S.NORMAL → accumula testo │
│ • S.IAC → DO/DONT/WILL/WONT/SB │
│ • S.SB_* → accumulator dati subneg │
│ • EOR/GA → flush + PromptEvent │
└──────┬───────────────────────────────────┘
│ list[TelnetEvent]
▼
┌──────────────────────────────────────────┐
│ conn.read_loop(handler) │ smistamento (TUTTI gli eventi
│ • tutti gli eventi passano anche per │ passano anche per `handler`):
│ l'handler; Negotiate/Subneg vengono │ • NegotiateEvent → _on_control
│ PRIMA processati da _on_control, │ (risposta WILL/WONT/DONT)
│ POI inoltrati all'utente │ • SubnegEvent → handler → cli
│ │ → _handle_gmcp (se OPT_GMCP)
│ │ • TextEvent → print + log
│ │ • PromptEvent → marker (no‑op) │
└──────┬───────────────────────────────────┘
│ TextEvent | SubnegEvent
▼
┌──────────────────────────────────────────┐
│ _on_telnet_event (cli.py) │
│ • TextEvent → MAPPER (via deadsouls) │
│ • TextEvent → auto‑login (regex) │
│ • SubnegEvent(GMCP) → gmcp.parse + │
│ state.update │
└──────────────────────────────────────────┘
Nessun modulo a valle dell'IAC si preoccupa di IAC: è un boundary
pulito. gmcp.py riceve solo i bytes della subneg, non il
flusso completo. deadsouls.RollingRoomParser riceve solo testo
plain (ANSI già strippato da cli.py).
Boundary concerns (punti deboli noti)
-
Canonicalizzazione direzioni duplicata.
deadsouls.EXIT_ALIASESè la sorgente canonica. Tuttaviamapper.py,gmcp.pyecli.py(via_handle_gmcp:exits_list) chiamano tuttiEXIT_ALIASES.get(d, d)su ogni uscita. Rischio reale: se domani aggiungi un alias inEXIT_ALIASESe dimentichi di aggiornare la logica del consumer, ottieni nomi non canonicali.- Mitigazione breve: aggiungere
deadsouls.normalize_exits(list)e farla usare ovunque. - Mitigazione strutturale: promuovere
EXIT_ALIASES+DIRECTION_OFFSETSin un modulonavigation.pyda cuideadsoulsre‑importi per retro‑compatibilità.
- Mitigazione breve: aggiungere
-
Doppia fonte "camera corrente".
mapper.Mapper._currentè la verità. Macli.ClientStateesponeroom_namecome property che rilancia sumapper.current_room. Non c'è cache incli, quindi sono sincronizzati, ma l'oggettoClientStateha più campi di quanti_run()ne inizializzi alcuni (auto_password_prompted,vitals) — è verboso ma non buggy. -
Re‑entrata del logger in
__init__.EventLogger.__init__apre due file in scrittura + scrive l'header MD; un errore sulla seconda operazione lascia il primo file aperto fino al GC. È mitigato datry/except OSError+_close_files(), ma l'eccezione rilanciata può lasciare un file orfano sul disco se il kernel non chiude immediatamente. In pratica: ilfinallydi_runchiamastate.logger.close()e quindi GC non è mai l'unico meccanismo. -
Validazione GMCP solo di shape, non di semantica. La regex
_MODULE_NAME_RE = ^[A-Za-z][A-Za-z0-9_.]*$è ancorata e rifiuta correttamente nomi malformati (.bad,1Room,Room..Info). Tuttavia non valida la semantica del payload: unRoom.Infoconexitscome stringa invece didict/listproduce silenziosamente una lista uscite vuota. Documentato ma non ancora controllato. -
refresh_interval=0.5suPromptSession. Anche in idle ridisegna la toolbar due volte al secondo. Costo CPU trascurabile su laptop moderne ma visibile su terminali via SSH su linee lente. Accettabile per v0.2; documentato come noto.
Estensioni future raccomandate
| Estensione | Impatto | Dove intervenire |
|---|---|---|
directions.py (alias + offset + normalize) |
alto (boundary) | spostare EXIT_ALIASES/DIRECTION_OFFSETS da deadsouls.py, re‑importare per back‑compat |
| MSDP / MCCP / MXP | medio | aggiungere costanti + handling in telnet._negotiate, modulo parser parallelo a gmcp.py |
| Persistenza stanze su disco | medio | mapper.Mapper.to_json() / from_json(), hook in _run/_maybe_autologin fine sessione |
EventLogger su sink syslog/journald |
basso | aggiungere journald_path a __init__ + dispatcher |
Multi‑sessione (multi Mapper + tab) |
alto | refactoring ClientState in Session, lista in cli |
| mappa HTML/SVG | medio | nuovo mapper.render_svg() + renderer HTML minimale |
Decisioni di design (perché così)
| Decisione | Alternativa scartata | Motivo |
|---|---|---|
telnet.py non importa gmcp.py |
metodo parse_gmcp(self, ev) |
injection di dipendenza esplicita + testing più semplice |
Mappa lazy: coordinate solo a render() |
coordinate in tempo reale | permette backtracking senza snapshot, layout deterministico al render |
| Logger append‑only senza buffering | buffer batch + flush periodico | tail -f immediato è OK per volumi MUD (kB/s non MB/s) |
| Lock difensivo nel logger | single‑thread assumption | costo trascurabile, protegge da futuri worker thread |
| Auto‑login regex multi‑linea | stateful prompt matcher | regex robusta + documentata + testabile |
| NAWS solo iniziale (no SIGWINCH handler) | re‑invio su cambio finestra | première itération: il client non intercetta segnali; documentato come gap |
EventLogger come sink opt‑in (no default) |
always‑on log | sovraccarico inutile su sessioni interattive |