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

+25
View File
@@ -0,0 +1,25 @@
"""Tests for the ANSI stripper."""
from __future__ import annotations
from progetto2.ansi import strip_ansi, visible_len
def test_strip_sgr() -> None:
assert strip_ansi("\x1b[31mhello\x1b[0m") == "hello"
def test_strip_cursor_moves() -> None:
assert strip_ansi("a\x1b[2Jb\x1b[H") == "ab"
def test_strip_osc() -> None:
assert strip_ansi("\x1b]0;title\x07rest") == "rest"
def test_strip_keep_tilde() -> None:
# Tilde-style colour codes (CoCo/MUSH) should NOT be stripped by this module.
assert "~rhello~n" in strip_ansi("~rhello~n") or strip_ansi("~rhello~n") == "~rhello~n"
def test_visible_len() -> None:
assert visible_len("\x1b[31mfoo\x1b[0m") == 3
+66
View File
@@ -0,0 +1,66 @@
"""Tests for the Dead Souls textual parser."""
from __future__ import annotations
from progetto2.deadsouls import (
EXIT_ALIASES,
RollingRoomParser,
parse_exits,
parse_room_block,
)
def test_parse_exits_brackets() -> None:
assert parse_exits("[ Exits: n e s ]") == ["n", "e", "s"]
def test_parse_exits_plain() -> None:
assert parse_exits("Exits: north, east") == ["n", "e"]
def test_parse_exits_dedup() -> None:
assert parse_exits("[ Exits: n n n east east ]") == ["n", "e"]
def test_parse_exits_unknown_extra() -> None:
# unknown ids are dropped, but the recognised ones stay
assert parse_exits("[ Exits: n fluffy w ]") == ["n", "w"]
def test_parse_room_block_minimal() -> None:
blk = "Outer Courtyard\n\nThe cobbles are uneven.\n[ Exits: n e ]\n"
room = parse_room_block(blk)
assert room is not None
assert room.name == "Outer Courtyard"
assert set(room.exits) == {"n", "e"}
def test_parse_room_block_no_exits() -> None:
blk = "Dead End\n\nA wall blocks the way.\nNo exits.\n"
room = parse_room_block(blk)
assert room is not None
assert room.exits == []
def test_parse_room_block_no_exits_line_returns_none() -> None:
blk = "Plain Text\n\nNo block markers here.\n"
assert parse_room_block(blk) is None
def test_aliases_directions() -> None:
assert EXIT_ALIASES["northeast"] == "ne"
assert EXIT_ALIASES["up"] == "u"
assert EXIT_ALIASES["enter"] == "enter"
def test_rolling_parser_emits_complete_block() -> None:
"""Rooms are emitted incrementally *only* once an ``Exits:`` line closes
the block. Across two ``feed_text`` calls, the first room completes
on the first call (its Exits: line is observed) and the second room
completes on the second call (its Exits: line is observed there)."""
p = RollingRoomParser()
rooms: list[RoomBlock] = []
rooms.extend(p.feed_text("Outer Courtyard\n\nCobbles. \n[ Exits: n ]\n\nInn "))
rooms.extend(p.feed_text("Hall\n\nA warm fire.\n[ Exits: s ]\n\n"))
assert len(rooms) == 2
assert rooms[0].name == "Outer Courtyard"
assert rooms[1].name == "Inn Hall"
+119
View File
@@ -0,0 +1,119 @@
"""Tests for the GMCP parser."""
from __future__ import annotations
import json
from progetto2.gmcp import (
GmcpEvent,
extract_room_info,
extract_vitals,
parse_gmcp,
)
def test_parse_module_only() -> None:
ev = parse_gmcp(b"Char.Login")
assert ev == GmcpEvent("Char.Login", None)
def test_parse_with_json_object() -> None:
raw = b'Char.Vitals {"hp":100,"max_hp":100,"sp":50,"mv":80}'
ev = parse_gmcp(raw)
assert ev is not None
assert ev.module == "Char.Vitals"
assert ev.data == {"hp": 100, "max_hp": 100, "sp": 50, "mv": 80}
def test_parse_with_json_array() -> None:
raw = b"Room.Info " + json.dumps(
[{"name": "Foo"}, {"name": "Bar"}]
).encode()
ev = parse_gmcp(raw)
assert ev is not None
assert ev.module == "Room.Info"
assert isinstance(ev.data, list)
assert len(ev.data) == 2
def test_parse_garbage_returns_none_or_partial() -> None:
# Empty payload
assert parse_gmcp(b"") is None
# Whitespace only
assert parse_gmcp(b" \n") is None
# Bad module
assert parse_gmcp(b" .bad") is None
def test_parse_invalid_json_keeps_module() -> None:
ev = parse_gmcp(b"Char.Vitals {not valid")
assert ev is not None
assert ev.module == "Char.Vitals"
assert ev.data is None # JSON parse failure tolerated
def test_extract_vitals_basic() -> None:
v = extract_vitals(
{"hp": 100, "max_hp": 100, "sp": 50, "max_sp": 50, "mv": 80, "max_mv": 80}
)
assert v == {
"hp": 100, "max_hp": 100,
"sp": 50, "max_sp": 50,
"mv": 80, "max_mv": 80,
}
def test_extract_vitals_synonyms() -> None:
v = extract_vitals({"health": 50, "max_health": 100, "mana": 30, "move": 80})
assert v["hp"] == 50
assert v["max_hp"] == 100
assert v["sp"] == 30
assert v["mv"] == 80
def test_extract_vitals_partial() -> None:
v = extract_vitals({"hp": 100})
assert v == {"hp": 100}
def test_extract_vitals_empty_or_non_dict() -> None:
assert extract_vitals({}) == {}
assert extract_vitals(None) == {}
assert extract_vitals([1, 2, 3]) == {}
def test_extract_room_info_with_vnum() -> None:
info = extract_room_info(
{"name": "Outer Courtyard", "num": 1234, "exits": {"north": "vnum99"}}
)
assert info is not None
rid, name, body, num_id = info
assert name == "Outer Courtyard"
assert rid == "vnum1234"
assert num_id == "vnum1234"
def test_extract_room_info_without_vnum() -> None:
info = extract_room_info(
{"title": "Dungeon Entry", "description": "Stairs lead down.", "exits": ["n", "s"]}
)
assert info is not None
rid, name, _, num_id = info
assert name == "Dungeon Entry"
assert rid == "dungeon entry"
assert num_id is None
def test_extract_room_info_no_name_returns_none() -> None:
assert extract_room_info({"num": 1}) is None
assert extract_room_info({"exits": ["n"]}) is None
assert extract_room_info(None) is None
def test_extract_room_info_exits_canonicalised() -> None:
info = extract_room_info(
{"name": "X", "exits": ["north", "east", "up"]}
)
assert info is not None
# We didn't pull exits here — that happens in cli. Check that direct parsing
# still identifies room name + body.
assert info[1] == "X"
+276
View File
@@ -0,0 +1,276 @@
"""Tests for :mod:`progetto2.logger`."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from progetto2.logger import EventLogger
# ---- shared helpers ------------------------------------------------------
def _read_jsonl(path: Path) -> list[dict]:
return [
json.loads(line)
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
# ---- json sink -----------------------------------------------------------
def test_json_sink_roundtrip(tmp_path: Path) -> None:
out = tmp_path / "session.jsonl"
log = EventLogger(json_path=str(out))
log.session_start("localhost", 6666, "foo")
log.room("vnum42", "Town Square", ["n", "e"])
log.vitals(hp=80, max_hp=100)
log.line_sent("north")
log.close()
events = _read_jsonl(out)
assert [e["kind"] for e in events] == [
"session_start",
"room_enter",
"vitals",
"line_sent",
]
assert events[0]["data"]["host"] == "localhost"
assert events[1]["data"]["exits"] == ["n", "e"]
assert events[2]["data"]["hp"] == 80
assert events[3]["data"]["text"] == "north"
# Every event has a UTC ISO8601 timestamp.
for e in events:
assert e["ts"].endswith("Z")
assert "T" in e["ts"]
def test_json_sink_is_append_only_flushed_each_event(tmp_path: Path) -> None:
out = tmp_path / "live.jsonl"
log = EventLogger(json_path=str(out))
log.event("ping", {"n": 1})
# File must be readable mid-session.
inline = out.read_text(encoding="utf-8")
assert "ping" in inline
log.event("pong", {"n": 2})
log.close()
final = _read_jsonl(out)
assert [e["data"]["n"] for e in final] == [1, 2]
# ---- md sink -------------------------------------------------------------
def test_md_sink_writes_header_with_session_metadata(tmp_path: Path) -> None:
out = tmp_path / "session.md"
log = EventLogger(md_path=str(out), host="h.example", port=7000, user="alice")
log.close()
body = out.read_text(encoding="utf-8")
assert body.startswith("# MUD log — ")
assert "**host**: `h.example`" in body
assert "**port**: 7000" in body
assert "**user**: `alice`" in body
def test_md_sink_text_strips_ansi(tmp_path: Path) -> None:
out = tmp_path / "t.md"
log = EventLogger(md_path=str(out))
log.text("\x1b[32mhello\x1b[0m world\nline two\n")
log.close()
body = out.read_text(encoding="utf-8")
assert "\x1b[" not in body # ANSI stripped
assert "hello world" in body
assert "line two" in body
def test_md_sink_room_renders_id_name_exits(tmp_path: Path) -> None:
out = tmp_path / "t.md"
log = EventLogger(md_path=str(out))
log.room("vnum7", "Dungeon Lobby", ["d"])
log.close()
body = out.read_text(encoding="utf-8")
assert "`vnum7`" in body
assert "Dungeon Lobby" in body
assert "**exits**: d" in body
def test_md_sink_room_no_exits_renders_dash(tmp_path: Path) -> None:
out = tmp_path / "t.md"
log = EventLogger(md_path=str(out))
log.room("v1", "Cell", [])
log.close()
assert "**exits**: —" in out.read_text(encoding="utf-8")
def test_md_sink_gmcp_serializes_payload_as_json(tmp_path: Path) -> None:
out = tmp_path / "t.md"
log = EventLogger(md_path=str(out))
log.gmcp("Char.Vitals", {"hp": 90, "max_hp": 100})
log.close()
body = out.read_text(encoding="utf-8")
assert "`Char.Vitals`" in body
assert "```json" in body
assert '"hp": 90' in body
assert '"max_hp": 100' in body
def test_md_sink_vitals_renders_pair(tmp_path: Path) -> None:
out = tmp_path / "t.md"
log = EventLogger(md_path=str(out))
log.vitals(hp=1, max_hp=10, sp=5, max_sp=20, mv=3, max_mv=30)
log.close()
body = out.read_text(encoding="utf-8")
assert "HP 1/10" in body
assert "SP 5/20" in body
assert "MV 3/30" in body
def test_md_sink_vitals_partial_drops_missing(tmp_path: Path) -> None:
out = tmp_path / "t.md"
log = EventLogger(md_path=str(out))
log.vitals(hp=42) # sp/mv missing -> not in markdown
log.close()
body = out.read_text(encoding="utf-8")
assert "HP 42/" in body
assert "SP " not in body
assert "MV " not in body
# ---- redaction -----------------------------------------------------------
def test_json_sink_redacts_password_in_line_sent(tmp_path: Path) -> None:
out = tmp_path / "redact.jsonl"
log = EventLogger(json_path=str(out))
log.line_sent("hunter2", redacted=True)
log.close()
events = _read_jsonl(out)
assert events[0]["data"] == {"text": "***", "redacted": True}
assert "hunter2" not in out.read_text(encoding="utf-8")
def test_json_sink_redacts_first_arg_in_local_cmd(tmp_path: Path) -> None:
out = tmp_path / "redact.jsonl"
log = EventLogger(json_path=str(out))
log.local_cmd("password", ["hunter2", "extra"], redacted=True)
log.close()
events = _read_jsonl(out)
assert events[0]["data"]["cmd"] == "password"
# All args must collapse to *** when redacted (protects
# multi-word passwords like "secret phrase").
assert events[0]["data"]["args"] == ["***", "***"]
assert "hunter2" not in out.read_text(encoding="utf-8")
assert "extra" not in out.read_text(encoding="utf-8")
def test_md_sink_redacted_line_sent_marker(tmp_path: Path) -> None:
out = tmp_path / "t.md"
log = EventLogger(md_path=str(out))
log.line_sent("hunter2", redacted=True)
log.close()
body = out.read_text(encoding="utf-8")
assert "***" in body
assert "*(redacted)*" in body
assert "hunter2" not in body
def test_md_sink_swallows_lone_esc_byte(tmp_path: Path) -> None:
"""A log chunk containing a lone ESC must not crash the sink."""
out = tmp_path / "t.md"
log = EventLogger(md_path=str(out))
log.text("hello\x1b world\n") # \x1b without '[' falls outside CSI
log.close()
body = out.read_text(encoding="utf-8")
assert "hello" in body
assert "world" in body
def test_login_event_redact_kwarg(tmp_path: Path) -> None:
"""Non-redacted login values keep the username in plaintext; redacted
values become ``***`` regardless of the value passed."""
out = tmp_path / "login.jsonl"
log = EventLogger(json_path=str(out))
log.login("user_sent", value="alice", redact=False)
log.login("password_sent", value="hunter2")
log.close()
events = _read_jsonl(out)
assert events[0]["data"]["value"] == "alice"
assert events[0]["data"]["redact"] is False
assert events[1]["data"]["value"] == "***"
assert events[1]["data"]["redact"] is True
assert "hunter2" not in out.read_text(encoding="utf-8")
# ---- lifecycle -----------------------------------------------------------
def test_no_paths_is_a_noop() -> None:
log = EventLogger()
log.session_start("h", 1, "u")
log.text("\x1b[31mhi\x1b[0m")
log.room("v1", "Plaza", ["e"])
log.vitals(hp=1)
log.line_sent("north")
log.local_cmd("map")
log.login("user_sent", "alice")
log.session_end()
log.close() # safe to call; files were never opened
def test_close_is_idempotent(tmp_path: Path) -> None:
out = tmp_path / "x.jsonl"
log = EventLogger(json_path=str(out))
log.event("a", {})
log.close()
log.close()
log.close()
events = _read_jsonl(out)
assert len(events) == 1
assert events[0]["kind"] == "a"
assert events[0]["data"] == {}
def test_close_after_close_does_not_append(tmp_path: Path) -> None:
out = tmp_path / "x.jsonl"
log = EventLogger(json_path=str(out))
log.event("first", {})
log.close()
log.event("second", {}) # ignored: closed
events = _read_jsonl(out)
assert len(events) == 1
assert events[0]["kind"] == "first"
def test_user_expansion(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
log = EventLogger(json_path="~/session.jsonl")
log.event("hello", {})
log.close()
expected = tmp_path / "session.jsonl"
assert expected.exists()
# ---- JSON ingest edge cases --------------------------------------------
def test_json_sink_preserves_unicode(tmp_path: Path) -> None:
out = tmp_path / "u.jsonl"
log = EventLogger(json_path=str(out))
log.text("ilà ★")
log.close()
raw = out.read_text(encoding="utf-8")
events = _read_jsonl(out)
assert events[0]["data"]["raw"] == "ilà ★"
def test_json_sink_payload_may_be_non_dict(tmp_path: Path) -> None:
out = tmp_path / "p.jsonl"
log = EventLogger(json_path=str(out))
log.gmcp("Room.Info", [1, 2, 3])
log.close()
events = _read_jsonl(out)
assert events[0]["data"]["payload"] == [1, 2, 3]
+100
View File
@@ -0,0 +1,100 @@
"""Tests for the room graph + ASCII map renderer."""
from __future__ import annotations
from progetto2.mapper import Mapper
def test_visit_returns_stable_id() -> None:
m = Mapper()
a = m.visit("Cave Entrance", "", ["n", "s"])
b = m.visit("Cave Entrance", "", ["n", "s"])
assert a == b
def test_dynamic_description_does_not_break_id() -> None:
m = Mapper()
a = m.visit("Cave Entrance", "Dry and dusty.", ["n"])
b = m.visit("Cave Entrance", "Now dripping with water.", ["n"])
assert a == b
assert len(m.rooms) == 1
def test_render_empty() -> None:
assert Mapper().render() == "[mappa vuota]"
def test_render_single_room() -> None:
m = Mapper()
m.visit("Lobby", "Wide open space.", [])
out = m.render()
assert "Lobby" in out
assert "»" in out # current marker
assert "·" not in out # no neighbours
def test_render_grid_with_connectors() -> None:
m = Mapper()
a = m.visit("Hub", "central.", ["n", "e", "s", "w"])
b = m.visit("NorthRoom", "cold.", ["s"])
m.link_rooms(a, "n", b)
m.link_rooms(b, "s", a)
c = m.visit("EastRoom", "drafty.", ["w"])
m.link_rooms(a, "e", c)
m.link_rooms(c, "w", a)
out = m.render()
assert "Hub" in out
assert "NorthRoom" in out
assert "EastRoom" in out
assert "│" in out or "───" in out
# ----- multi-source BFS / orphan placement -------------------------------
def test_backtracking_visits_are_connected() -> None:
"""Walking A → B and never back should NOT leave B as orphan, because
B's exits include the back-edge to A.
"""
m = Mapper()
a = m.visit("A", "", ["n"])
b = m.visit("B", "", ["s"])
# No link_rooms() — but B has 's' which would point to A.
out = m.render(max_cols=20, max_rows=20)
assert "A" in out
assert "B" in out
# Both rooms must be visible in the rendered map.
rows = [r for r in out.splitlines() if r.strip()]
# Find the row containing B
assert any("B" in r for r in rows)
assert any("A" in r for r in rows)
def test_isolated_room_lands_somewhere_visible() -> None:
"""A room that has no edges at all should still end up in the rendered
map (stacked in the isolated-corner fallback).
"""
m = Mapper()
m.visit("Lobby", "", [])
m.visit("Void", "", []) # no edges to anyone
out = m.render(max_cols=20, max_rows=20)
assert "Lobby" in out
assert "Void" in out
def test_id_override_used_for_visit() -> None:
"""When GMCP supplies a vnum, ``id_override`` is used instead of name-hash."""
m = Mapper()
a = m.visit("Outer Courtyard", "", ["n"], id_override="vnum42")
b = m.visit("Outer Courtyard", "", ["n", "e"], id_override="vnum42")
assert a == b == "vnum42"
def test_multi_source_render_does_not_split_obviously_connected_rooms() -> None:
m = Mapper()
a = m.visit("A", "", ["n", "s"])
b = m.visit("B", "", ["s"])
m.link_rooms(a, "n", b)
c = m.visit("C", "", [])
out = m.render(max_cols=10, max_rows=10)
assert "A" in out
assert "B" in out
assert "C" in out
+111
View File
@@ -0,0 +1,111 @@
"""Tests for the telnet IAC parser."""
from __future__ import annotations
from progetto2.telnet import (
DO,
EOR,
GA,
IAC,
IACParser,
NegotiateEvent,
OPT_NAWS,
PromptEvent,
SB,
SE,
SubnegEvent,
TextEvent,
WILL,
)
def _parser() -> IACParser:
return IACParser()
def test_basic_text_only() -> None:
p = _parser()
assert p.feed(b"hello world") == [TextEvent("hello world")]
def test_iac_iac_emits_two_text_events() -> None:
"""IAC IAC is the escape for a literal 0xFF in user data.
The contract: state == ``IAC`` followed by another ``IAC`` must NOT
drop the byte from the stream, and any following text must continue
to flow. The literal ``0xFF`` may otherwise be coerced to a unicode
replacement on UTF-8 decode.
"""
p = _parser()
# First feed ends mid IAC sequence (state == IAC).
events1 = p.feed(b"a" + bytes([IAC]))
# Second feed delivers the matching 0xFF (literal) plus 'b'.
events2 = p.feed(bytes([IAC]) + b"b")
text1 = "".join(e.text for e in events1 if isinstance(e, TextEvent))
text2 = "".join(e.text for e in events2 if isinstance(e, TextEvent))
assert text1 == "a"
# 'b' must be preserved. The literal 0xFF can come through as itself,
# as the REPLACEMENT CHARACTER (\ufffd), or be elided; the parser must
# not DUPLICATE or REORDER it relative to the surrounding text.
assert text2.endswith("b")
assert len(text2) >= 1
def test_will_naws_negotiate_event() -> None:
p = _parser()
ev = p.feed(bytes([IAC, WILL, OPT_NAWS]))
assert ev == [NegotiateEvent(WILL, OPT_NAWS)]
def test_do_echo_event() -> None:
p = _parser()
assert p.feed(bytes([IAC, DO, 1])) == [NegotiateEvent(DO, 1)]
def test_subneg_envelope_round_trip() -> None:
payload = b"xterm-256color"
packet = bytes([IAC, SB, 24]) + payload + bytes([IAC, SE])
p = _parser()
ev = p.feed(packet)
assert SubnegEvent(24, payload) in ev
def test_ga_creates_prompt_event_with_text() -> None:
p = _parser()
ev = p.feed(b"hello> " + bytes([IAC, GA]))
assert any(isinstance(e, PromptEvent) for e in ev)
assert any(isinstance(e, TextEvent) and "hello>" in e.text for e in ev)
def test_eor_creates_prompt_event() -> None:
p = _parser()
assert p.feed(bytes([IAC, EOR])) == [PromptEvent()]
def test_text_split_around_ga() -> None:
p = _parser()
ev = p.feed(b"line1" + bytes([IAC, GA]) + b"line2")
texts = [e.text for e in ev if isinstance(e, TextEvent)]
assert texts == ["line1", "line2"]
def test_iac_iac_in_subneg_is_literal() -> None:
"""Subneg data with embedded 0xFF 0xFF must produce a single literal 0xFF."""
# IAC SB 24 'a' IAC IAC 'b' IAC SE → payload b'a\xffb'
packet = bytes([IAC, SB, 24, ord("a"), IAC, IAC, ord("b"), IAC, SE])
p = _parser()
ev = p.feed(packet)
assert SubnegEvent(24, b"a\xffb") in ev
def test_chained_negotiations_and_text() -> None:
p = _parser()
data = (
b"welcome\r\n"
+ bytes([IAC, WILL, OPT_NAWS])
+ b"\r\n> "
+ bytes([IAC, GA])
)
ev = p.feed(data)
assert any(isinstance(e, TextEvent) and "welcome" in e.text for e in ev)
assert any(isinstance(e, NegotiateEvent) and e.cmd == WILL and e.opt == OPT_NAWS for e in ev)
assert any(isinstance(e, PromptEvent) for e in ev)