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

+167
View File
@@ -0,0 +1,167 @@
# 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 |