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

+41
View File
@@ -0,0 +1,41 @@
# `docs/` — indice della documentazione di `progetto2`
Documentazione completa del client MUD CLI. La lingua è l'italiano per
coerenza con il `README.md` principale. I blocchi di codice e i nomi
di simboli rimangono in inglese.
| Sezione | Cosa trovi |
| --- | --- |
| [`architecture.md`](./architecture.md) | Analisi architetturale: dipendenze tra moduli, data flow, boundary, accoppiamenti noti. **Leggi prima questo.** |
| [`modules.md`](./modules.md) | Descrizione modulo‑per‑modulo: scopo, API pubblica, dipendenze interne, slot/responsabilità. |
| [`protocol.md`](./protocol.md) | Telnet (IAC state machine), GMCP wire format, euristiche Dead Souls, ANSI handling. |
| [`usage.md`](./usage.md) | Tutti i flag della CLI + esempi + flusso di auto‑login + comandi locali `/...`. |
| [`events.md`](./events.md) | Schema completo degli eventi emessi dal `EventLogger` (JSONL + Markdown). |
| [`development.md`](./development.md) | Setup dev, `pytest`, `ruff`, come aggiungere un modulo, debugging. |
| [`gitea.md`](./gitea.md) | Flusso di pubblicazione sul server Gitea `192.168.0.183:8085` (utente `truetype`) + **regole di sicurezza del token**. |
## Biglietto da visita
```
┌──────────────┐
│ cli.py │ ← orchestratore async (prompt / dispatcher)
└──────┬───────┘
│
┌───────────┼─────────────┐
│ │ │
┌─▼────┐ ┌────▼─────┐ ┌─────▼──────┐
│logger│ │ telnet │ │ mapper │
└──────┘ └─────┬────┘ └─────┬──────┘
│ │
│ GMCP ▲ via deadsouls (testo)
┌────────▼────────┐ │
│ gmcp │ │
└─────────────────┘ │
│
┌───────▼────────┐
│ deadsouls │
└────────────────┘
+ ansi (foglia pura)
```
Per la versione completa e discorsa vedi [`architecture.md`](./architecture.md).
+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 |
+174
View File
@@ -0,0 +1,174 @@
# Sviluppo
Setup locale, pytest, ruff, e come estendere il client.
---
## Setup
```bash
git clone https://192.168.0.183:8085/truetype/progetto2.git
cd progetto2
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
Lo sviluppo quotidiano è Python puro; nessun build system esterno
(setuptools è trasparente via `pyproject.toml`).
---
## Test
```bash
pytest # 66+ test (telnet + ansi + deadsouls + mapper + logger)
pytest tests/test_logger.py -v # solo il logger (più verboso)
pytest -k "test_rolling_parser" # filtrato per nome
pytest --tb=short # traceback corti
```
I test usano `pytest.ini_options = ["-q"]` di default in `pyproject.toml`.
### Cosa è coperto
| File | Copre |
| --- | --- |
| `test_ansi.py` | stripper ANSI + `visible_len` |
| `test_telnet.py` | IACParser (state machine completa) + TelnetConnection (mock asyncio) |
| `test_deadsouls.py` | `parse_exits` + `RollingRoomParser` scenario multi‑chunk |
| `test_mapper.py` | `Mapper.visit`, link, layout BFS, orphan placement, render |
| `test_logger.py` | EventLogger round‑trip JSONL, MD rendering per ogni `kind`, redazione password, idempotenza `close()`, lone‑ESC safety |
### Cosa *non* è coperto
- `cli.py` end‑to‑end (richiede telnet server; il vero test è una
smoke run contro il docker locale di Dead Souls).
- `mapper._render_vertical_row` (casi degeneri con celle non
continue; non aggiunti perché non osservati in pratica).
- Re‑entrata del logger in un worker thread (lock difensivo è
documentato ma non testato).
---
## Lint
```bash
ruff check src tests
ruff format src tests
```
Config in `pyproject.toml`:
```toml
[tool.ruff]
line-length = 100
target-version = "py310"
```
(Aggiungi regole strict `select = ["E", "F", "I", "B", "UP"]` se
vuoi enforcement.)
---
## Smoke test end‑to‑end
Dead Souls docker su `localhost:6666`:
```bash
# 2 secondi, log JSONL + MD
LOGJSON=$(mktemp /tmp/mud-json.XXXXXX.jsonl)
LOGMD=$(mktemp /tmp/mud-md.XXXXXX.md)
timeout 2 python -m progetto2 --host localhost --port 6666 \
--log-json "$LOGJSON" --log-md "$LOGMD" </dev/null >/dev/null 2>&1 || true
wc -l "$LOGJSON" "$LOGMD"
head -3 "$LOGJSON"
head -8 "$LOGMD"
```
Aspettative: almeno 2‑3 righe JSONL (session_start, text, session_end)
e un header MD con `host/port/user`.
---
## Come aggiungere un modulo
Esempio: nuovo modulo `directions.py` che assorbe `EXIT_ALIASES` da
`deadsouls.py`. Procedura:
1. Crea `src/progetto2/directions.py` con le costanti e `normalize_exits()`.
2. Aggiorna `deadsouls.py` → re‑import da `directions` per back‑compat.
3. Aggiorna `gmcp.py`, `mapper.py`, `cli.py` → import da `directions`.
4. Scrivi `tests/test_directions.py`.
5. Aggiorna questo file (`docs/development.md`) e `docs/modules.md`.
6. `pytest` + `ruff check`.
### Principi
- **Foglia pura sopra orchestratore.** Solo `cli.py` può dipendere da
più moduli. Mai il contrario.
- **No mutazione cross‑module.** Ogni modulo possiede il suo stato.
- **Mai dipendere dal payload GMCP da mapper o deadsouls.** Sono
moduli di testo puro.
- **Mai import `cli` da moduli bassi.** Solo `cli` sa di `prompt_toolkit`.
---
## Debugging tipico
| Sintomo | Tool |
| --- | --- |
| IAC parser perso un byte | `pytest tests/test_telnet.py -v` + aggiungere print in `_byte()` |
| GMCP module non riconosciuto | leggere `gmcp.parse_gmcp` con `data=b"..."` in REPL |
| Stanze non rilevate senza GMCP | stampare `state.parser._buf` dopo feed_text |
| Mapper collassa in `[mappa troppo larga]` | abbassare `max_cols`/`max_rows` in `render(...)` |
| Stanza GMCP arriva ma render non aggiorna | `state.mapper.current_room` punta ancora al vecchio? |
| Password finisce nel log | grep `--log-md` per `hunter2`/`***` ratio |
| Sessione hung dopo quit | `conn.close()` chiamato? `finally` cleanup? |
---
## Aggiungere un nuovo comando slash
1. In `cli.py:_handle_local_command`, aggiungi un ramo `elif cmd == "foo":`.
2. Se modifica stato → metti la logica prima del `if state.logger is not None: state.logger.local_cmd(...)`.
3. Se inoltra al MUD → usa `await conn.send_line("foo")`.
4. Aggiungi voce nella tabella di `docs/usage.md` e `_print_help()`.
5. Test: aggiungi in `tests/test_cli.py` (futuro; ancora non esiste).
---
## Versioning
`__version__` in `__init__.py` (`0.1.0` in v0.2). Bump:
- **patch** (0.1.x): bug fix, no nuovi flag.
- **minor** (0.x.0): nuovi flag, nuove feature senza break della CLI.
- **major** (x.0.0): break di `--log-*` schema eventi o rename simboli
pubblici dei moduli.
---
## CI minima (Gitea Actions, opzionale)
Esempio `.gitea/workflows/ci.yml`:
```yaml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- run: python -m pip install -e ".[dev]"
- run: pytest
- run: ruff check src tests
```
Adatta ai vincoli del tuo runner Gitea (alcuni runner non hanno
`actions/setup-python@v5`; pinning a versioni disponibili è tipico).
+209
View File
@@ -0,0 +1,209 @@
# Schema eventi del `EventLogger`
`EventLogger` espone due sink paralleli attivabili via `__init__`:
- **JSONL** (`--log-json PATH`) — uno `{"ts":..., "kind":..., "data":{...}}` per riga.
- **Markdown** (`--log-md PATH`) — heading `## HH:MM:SS.mmm — kind` seguito da
un body specifico del `kind`.
Entrambi `flush()` dopo ogni evento (sub‑os buffer vuoto) → adatto a
`tail -f`.
---
## Timestamp
- **JSONL**: ISO‑8601 UTC con precisione millisecondo + `Z` finale
(es. `"2026-07-05T12:34:56.789Z"`).
- **MD**: solo l'ora, `HH:MM:SS.mmm`.
Entrambi calcolati da `datetime.now(timezone.utc)` non da `time.time()`
(locale‑indipendente).
---
## Tipi di evento
### `session_start` (1 per sessione)
```json
{"kind": "session_start", "data": {"host": "localhost", "port": 6666, "user": ""}}
```
MD: header `# MUD log — <ts>` con `host`, `port`, `user` poi `---`.
### `session_end` (1 per sessione)
```json
{"kind": "session_end", "data": {"reason": "done"}}
```
Possibili `reason`: `done`, `quit`.
### `disconnect`
```json
{"kind": "disconnect", "data": {"reason": "local_close"}}
```
Emesso quando `conn.close()` viene chiamato explicitamente
(`--quit`, EOF, ecc.).
### `text`
```json
{"kind": "text", "data": {"raw": "Inn Hall\n\x1b[36mCobbles.\x1b[0m\n[ Exits: n ]\n"}}
```
Chunk ANSI grezzo dal server, subito dopo la IAC strip. Nel MD:
blocco ` ``` ` con testo privato di ANSI via `strip_ansi`.
> Attenzione: ANSI con byte non validi vengono preservati nel `raw`
> JSONL ma al massimo appaiono come "?" nel markdown. La redazione
> non si applica al text (se il server ti invia una password in
> chiaro la vedrai).
### `room_enter`
```json
{"kind": "room_enter", "data": {"id": "vnum12345", "name": "Inn Hall", "exits": ["n", "e"]}}
```
`id`:
- `vnum{int(num)}` se GMCP `Room.Info.num`/`vnum`/`id` era numerico.
- altrimenti `name.lower()` (case‑insensitive identity).
MD: lista bullet `id` / `name` / `exits`.
### `gmcp`
```json
{"kind": "gmcp", "data": {"module": "Char.Vitals", "payload": {"hp": 100, "max_hp": 120}}}
```
`payload` è il JSON decodificato (potrebbe essere `null` se il modulo
veniva emesso senza body). MD: blocco ` ```json ` con `payload` ordinato
per chiave + indentazione.
### `vitals`
```json
{"kind": "vitals", "data": {"hp": 100, "max_hp": 120, "sp": 50, "max_sp": 60, "mv": 80, "max_mv": 100}}
```
Solo i campi effettivamente presenti nel payload GMCP. MD:
`HP 100/120 — SP 50/60 — MV 80/100`.
### `line_sent`
```json
{"kind": "line_sent", "data": {"text": "north", "redacted": false}}
{"kind": "line_sent", "data": {"text": "***", "redacted": true}}
```
`redacted=true` ⇒ la `text` diventa `"***"` (la stringa originale
viene scartata). MD: `> … *(redacted)*` se redatto, altrimenti `> …`.
### `local_cmd`
```json
{"kind": "local_cmd", "data": {"cmd": "map", "args": [], "redacted": false}}
{"kind": "local_cmd", "data": {"cmd": "password", "args": ["***"], "redacted": true}}
```
Se `redacted=true`, `args` è collassato a una lista di `"***"` di pari
lunghezza all'originale (anche password multi‑parola).
### `subneg` (forward hook diagnostico)
```json
{"kind": "subneg", "data": {"opt": "NAWS", "bytes": 4}}
{"kind": "subneg", "data": {"opt": "TTYPE", "bytes": 0}}
```
Emesso da `cli.py` per ogni subnegoziazione telnet **non‑GMCP**
ricevuta. `opt` è il nome umano (`ECHO`/`SUPPRESS_GO_AHEAD`/
`TTYPE`/`NAWS`/`GMCP` o `opt_<num>` se sconosciuto) da `_OPT_NAMES`,
`bytes` è la lunghezza del payload. Utile in `tail -f` per
diagnosticare la negoziazione delle opzioni senza dover attivare
`--log DEBUG`.
MD: `- **opt**: NAWS` + `- **bytes**: 4`.
### `login`
```json
{"kind": "login", "data": {"phase": "user_sent", "value": "alice", "redact": false}}
{"kind": "login", "data": {"phase": "password_sent", "value": "***", "redact": true}}
{"kind": "login", "data": {"phase": "password_prompt", "value": "", "redact": true}}
```
`phase ∈ {user_sent, password_prompt, password_sent}`.
Default `redact=True` ⇒ `value` registrato come `"***"`. Passa
`redact=False` esplicitamente per i nomi account (devono essere
tracciabili per leggere i log).
MD: `**{phase}** → \`{value}\`` (con back‑tick escape).
---
## Esempio di sessione (estratti)
```bash
$ jq -c '.' < session.jsonl
{"ts":"2026-07-05T12:34:56.789Z","kind":"session_start","data":{"host":"localhost","port":6666,"user":"zork"}}
{"ts":"2026-07-05T12:34:57.012Z","kind":"text","data":{"raw":"Welcome to Dead Souls.\n"}}
{"ts":"2026-07-05T12:34:57.234Z","kind":"text","data":{"raw":"What name do you wish?\n"}}
{"ts":"2026-07-05T12:34:57.235Z","kind":"line_sent","data":{"text":"zork","redacted":false}}
{"ts":"2026-07-05T12:34:57.236Z","kind":"login","data":{"phase":"user_sent","value":"zork","redact":false}}
{"ts":"2026-07-05T12:34:58.001Z","kind":"text","data":{"raw":"Password:\n"}}
{"ts":"2026-07-05T12:34:58.002Z","kind":"login","data":{"phase":"password_prompt","value":"","redact":true}}
{"ts":"2026-07-05T12:35:00.500Z","kind":"line_sent","data":{"text":"***","redacted":true}}
{"ts":"2026-07-05T12:35:00.501Z","kind":"login","data":{"phase":"password_sent","value":"***","redact":true}}
{"ts":"2026-07-05T12:35:01.123Z","kind":"gmcp","data":{"module":"Room.Info","payload":{"num":12345,"name":"Town Square","exits":{"n":12346}}}}
{"ts":"2026-07-05T12:35:01.124Z","kind":"room_enter","data":{"id":"vnum12345","name":"Town Square","exits":["n"]}}
{"ts":"2026-07-05T12:35:30.000Z","kind":"session_end","data":{"reason":"quit"}}
```
---
## Helpers di analisi (`jq`)
```bash
# solo stanze visitate
jq -c 'select(.kind=="room_enter")' session.jsonl
# vitals (ultimo snapshot)
jq -c 'select(.kind=="vitals")' session.jsonl | tail -1
# righe inviate (escludendo redacted)
jq -c 'select(.kind=="line_sent" and .data.redacted==false)' session.jsonl
# tutti i comandi locali slash
jq -c 'select(.kind=="local_cmd")' session.jsonl
```
---
## Idempotenza
`EventLogger.close()` può essere chiamato più volte senza effetti
collaterali. Nel `finally` di `cli._run`, viene chiamato esattamente
una volta anche se l'utente fa `Ctrl‑C` due volte.
---
## Note di privacy
- Il client **non** logga la password in chiaro. Tutti i punti di
emissione relativi a credenziali sono protetti da `redacted=True`.
- `text` chunk può contenere **qualsiasi cosa** il server ti invii. Se
il MUD fa echo della tua password (raro, perché WILL ECHO è attivo),
apparirà nel log.
- `gmcp.payload` è decodificato; se un modulo MUD contiene segreti, li
vedrai nel log.
- Per ambienti condivisi si consiglia `--log-json` su FS cifrato e/o
log ruotato con uno scheduler esterno (es. `logrotate`).
+199
View File
@@ -0,0 +1,199 @@
# Pubblicazione su Gitea
Server: **http://192.168.0.183:8085**
Owner: `truetype`
Repo: `progetto2`
Branch iniziale: `main`
---
## ⚠️ Sicurezza del token
Il token Gitea in colonna `truetype` (stringa 40‑hex) fornisce accesso
**completo** all'account e ai suoi repo. Regole non negoziabili:
1. **Non committare mai** il token nel repo. Il comando di push
sotto lo passa *inline* nell'URL di una sola `git push`. Mai
`git config credential.helper`, mai `.netrc` committato, mai
variabili d'ambiente in uno script versionato.
2. **Revoca dopo l'uso.** Dopo il primo push riuscito, vai su
`http://192.168.0.183:8085/user/settings/applications` → revoca il
token usato. Generane uno nuovo *solo* se ti serve un altro push.
Conserva il vecchio token solo se prevedi push automatici da CI
(allora mettilo nei **secrets** della pipeline, non nel codice).
3. **Preferisci token *scoped***: quando ne crei uno nuovo, limita
`repository.write` e basta, non `repository.admin`.
4. **HTTPS vs SSH.** Se hai una chiave SSH sull'account Gitea,
preferisci sempre SSH: `git@192.168.0.183:8085:truetype/progetto2.git`
(nota: Gitea usa `:owner/repo.git` dopo il `:`). Nessun token in
chiaro è necessario.
> I log di esempio in questo file usano il placeholder `<TOKEN>`.
> **Non** sostituirlo durante la lettura: il token reale dovrebbe
> vivere solo nella shell history dell'operatore.
---
## Creazione del repo (una tantum)
```bash
# 1: crea il repo via API (body JSON minimo)
curl -X POST http://192.168.0.183:8085/api/v1/user/repos \
-H "Authorization: token <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name": "progetto2", "description": "MUD client (Dead Souls / LP-MUD)", "auto_init": false, "private": false}'
```
Risposta attesa: `201 Created` con JSON contenente `id`, `ssh_url`,
`clone_url`, `html_url`. Annota:
- `html_url` (visibile in browser)
- `clone_url` (es. `http://192.168.0.183:8085/truetype/progetto2.git`)
---
## Configurazione git locale (una tantum)
Da `/home/ubuntu/code/progetto2`:
```bash
git config user.name "truetype"
git config user.email "truetype@192.168.0.183"
```
(Non mettere `user.email` reale se non vuoi che appaia nei commit.)
---
## Primo push
Se `.git/` non esiste ancora:
```bash
cd /home/ubuntu/code/progetto2
git init
git branch -M main
```
Commit iniziale (esclude venv, pycache, cache pytest via `.gitignore`
già presente):
```bash
git add .
git commit -m "Initial commit: MUD client v0.2 (cli, telnet, gmcp, mapper, deadsouls, logger, tests)"
```
Push (token inline, **non** salvato):
```bash
git push https://truetype:<TOKEN>@192.168.0.183:8085/truetype/progetto2.git main
```
Verifica:
```bash
git remote -v
# deve mostrare origin → http://192.168.0.183:8085/truetype/progetto2.git
git log --oneline -5
```
Dopo il push, **revoca `<TOKEN>`** come da sezione sicurezza.
### Strip delle credenziali dalla config locale
Dopo un push con URL `https://user:token@host/...`, Gitea lascia
**l'URL completo (con token!)** scritto in `.git/config`. Rimuovi
subito le credenziali:
```bash
git remote set-url origin http://192.168.0.183:8085/truetype/progetto2.git
git remote -v
# deve mostrare: origin http://192.168.0.183:8085/truetype/progetto2.git (no user:token@)
```
Da questo momento i push successivi richiederanno il token solo se
usi HTTPS nudo. Per evitare di digitarlo ogni volta, configura una
**chiave SSH** (`http://192.168.0.183:8085/user/settings/keys`) e
cambia il remote in `git@192.168.0.183:8085:truetype/progetto2.git`.
---
## Push successivi (senza token)
Dopo aver configurato `git remote add origin ...` con un URL senza
credenziali (es. SSH o HTTPS nudo), i push successivi sono:
```bash
git add .
git commit -m "Fix: ..."
git push origin main
```
Se devi restare su HTTPS, configura un **credential helper**
*temporaneo* nella shell (niente su disco):
```bash
GIT_ASKPASS=/bin/true git -c credential.helper= -c core.askPass=/bin/true push origin main
```
(in pratica è più comodo usare SSH.)
---
## Branch & tag
```bash
# feature branch
git checkout -b feature/msdp-support
git push -u origin feature/msdp-support
# tag di release
git tag -a v0.2.0 -m "release v0.2.0"
git push origin v0.2.0
```
I tag abilitano la releases page di Gitea; usa i tag `vX.Y.Z` come nomi
dei binari / delle release notes.
---
## Re‑push dopo perdita di `.git/`
Se per qualunque motivo il `.git/` locale viene perso:
```bash
cd /home/ubuntu/code/progetto2
git init
git branch -M main
git remote add origin https://192.168.0.183:8085/truetype/progetto2.git
git add .
git commit -m "Restore: progetto2 v0.2"
git push -u origin main
# NB: questo push richiede di nuovo un token se SSH non è configurato
```
---
## Webhook di release (opzionale)
Per triggerare un deploy o un CI su tag push:
1. In Gitea: `Repo → Settings → Webhooks → Add webhook`
2. URL: il tuo endpoint CI (es. Drone, Woodpecker, Gitea Actions)
3. Events: `Push events` + `Create events` (quest'ultimo copre i tag)
4. Secret: `<WEBHOOK_SECRET>` (salvalo nei secrets della CI, non nel
codebase).
---
## Troubleshooting
| Errore | Causa | Rimedio |
| --- | --- | --- |
| `403 Forbidden` | token errato o revocato | rigenera; controlla che lo scope includa `repo` |
| `401 Unauthorized` | token mancante o scaduto | verifica header `Authorization` |
| `repository does not exist` | repo non creato lato Gitea | ricrea via API; assicurati che `owner`/`name` siano giusti |
| `failed to push some refs` | remote ahead | `git pull --rebase origin main` poi push |
| `Could not resolve host` | Gitea offline | controlla `curl http://192.168.0.183:8085/api/v1/version` |
| `Permission denied (publickey)` | SSH non configurato | aggiungi la chiave pubblica a `http://192.168.0.183:8085/user/settings/keys` |
+376
View File
@@ -0,0 +1,376 @@
# Moduli — tour ragionato
Un modulo per sezione. Le funzioni e le classi elencate sono quelle
dell'API *pubblica* del modulo — leaky implementation details sono
omesse (sono `_underscore_prefixed` e cambiano spesso).
---
## `ansi.py` — stripper ANSI
**Scopo.** Funzione pura per togliere gli escape ANSI/VT100 prima che il
testo venga dato in pasto a euristiche di parsing (stanze, login prompt,
mapper UI). È una foglia del grafo: non importa nulla e non sa nulla
del MUD.
**API pubblica.**
```python
def strip_ansi(text: str) -> str: ...
def strip_ansi_bytes(data: bytes) -> bytes: ...
def visible_len(text: str) -> int: ...
```
**Internals.** Una regex (`_ANSI_RE`) compilata a import‑time che
copre CSI, OSC, charset designation e DEC private mode. Sostituzione
`re.sub` lineare. I single ESC byte senza sequenza sono lasciati
intatti (non sono errori, sono escape non validi che la stampante
gestirà).
---
## `deadsouls.py` — euristiche Dead Souls / LP‑MUD
**Scopo.** Riconoscere stanze dal testo ricevuto dal server e
canonicalizzare le direzioni delle uscite. *Solo* testo e solo
euristica — nessuna conoscenza di IAC, GMCP, asyncio.
**API pubblica.**
```python
EXIT_ALIASES: dict[str, str]
DIRECTION_OFFSETS: dict[str, tuple[int, int]]
OPPOSITE: dict[str, str] # importato da mapper
def parse_exits(line: str) -> list[str]
@dataclass
class RoomBlock:
name: str
body: str
exits: list[str]
def parse_room_block(block: str) -> RoomBlock | None
class RollingRoomParser:
def __init__(self) -> None
def feed_text(self, text: str) -> list[RoomBlock]
def flush(self) -> list[RoomBlock]
```
**Strategia di parsing blocco stanza.** Basata su linee (non regex
multi‑linea):
1. Accumula testo in un buffer interno.
2. Aggiungi il nuovo testo.
3. Split per `\n`; conserva l'ultimo pezzo parziale come buffer.
4. Per ogni riga completa: se contiene `Exits:` → chiudi il blocco
corrente (nome = prima riga non vuota, body = resto) ed emetti un
`RoomBlock`. Altrimenti appendi al blocco corrente.
5. Il blocco corrente rimane bufferizzato per la prossima chiamata.
**Tolleranze.** ANSI già strippato a monte. Linee con la parola
"exits" in mezzo a dialoghi NPC sono al sicuro: `parse_exits`
controlla il *formato* (bracketed/unbracketed, ":" o "-") prima di
popolare la lista uscite.
---
## `gmcp.py` — parser GMCP
**Scopo.** Decodificare il payload GMCP (`IAC SB GMCP <module> [<json>]
IAC SE`) in `GmcpEvent(module, data)`. Validare la *forma* del module
name. Estrarre i campi tolleranti (sinonimi) di `Char.Vitals` /
`Room.Info`.
**API pubblica.**
```python
@dataclass(frozen=True)
class GmcpEvent:
module: str
data: Any
# moduli standard
MODULE_CHAR_LOGIN: str
MODULE_CHAR_LOGOUT: str
MODULE_CHAR_VITALS: str
MODULE_CHAR_STATUS: str
MODULE_CHAR_INFO: str
MODULE_ROOM_INFO: str
MODULE_ROOM_WALK_DIR: str
def parse_gmcp(data: bytes) -> GmcpEvent | None
def extract_vitals(payload: Any) -> dict[str, int]
def extract_room_info(payload: Any) -> tuple[str, str, str, str | None] | None
```
**Sinonimi tollerati (vitale).**
| Chiave canonica | Sinonimi accettati (case‑sensitive) |
| --- | --- |
| `hp` / `max_hp` | `hp`, `health`, `life` |
| `sp` / `max_sp` | `sp`, `mana`, `magic` |
| `mv` / `max_mv` | `mv`, `move`, `stam` |
**Room.Info.** Il primo argomento è l'`id` stabile della stanza: se
`num`/`vnum`/`id` è presente e numerico, uso `vnum{int(num)}`;
altrimenti `name.strip().lower()`. Questo è ciò che permette al mapper
di ri‑identificare la stessa stanza tra sessioni.
---
## `mapper.py` — grafo stanze + render ASCII
**Scopo.** Persistenza in‑memory delle stanze visitate e rendering
ASCII del grafo. Le coordinate vengono assegnate **solo** al momento
del render (lazy layout).
**API pubblica.**
```python
CELL_W: int = 22
@dataclass
class Room:
id: str
name: str
body: str
exits: dict[str, str] # direzione -> id vicino
class Mapper:
def __init__(self) -> None
def visit(self, name, body, exits, *, id_override=None) -> str
def link_rooms(self, from_id, direction, to_id) -> None
def move(self, direction: str) -> bool
@property
def current_room(self) -> Room | None
@property
def rooms(self) -> dict[str, Room]
def render(self, *, max_cols=12, max_rows=16) -> str
```
**Algoritmo di layout (3 fasi).**
1. **BFS dal current.** Anchor `(0, 0)` ed esplora i forward edges
noti. Su collisione, nudge `+1` su `y` (fino a 16 tentativi).
2. **Orphan placement.** Per ogni stanza non ancora piazzata, prova
un edge in *qualsiasi direzione* verso un placed‑room (forward o
reverse). Itera finché non ci sono più avanzamenti. Questo gestisce
il backtracking `n → s` dove la `s`‑room non era collegata via
`exits[e]` ma tramite il movimento utente.
3. **Stack in basso a destra.** Le stanze veramente isolate finiscono
qui. Se ne trovi mai una, hai probabilmente un bug di parsing: ogni
stanza raggiungibile ha almeno un edge.
**Snapshot in `_place_adjacent`.** `placed = list(coords.items())` viene
copiato *prima* di mutare `coords` (altrimenti l'iterazione successiva
vedrebbe il nuovo elemento appena inserito e potrebbe creare cicli).
**Render.** Coordinate y ordinate top‑down (y grande = nord). Celle di
larghezza fissa `CELL_W = 22`. Marker `»` per la stanza corrente, `·`
per le altre. I link `e → w` sono tratteggi `───`, le verticali `│`.
---
## `logger.py` — `EventLogger`
**Scopo.** Sink append‑only per eventi di sessione. Due formati
paralleli: JSON Lines (machine‑readable) e Markdown (human‑readable).
File aperti in write‑mode (non append): ogni sessione riparte pulito.
**API pubblica.**
```python
class EventLogger:
def __init__(
self,
json_path: str | None = None,
md_path: str | None = None,
*,
host: str = "",
port: int = 0,
user: str = "",
) -> None
def close(self) -> None
def event(self, kind: str, data: dict[str, Any] | None = None) -> None
# shortcut tipizzati
def session_start(self, host: str, port: int, user: str) -> None
def session_end(self, reason: str = "") -> None
def disconnect(self, reason: str = "") -> None
def text(self, raw_ansi: str) -> None
def room(self, rid: str, name: str, exits: list[str]) -> None
def gmcp(self, module: str, payload: Any) -> None
def vitals(
self, *,
hp=None, sp=None, mv=None,
max_hp=None, max_sp=None, max_mv=None,
) -> None
def line_sent(self, line: str, *, redacted: bool = False) -> None
def local_cmd(self, cmd, args=None, *, redacted: bool = False) -> None
def login(self, phase: str, value: str = "", *, redact: bool = True) -> None
```
**Thread safety.** `threading.Lock` interno. Oggi è uncontended
(asyncio è single‑threaded) ma difende da futuri worker thread. Tutte
le scritture passano per `_write_json_unlocked` + `_append_md_unlocked`
+ `_flush_unlocked` sotto il lock.
**Sicurezza password.** Tre punti di redazione:
- `line_sent(line, redacted=True)` → testo registrato come `"***"`.
- `local_cmd(cmd, args, redacted=True)` → args list collassato a `["***"]`.
- `login(phase, value, redact=True)` (default) → value registrato come `"***"`.
**Idempotenza.** `close()` è idempotente; `__init__` solleva `OSError`
in caso di errore di I/O e chiude gli handle aperti prima di rilanciare
(nessun file descriptor leak).
---
## `telnet.py` — trasporto telnet
**Scopo.** Implementare la state machine IAC (RFC 854) sopra un
`asyncio.StreamReader`/`StreamWriter` e gestire la negoziazione
opzioni (ECHO/NAWS/TTYPE/GMCP). **Nessuna** conoscenza del MUD o della
UI.
**API pubblica.**
```python
# costanti di protocollo
IAC, DO, DONT, WILL, WONT, SB, SE = 255, 253, 254, 251, 252, 250, 240
EOR, GA = 239, 249
OPT_ECHO, OPT_TTYPE, OPT_NAWS, OPT_GMCP = 1, 24, 31, 201
# eventi
@dataclass
class TextEvent: text: str
@dataclass
class NegotiateEvent: cmd: int; opt: int
@dataclass
class SubnegEvent: opt: int; data: bytes
@dataclass
class PromptEvent: ...
TelnetEvent = TextEvent | NegotiateEvent | SubnegEvent | PromptEvent
class IACParser:
"""Push parser: feed bytes, get events."""
def feed(self, buf: bytes) -> list[TelnetEvent]
class TelnetConnection:
def __init__(self, host, port, *,
naws_size=None, ttype="XTERM-256COLOR", want_gmcp=True) -> None
async def connect(self) -> None
async def close(self) -> None
async def read_loop(self, handler: Callable[[TelnetEvent], Awaitable[None]]) -> None
async def send(self, data: bytes) -> None
async def send_line(self, line: str) -> None
async def report_window_size(self, cols: int, rows: int) -> None
```
**State machine (`S` enum).**
```
NORMAL → byte normale → NORMAL
NORMAL → IAC → IAC
IAC → IAC → NORMAL (literal)
IAC → DO|DONT|WILL|WONT → NEGOTIATE
IAC → SB → SB_OPTION
IAC → SE → NORMAL (stray)
IAC → EOR|GA → NORMAL (PromptEvent emesso)
IAC → altro (NOP/DM/AO) → NORMAL
NEGOTIATE → <opt> → NORMAL (NegotiateEvent emesso)
SB_OPTION → <opt> → SB_DATA
SB_DATA → IAC → SB_IAC
SB_IAC → SE → NORMAL (SubnegEvent emesso)
SB_IAC → IAC → SB_DATA (literal)
SB_IAC → altro → NORMAL (abort)
```
**Negoziazione opzioni.**
| cmd | opt | Risposta client |
| --- | --- | --- |
| `DO` | `ECHO`/`NAWS`/`TTYPE`/`GMCP` se supportato | `WILL` (+ side‑effect: invio dimensione finestra per NAWS, set `expects_ttype_send` per TTYPE) |
| `DO` | altro | `WONT` |
| `DONT` | qualsiasi | `WONT` |
| `WILL` | qualsiasi | `DONT` (server offers non ci servono in v0.2) |
| `WONT` | qualsiasi | `DONT` |
**Side‑channel GMCP.** Se `want_gmcp=True`, `connect()` invia
proattivamente `IAC WILL GMCP` prima di leggere. Alcuni MUD preferiscono
l'offerta client piuttosto che `DO` server.
---
## `cli.py` — orchestratore
**Scopo.** Compostare tutti i moduli sopra in un'unica sessione
interattiva. Possiede il prompt, la status bar, il dispatcher telnet,
la macchina a stati di auto‑login e i comandi slash locali.
**API pubblica.**
```python
@dataclass
class ClientState:
mapper: Mapper
parser: RollingRoomParser
vitals: dict[str, int]
gmcp_enabled: bool
auto_user_sent: bool
auto_password_prompted: bool
auto_password_sent: bool
logger: EventLogger | None
def main(argv: list[str] | None = None) -> int
```
**Componenti.**
- `_format_prompt(state, user)` → `FormattedText` con `[room] <user> > `.
- `_format_status_bar(state)` → `FormattedText` con `room`, GMCP
on/off, hp/sp/mv.
- `_handle_gmcp(data, state)` → dispatch modulo → vitale/room/mapper.
- `_maybe_autologin(text, conn, state, args)` → matching regex su
`What name do you wish?` / `Password:` e invio automatico.
- `_on_telnet_event(ev, conn, state, args)` → dispatcher principale.
- `_input_loop(session, conn, state, user)` → prompt loop async.
- `_handle_local_command(line, conn, state) -> bool` → comandi slash.
- `_run(args)` → orchestrata con `asyncio.wait(FIRST_COMPLETED)`,
`conn.close()` *prima* del drain delle pending task.
**Comandi slash.**
| Slash | Implementazione | Note |
| --- | --- | --- |
| `/quit` `/exit` `/q` | `conn.close()` + `SystemExit(0)` | non loggato |
| `/help` `/?` | `_print_help()` | loggato |
| `/map` | `mapper.render()` stampato | loggato |
| `/where` | room corrente + uscite | loggato |
| `/look` | `conn.send_line("look")` | loggato |
| `/stats` | stanze, GMCP, vitali | loggato |
| `/vitals` | `state.vitals` | loggato |
| `/clear` | `prompt_toolkit.clear()` | loggato |
| `/password PWD...` | redacted + `conn.send_line(pwd)` | loggato (redatto) |
| altro `/...` | `return False` → forward al MUD | loggato come `line_sent` da `_input_loop` |
**Auto‑login state machine.**
```
text: "What name do you wish?"
↓
args.user exists & not auto_user_sent
↓
send_line(args.user); auto_user_sent = True
↓
text: "Password:"
↓
auto_password_prompted = True
├── args.password exists → send_line, auto_password_sent = True
└── args.password "" → stampa suggerimento /password PWD
```
+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 |
+194
View File
@@ -0,0 +1,194 @@
# Uso della CLI — riferimento completo
Il comando dopo `pip install -e .` è **`mudclient`** (entry point
definito in `pyproject.toml`). È equivalente a `python -m progetto2`.
---
## Flag
| Flag | Tipo | Default | Note |
| --- | --- | --- | --- |
| `--host HOST` | str | `localhost` | host MUD (default punta al docker locale di Dead Souls) |
| `--port PORT` | int | `6666` | porta TCP |
| `--user NAME` | str | `""` (vuoto = no auto‑login del nome) | auto‑login quando il server chiede "What name do you wish?" |
| `--password PWD` | str | `os.environ.get("MUDCLIENT_PASSWORD", "")` | password per auto‑login dopo `Password:` |
| `--cols COLS` | int | `120` | larghezza iniziale finestra (NAWS subneg) |
| `--rows ROWS` | int | `40` | altezza iniziale finestra |
| `--log LVL` | choice | `WARNING` | livello Python logging. Valori validi (rifiutati se diversi): `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`. |
| `--log-json PATH` | str | `""` (no log JSON) | path JSONL append‑only (vedi `docs/events.md`) |
| `--log-md PATH` | str | `""` (no log MD) | path Markdown streamed |
> **Sicurezza `--password`**: il valore è in chiaro in `ps`/`history`.
> Usare l'env `MUDCLIENT_PASSWORD` o `/password PWD` (redatto). Il
> client redige automaticamente il valore nei log JSON/MD.
---
## Esempi rapidi
```bash
# Connessione minimale al docker di sviluppo
mudclient
# Server remoto esplicito
mudclient --host mud.example.com --port 4000
# Auto‑login completo (account + password)
mudclient --user zork --password hunter2
# password via env (preferibile in CI / shell history):
MUDCLIENT_PASSWORD=hunter2 mudclient --user zork
# Auto‑login solo nome; password manuale quando il server chiede
mudclient --user zork
# Finestra ridotta (es. 80x24) per terminali piccoli
mudclient --cols 80 --rows 24
# Debug del parser / IAC
mudclient --log DEBUG --log-json ./debug.jsonl --log-md ./debug.md
# Log persistente di una sessione intera (rotation esterna)
mudclient --log-json ~/mud-$(date +%Y%m%d-%H%M).jsonl \
--log-md ~/mud-$(date +%Y%m%d-%H%M).md
```
---
## Comandi slash (locali)
| Slash | Implementazione | Loggato come |
| --- | --- | --- |
| `/help` o `/?` | stampa aiuto | `local_cmd` |
| `/map` | render ASCII del mapper | `local_cmd` |
| `/where` | "Sei in: {Room}. Uscite: {list}" | `local_cmd` |
| `/look` | `conn.send_line("look")` | `local_cmd` |
| `/stats` | locali scoperti, GMCP, vitali | `local_cmd` |
| `/vitals` | `state.vitals` raw | `local_cmd` |
| `/clear` | `prompt_toolkit.clear()` | `local_cmd` |
| `/quit` `/exit` `/q` | chiude + SystemExit(0) | non loggato |
| `/password PWD` | redacted + `conn.send_line(pwd)` | `local_cmd` (redatto) + `login` |
| `/...` (altro) | passa al MUD con `/` rimosso | `line_sent` |
`Ctrl-D` chiude in modo equivalente a `/quit`.
---
## Prompt e status bar
**Prompt** (ricostruito a ogni line):
```
[NomeStanza]> _
```
`[NomeStanza]` cyan brillante, `>` verde brillante.
**Status bar** (ricostruito a ogni refresh):
```
[stanza: Outer Courtyard] [GMCP: on] HP 100/100 SP 50/50 MV 80/80
```
Refresh forzato ogni **0.5 s** via `PromptSession(refresh_interval=0.5)`.
Costo: ~2 redraw/sec anche in idle. Documentato come scelta di
trade‑off tra reattività e CPU nel file `docs/architecture.md`.
---
## Flusso auto‑login
```
server: "What name do you wish? " (o "Enter your name:", "Login:")
client: send_line(args.user) ← solo se --user e one-shot
client: state.auto_user_sent = True
server: "Password: "
client: state.auto_password_prompted = True
├── args.password → send_line(args.password); state.auto_password_sent = True
└── args.password "" → stampa: "[login] server chiede password; digita /password <pwd>"
```
`auto_*_sent` è **one‑shot**: una seconda occorrenza di "What name…?"
non re‑invio il nome. Previene spam in caso di messaggi di echo o
re‑auth lato server.
**Varianti regex coperte** (`_LOGIN_RE`):
```
What name do you wish
Enter your name
Login: / Login name
Name to use
```
più prefissi opzionali `>` o `*`.
**Varianti password** (`_PASSWORD_RE`):
```
Password:
* Password ?
> Password:
```
Tolleranti a prefissi `> *`, spaziature e `: ?` finale.
Se il tuo MUD usa prompt diversi, modifica le regex in
`src/progetto2/cli.py` (sono 3 righe ciascuna).
---
## Output delle stanze
Dead Souls con GMCP attivo invia `Room.Info {num, name, desc, exits}`.
Il client usa `num` come id stabile della stanza. Senza GMCP, le
stanze sono rilevate euristicamente vedendo:
```
Inn Hall
A warm fire crackles.
[ Exits: n e ]
```
`RollingRoomParser` chiude il blocco su `Exits:` line. Il mapper
visita la stanza immediatamente. Puoi digitare `n` o `e` e la prossima
volta che il server conferma la stanza, **l'edge viene creato**.
---
## Rotazione dei log
I path `--log-json` e `--log-md` vengono **truncati** all'apertura.
Per conservare le sessioni precedenti:
```bash
mv ~/mud.jsonl{,.prev} && mudclient --log-json ~/mud.jsonl
```
Per archivi automatici prima di ogni sessione:
```bash
LOGBASE=~/mud-$(date +%Y%m%d-%H%M)
mudclient --log-json "$LOGBASE.jsonl" --log-md "$LOGBASE.md"
```
Per tail in tempo reale:
```bash
# in un secondo terminale durante una sessione attiva
tail -f ~/mud.jsonl | jq -c '.kind + ": " + (.data|tostring)'
```
---
## Diagnosi minimali
| Sintomo | Probabile causa | Cosa provare |
| --- | --- | --- |
| Il client si blocca dopo Connect | Server MUD non risponde `DO ECHO` / GMCP | `--log DEBUG` + `--log-json ./x.jsonl` |
| Prompt non ridisegnato | `refresh_interval` non rinfresca in idle | è normale, aspettare il prossimo input |
| `/map` mostra [mappa vuota] | nessuna stanza ancora riconosciuta | cammina un po', oppure controlla se `Room.Info` GMCP arriva |
| Login password non parte | regex `_PASSWORD_RE` non match | `--log DEBUG` e ispezione `data` di `text` events |
| Sessione termina subito | `conn.close()` per EOF inatteso | `--log INFO` per vedere l'eccezione in `read_loop` |