diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4d9beb3..dee60cb 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -14,9 +14,29 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv. - `src/mudlib/` - the engine (commands, world, combat, render, store) - `tests/` - pytest tests - `worlds/` - world definition files (yaml/toml, version controlled) -- `notes/` - design docs, daydreaming +- `docs/` - project knowledge (see below) +- `DREAMBOOK.md` - the vision, philosophy, wild ideas. not a spec - `repos/` - symlinked reference repos (telnetlib3, miniboa). gitignored, not our code +## Docs + +Three categories in `docs/`. Plain text, not markdown. + +- `docs/how/` - how things work. write one when you build something non-obvious. + terrain generation, command system, etc. aimed at someone reading the code + who wants the "why did we do it this way" context. +- `docs/why/` - design philosophy. telnet-first, text worlds, etc. the reasoning + behind big decisions. doesn't change often. +- `docs/lessons/` - things we learned the hard way. write one when you hit a + real bug or gotcha that cost time. charset-vs-mtts, etc. include the fix so + we don't repeat it. + +Update docs when: +- you build a new system (add a how/) +- you make a design decision worth explaining (add a why/) +- you debug something painful (add a lessons/) +- existing docs become wrong (update them) + ## Architecture - telnetlib3 is a **dependency**, not vendored. contribute fixes upstream diff --git a/docs/how/commands.txt b/docs/how/commands.txt new file mode 100644 index 0000000..232e7f8 --- /dev/null +++ b/docs/how/commands.txt @@ -0,0 +1,58 @@ +command system +============== + +commands are registered in a simple dict mapping names to async handlers. + + async def my_command(player: Player, args: str) -> None: + player.writer.write("hello\r\n") + await player.writer.drain() + + register("mycommand", my_command, aliases=["mc"]) + +dispatch parses input, looks up the handler, calls it: + + await dispatch(player, "mycommand some args") + +if the command isn't found, the player gets "Unknown command: ..." + +movement +-------- + +8 directions, each with short and long aliases: + + n/north s/south e/east w/west + ne/northeast nw/northwest se/southeast sw/southwest + +movement checks passability before updating position. impassable terrain +gives "You can't go that way." + +nearby players see arrival/departure messages: + + jared leaves east. + jared arrives from the west. + +"nearby" = within viewport range (10 tiles) of old or new position. + +look +---- + +look/l renders the viewport. player is always @ at center. other players +show as *. output is ANSI-colored. + +adding commands +--------------- + +1. create src/mudlib/commands/yourcommand.py +2. import register from mudlib.commands +3. define async handler(player, args) +4. call register() with name and aliases +5. import the module in server.py so registration runs at startup + +code +---- + +src/mudlib/commands/__init__.py registry + dispatch +src/mudlib/commands/movement.py direction commands +src/mudlib/commands/look.py look/l +src/mudlib/commands/quit.py quit/q +src/mudlib/player.py Player dataclass + registry diff --git a/docs/how/terrain-generation.txt b/docs/how/terrain-generation.txt new file mode 100644 index 0000000..d16ce9e --- /dev/null +++ b/docs/how/terrain-generation.txt @@ -0,0 +1,64 @@ +terrain generation +================== + +the world is a 2D grid of tiles generated deterministically from a seed. +same seed = same world, always. + +how it works +------------ + +1. generate a permutation table from the seed (shuffled 0-255) +2. compute elevation at 1/4 resolution using layered Perlin noise (3 octaves) +3. normalize elevation to [0, 1] +4. bilinear interpolate up to full resolution +5. derive terrain from elevation thresholds: + > 0.75 mountain ^ + > 0.55 forest T + > 0.25 grass . + > 0.15 sand : + <= 0.15 water ~ +6. trace rivers from random high-elevation points downhill to water + +the 1/4 resolution trick is why generation takes ~1s instead of ~9s for a +1000x1000 map. terrain features are large enough that interpolation doesn't +lose visible detail. + +geography emerges naturally from noise: mountain ranges form along ridges, +forests cluster at foothills, sand appears at shorelines, oceans fill low +basins. rivers connect highlands to water bodies. + +world config lives in worlds//config.toml: + + [world] + name = "Earth" + seed = 42 + width = 1000 + height = 1000 + +rendering +--------- + +viewport is centered on the player (@). size comes from NAWS (terminal +dimensions) — eventually. for now it's a fixed 21x11. + +ANSI colors per terrain type: + . grass green + ^ mountain dark gray + ~ water blue + T forest green + : sand yellow + @ player bold white + * entity bold red + +passability +----------- + +mountains and water are impassable. forest, grass, sand are walkable. +water will eventually have shallow/deep variants (wadeable vs boat-only). + +code +---- + +src/mudlib/world/terrain.py world generation + noise +src/mudlib/render/ansi.py ANSI color mapping +worlds/earth/config.toml earth config diff --git a/docs/lessons/charset-vs-mtts.txt b/docs/lessons/charset-vs-mtts.txt new file mode 100644 index 0000000..dbee5ad --- /dev/null +++ b/docs/lessons/charset-vs-mtts.txt @@ -0,0 +1,72 @@ +charset negotiation vs mtts: why tintin++ says WILL then REJECTED +================================================================= + +the problem +----------- + +telnetlib3 defaults to connect_maxwait=4.0, waiting for CHARSET encoding +negotiation to resolve. MUD clients like tintin++ reject CHARSET but advertise +encoding support through MTTS instead. result: 4 seconds of dead air on every +connection. + +the fix (our side) +------------------ + +pass connect_maxwait=0.5 to create_server(). the actual option negotiation +finishes in ~500ms. CHARSET gets rejected almost immediately but telnetlib3 +doesn't have a fallback path — it just waits for the timeout. 0.5s is plenty. + + server = await telnetlib3.create_server( + host="127.0.0.1", port=PORT, shell=shell, connect_maxwait=0.5 + ) + +the upstream fix (telnetlib3) +----------------------------- + +when CHARSET is rejected, telnetlib3 could check MTTS bit 3 for UTF-8 support +and resolve encoding immediately instead of running out the clock. this would +be a good contribution. the data is already there: + + MTTS 2825 → bit 3 set → client supports UTF-8 + +what happens on the wire +------------------------ + +telnetlib3 sends DO CHARSET. tintin++ responds WILL CHARSET ("i know what that +is") then immediately REJECTED ("but i don't want to use it"). this is normal +for MUD clients — they prefer MTTS. + + +0.000 server send DO CHARSET + +0.100 client recv WILL CHARSET + +0.100 server send SB CHARSET REQUEST UTF-8 ... US-ASCII + +0.200 client recv SB CHARSET REJECTED + +meanwhile, in the same negotiation, tintin++ already told us via MTTS: + + ttype3: MTTS 2825 + bit 0 (1) = ANSI color + bit 3 (8) = UTF-8 + bit 8 (256) = 256 colors + bit 9 (512) = OSC color palette + bit 11 (2048) = true color + +two protocols, one answer. MTTS (via TTYPE round 3) is what the MUD ecosystem +uses. RFC 2066 CHARSET is technically correct but practically ignored. + +will echo and mud clients +------------------------- + +related discovery: telnetlib3 intentionally skips WILL ECHO for detected MUD +clients. tintin++ is in the MUD_TERMINALS fingerprint list. MUD clients +interpret WILL ECHO as "password mode" and mask input, so the server avoids +sending it. the client handles its own local echo. + +this means will_echo is always False for tintin++, and that's correct. don't +try to fight it or wait for it. + +see also +-------- + +- MTTS spec: https://tintin.mudhalla.net/protocols/mtts/ +- RFC 2066: CHARSET option +- telnetlib3 source: server.py _negotiate_echo(), fingerprinting.py _is_maybe_mud() diff --git a/docs/why/telnet-first.txt b/docs/why/telnet-first.txt new file mode 100644 index 0000000..84aeb6a --- /dev/null +++ b/docs/why/telnet-first.txt @@ -0,0 +1,32 @@ +why telnet first +================ + +from the dreambook: "we honor telnet. the protocol is the interface." + +telnet is the native interface. a person with a terminal emulator gets the full +experience. no browser, no client download. `telnet mud.example.com 6789` and +you're in. + +telnetlib3 gives us: +- NAWS (terminal dimensions — adapt viewport to window size) +- TTYPE/MTTS (client capability detection) +- GMCP/MSDP (structured data for rich clients like Mudlet) +- SGA (suppress go-ahead for modern line handling) +- async server with reader/writer streams + +the MUD ecosystem has decades of protocol extensions built on telnet. we get +all of them for free by staying in the protocol. + +a web client, when it comes, should just be a terminal emulator (xterm.js) +pointing at the telnet port. same protocol, different transport. + +design consequences +------------------- + +- all output is text + ANSI escape codes. no binary formats. +- line endings are \r\n (telnet NVT standard) +- input is line-based (readline). no raw keystroke handling (yet) +- server-side echo is complicated — MUD clients handle their own echo + (see docs/lessons/charset-vs-mtts.txt for the will_echo story) +- NAWS tells us the terminal size. viewport adapts. small terminal = smaller + map view. this is a feature, not a limitation. diff --git a/docs/why/text-worlds.txt b/docs/why/text-worlds.txt new file mode 100644 index 0000000..4da9c35 --- /dev/null +++ b/docs/why/text-worlds.txt @@ -0,0 +1,26 @@ +why text +======== + +from the dreambook, drawing on david jacobson's research at brandeis: + +narrow bandwidth doesn't impoverish experience, it enriches imagination. when +you read "a torch flickers against wet stone walls" your brain builds a +cathedral. VR hands you someone else's cathedral. + +text worlds are collaborative hallucinations between the author and the reader. + +the MOO community proved this for decades. LambdaMOO, MediaMOO, PMC-MOO — +people formed real relationships, built real communities, lived real social +lives through nothing but text on a terminal. + +presence isn't about polygons. presence is about engagement, attention, the +feeling that you are *there* and that what happens *matters*. + +what the research showed +------------------------ + +- presence comes from engagement, not fidelity +- identity is constructed through language (description, emotes, speech) +- community forms through shared space and shared creation +- the mask enables authenticity — removing physical cues lets people construct + identity more deliberately. that's not deception, it's freedom. diff --git a/notes/DAYDREAMING.txt b/notes/DAYDREAMING.txt deleted file mode 100644 index be95e76..0000000 --- a/notes/DAYDREAMING.txt +++ /dev/null @@ -1,64 +0,0 @@ -i like to imagine blending concepts with a MUD, interactive fiction, text -based adventures. - -so in one part it's moving around like you are in a mud, room names, short -description perhaps, long description perhaps, and a little ascii -representation that perhaps renders on the left or right or above or beneath -room description and available exits - - -however, i love what trenton gave us. the ascii worlds that update on -movement/look and have you centered in them - -i love the math of the combat, you could understand damage with percentages as -a function of your powerlevel, which was also your health. stamina emptying -also had you passing out, iirc. tbh i dont entirely remember how it worked. im -actually quite fuzzy on it atm - -let me type some of the combat commands - -punch right/left [target, not needed if u have an active target aka in combat] -roundhouse [target] -dodge right/left -duck -jump -parry high/low - -you might see: - -jake arrives from the west -jake pulls back his right fist - -this informs you that you had better parry high or dodge left - -parry high was roughly needed.. in a small window.. not RIGHT away, and not too -late - -all commands took time so you could just parry high .9 seconds oto early and -then instnatly fire it agtain, yhou had to fail the parry first or something - -we will honestly have to tweak all tthese timings - ---- - -so i said we're on an ascii map, that was earth - -you could travel around earth and find mobs to find - -if your powerlevel was 100 - -and you fought a mob that had the same pl let us say, you could earn no mopre -than 10 pl from then, 10%. but if you were 1000pl and killed a 100pl, you -wouldnt gain 10%, the function doews account your actual power level too -(diminishign returns i guess idk, means yhou gotta move on and fight more -p[owerful mobs) - - -what i really want to think about though isnt all these details from ONE dbz -mud i played - -but, the.. framework to suport: - -ascii mobs, combat, that kinda stuff - -tweaking it, expanding it, etc diff --git a/notes/charset-vs-mtts.txt b/notes/charset-vs-mtts.txt deleted file mode 100644 index 59ee38f..0000000 --- a/notes/charset-vs-mtts.txt +++ /dev/null @@ -1,161 +0,0 @@ -charset negotiation vs mtts: why tintin++ says WILL then REJECTED -================================================================= - -the problem ------------ - -when connecting to a telnetlib3 server, there's a 4-second delay before the -shell starts. the server is waiting for encoding to resolve. it never does. - -what the logs show ------------------- - -the full option negotiation between telnetlib3 and tintin++ takes about 500ms. -everything resolves quickly - TTYPE, SGA, NAWS, NEW_ENVIRON. but CHARSET -negotiation goes like this: - - 26.220 server sends DO CHARSET - 26.332 tintin responds WILL CHARSET ("i can do charset negotiation") - 26.332 server sends SB CHARSET REQUEST UTF-8 UTF-16 LATIN1 ... US-ASCII - 26.442 tintin responds SB CHARSET REJECTED - -tintin++ said "i support charset negotiation" and then rejected a list of 16 -charsets including UTF-8 and US-ASCII. every charset the server knows about. - -the server marks encoding as unresolved, waits for connect_maxwait (4.0s), -gives up, falls back to US-ASCII, and finally starts the shell. - - 30.122 server "encoding failed after 4.00s: US-ASCII" - 30.122 server "negotiation complete after 4.00s." - -so the actual work is done in 500ms. the remaining 3.5 seconds is dead air. - -but tintin++ already told us the answer ----------------------------------------- - -in the same connection, tintin++ sent this through NEW_ENVIRON: - - CHARSET=ASCII - CLIENT_NAME=TinTin++ - CLIENT_VERSION=2.02.61 - MTTS=2825 - TERMINAL_TYPE=tmux-256color - -and through TTYPE cycling (3 rounds): - - ttype1: TINTIN++ - ttype2: tmux-256color - ttype3: MTTS 2825 - -MTTS is the MUD Terminal Type Standard, a bitmask the MUD community created -for advertising client capabilities. 2825 in binary: - - 2825 = 2048 + 512 + 256 + 8 + 1 - - bit 0 (1) = ANSI color - bit 3 (8) = UTF-8 - bit 8 (256) = 256 colors - bit 9 (512) = OSC color palette - bit 11 (2048) = true color - -bit 3 is set. tintin++ explicitly advertises UTF-8 support through MTTS. - -two standards, one winner -------------------------- - -RFC 2066 CHARSET (1997) is a proper telnet standard. it defines a full -negotiation dance: - - 1. one side sends DO CHARSET, other responds WILL CHARSET - 2. requester sends SB CHARSET REQUEST ... - 3. responder either ACCEPTED or REJECTED - -this is thorough but cumbersome. the MUD community needed something simpler. - -MTTS piggybacks on the TTYPE mechanism that already existed. instead of adding -a new option negotiation, it uses the third round of TTYPE cycling to send a -bitfield encoding all client capabilities in a single integer. no extra -round-trips, no subnegotiation, works with any server that already does TTYPE. - - round 1: client name "TINTIN++" - round 2: terminal type "tmux-256color" - round 3: MTTS bitfield "MTTS 2825" - -the MUD world settled on MTTS. clients implement it properly. RFC 2066 CHARSET -gets a polite WILL ("sure i know what that option is") followed by REJECTED -("but i don't actually want to use it"). tintin++ is not alone in this - most -MUD clients behave the same way. - -what telnetlib3 could do better -------------------------------- - -right now telnetlib3 waits for CHARSET to resolve encoding. when it gets -REJECTED, it has no fallback path except running out the clock. but by the time -CHARSET is rejected (26.442), the server already has: - - - MTTS=2825 via NEW_ENVIRON (26.595) or will have it momentarily - - MTTS 2825 via TTYPE round 3 (26.595) - -if the server checked MTTS bit 3 after CHARSET rejection, it could resolve -encoding to UTF-8 immediately and start the shell in under 600ms instead of -waiting the full 4 seconds. - -this would be a good upstream contribution to telnetlib3: when CHARSET is -rejected, check MTTS flags for encoding hints before falling back to the -timeout. - -the full annotated session --------------------------- - -timestamps are seconds within the connection. total negotiation takes ~477ms. -the 4-second mark is the connect_maxwait timeout. - - +0.000 server Connection from - - -- phase 1: TTYPE discovery -- - - +0.000 server send IAC DO TTYPE - +0.101 tintin recv IAC WILL TTYPE - +0.101 server send IAC SB TTYPE SEND IAC SE (ask for ttype1) - - -- phase 2: advanced negotiation begins after WILL TTYPE -- - - +0.102 server send IAC WILL SGA - +0.102 server send IAC WILL BINARY - +0.102 server send IAC DO NAWS - +0.102 server send IAC DO CHARSET - - +0.212 tintin recv IAC SB TTYPE IS 'TINTIN++' (ttype1: client name) - +0.212 server (recognizes MUD client, skips WILL ECHO) - +0.213 server send IAC DO NEW_ENVIRON - +0.213 server send IAC SB TTYPE SEND IAC SE (ask for ttype2) - - +0.213 tintin recv IAC DO SGA -> local_option[SGA] = True - +0.214 tintin recv IAC DONT BINARY -> local_option[BINARY] = False - +0.214 tintin recv IAC WILL NAWS - +0.214 tintin recv IAC SB NAWS (cols=89, rows=56) (terminal size!) - +0.214 tintin recv IAC WILL CHARSET - +0.214 server send IAC WILL CHARSET (reciprocating) - +0.214 server send IAC SB CHARSET REQUEST UTF-8 ... US-ASCII IAC SE - - -- charset rejected, but negotiation continues -- - - +0.323 tintin recv IAC WILL NEW_ENVIRON - +0.323 server send IAC SB NEW_ENVIRON SEND ... - +0.323 tintin recv IAC SB TTYPE IS 'tmux-256color' (ttype2: terminal) - +0.324 server send IAC SB TTYPE SEND IAC SE (ask for ttype3) - +0.324 tintin recv IAC SB CHARSET REJECTED *** rejected *** - - +0.434 tintin recv NEW_ENVIRON IS: CHARSET=ASCII - +0.477 tintin recv NEW_ENVIRON IS: CLIENT_NAME=TinTin++ - +0.477 tintin recv NEW_ENVIRON IS: CLIENT_VERSION=2.02.61 - +0.477 tintin recv NEW_ENVIRON IS: MTTS=2825 *** utf-8 is here *** - +0.477 tintin recv NEW_ENVIRON IS: TERMINAL_TYPE=tmux-256color - +0.477 tintin recv IAC SB TTYPE IS 'MTTS 2825' (ttype3: capability bits) - - -- all options resolved, but encoding still "pending" -- - - +4.004 server "encoding failed after 4.00s: US-ASCII" - +4.004 server "negotiation complete after 4.00s." - - -- shell finally starts, 3.5 seconds of dead air later --