Initial commit: MUD client v0.2
This commit is contained in:
commit
0030fd7d82
26 files changed
+4421
No files matched your search
@@ -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).
|
||||
@@ -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 |
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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` |
|
||||
Reference in new issue
Block a user