# 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 |