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

+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
```