Files
viandante/docs/architecture.md
T

10 KiB
Raw Blame History

Architettura di progetto2

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/ruff per 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)

  1. Canonicalizzazione direzioni duplicata. deadsouls.EXIT_ALIASES è la sorgente canonica. Tuttavia mapper.py, gmcp.py e cli.py (via _handle_gmcp:exits_list) chiamano tutti EXIT_ALIASES.get(d, d) su ogni uscita. Rischio reale: se domani aggiungi un alias in EXIT_ALIASES e 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_OFFSETS in un modulo navigation.py da cui deadsouls re‑importi per retro‑compatibilità.
  2. Doppia fonte "camera corrente". mapper.Mapper._current è la verità. Ma cli.ClientState espone room_name come property che rilancia su mapper.current_room. Non c'è cache in cli, quindi sono sincronizzati, ma l'oggetto ClientState ha più campi di quanti _run() ne inizializzi alcuni (auto_password_prompted, vitals) — è verboso ma non buggy.

  3. 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 da try/except OSError + _close_files(), ma l'eccezione rilanciata può lasciare un file orfano sul disco se il kernel non chiude immediatamente. In pratica: il finally di _run chiama state.logger.close() e quindi GC non è mai l'unico meccanismo.

  4. 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: un Room.Info con exits come stringa invece di dict/list produce silenziosamente una lista uscite vuota. Documentato ma non ancora controllato.

  5. refresh_interval=0.5 su PromptSession. 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