From 47ef606e7fb5905ed085a4fd4ef87789fea7ffbd Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Mon, 9 Feb 2026 17:59:47 -0500 Subject: [PATCH] Update documents with new IF system --- .claude/CLAUDE.md | 1 + docs/how/if-journey.rst | 12 +- docs/how/if-terminal.txt | 152 +++++++++++++++++++++++ docs/lessons/dfrotz-prompt-detection.txt | 85 +++++++++++++ 4 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 docs/how/if-terminal.txt create mode 100644 docs/lessons/dfrotz-prompt-detection.txt diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 780a7b8..b1068f9 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -56,6 +56,7 @@ Update docs when: - content loading: TOML definitions for commands and combat moves, loaded at startup - entity model: Entity base class, Player and Mob subclasses sharing common interface - editor mode: in-world text editor with syntax highlighting and search/replace +- IF mode: interactive fiction via dfrotz subprocess, mode stack integration, spectator broadcasting. see `docs/how/if-terminal.txt` ## Style diff --git a/docs/how/if-journey.rst b/docs/how/if-journey.rst index 5154064..096cdba 100644 --- a/docs/how/if-journey.rst +++ b/docs/how/if-journey.rst @@ -13,7 +13,7 @@ Level 1 — terminal mode Subprocess (dfrotz), text in/out, spectators see text scrolling on a screen. Player sits at an arcade terminal, plays Zork. Others in the room see the output. The IF world is opaque — a black box. -Works today with dfrotz. Proven technology. +Implemented. See ``docs/how/if-terminal.txt`` for how the system works. Level 2 — inspectable world ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -174,12 +174,12 @@ Input routing Room-local broadcasting ~~~~~~~~~~~~~~~~~~~~~~~~ -Not implemented yet. IF integration is the first use case. Terminal object maintains spectator list, broadcasts output to all. Pattern will be reused for ambient messages, weather, room events. +Implemented for IF. ``broadcast_to_spectators()`` in ``if_session.py`` sends to all players at the same (x,y) location. Pattern will be reused for ambient messages, weather, room events. State storage ~~~~~~~~~~~~~ -Quetzal saves as blobs in SQLite. Same pattern as other binary persistence. +Quetzal saves stored as files in ``data/if_saves/{player}/{game}.qzl``. Filesystem approach (simpler than SQLite blobs for dfrotz, which already writes to disk). Terminal game object ~~~~~~~~~~~~~~~~~~~~ @@ -238,15 +238,17 @@ Concrete next steps, roughly ordered. Update as items get done. - [ ] prototype the hybrid: pick 5-10 common opcodes, port them from viola to zvm's architecture. see how the pattern feels. if it's smooth, the hybrid is viable. if every opcode is a battle, reconsider. -- [ ] build level 1 prototype: regardless of interpreter choice, implement the terminal object, IF mode, and subprocess dfrotz path. this proves the MUD-side architecture (mode stack, spectators, save/restore) independently of the interpreter question. +- [x] build level 1 prototype: regardless of interpreter choice, implement the terminal object, IF mode, and subprocess dfrotz path. this proves the MUD-side architecture (mode stack, spectators, save/restore) independently of the interpreter question. (done — see ``docs/how/if-terminal.txt``) - [ ] study MojoZork's multiplayer model: read the MultiZork source for how it handles multiple players in one z-machine. document the pattern for our eventual level 4. -- [ ] find the game files: locate freely distributable z-machine story files for the games we care about. Wizard Sniffer, Lost Pig, Zork (if legally available). +- [x] find the game files: locate freely distributable z-machine story files for the games we care about. Wizard Sniffer, Lost Pig, Zork (if legally available). (zork1.z3 bundled in content/stories/) related documents ----------------- +``docs/how/if-terminal.txt`` — how the level 1 IF system works (implementation reference) + ``docs/how/if-integration.txt`` — original research and integration plan (predates audits) ``docs/how/viola-embedding-audit.rst`` — detailed viola architecture audit diff --git a/docs/how/if-terminal.txt b/docs/how/if-terminal.txt new file mode 100644 index 0000000..8d11c39 --- /dev/null +++ b/docs/how/if-terminal.txt @@ -0,0 +1,152 @@ +if terminal — how the interactive fiction system works +====================================================== + +what it is +========== + +players can play z-machine interactive fiction games (zork, etc) from inside +the mud. input routes to a dfrotz subprocess instead of the command dispatcher. +other players in the room see the output scrolling on a virtual terminal. + +this is level 1 from if-journey.rst — the IF world is a black box. we pipe +text in and out. no interpreter embedding, no VM introspection. + + +how it works +============ + +three pieces: the session, the command, and the server integration. + + +IFSession (if_session.py) +------------------------- + +manages a single dfrotz subprocess. one session per player. + +spawns dfrotz with: + dfrotz -p -w 80 -m + + -p plain ASCII (no formatting escapes) + -w 80-column width + -m no MORE prompts (continuous output) + +IFResponse dataclass carries output + done flag back to caller. + +input flow: + player types something + -> server routes to if_session.handle_input() + -> escape commands (::quit, ::save, ::help) handled at mud layer + -> everything else written to dfrotz stdin + -> response read from dfrotz stdout + -> IFResponse returned to server + +reading output: + dfrotz writes text then shows a ">" prompt and waits for input. + _read_response() reads in 1024-byte chunks until it detects the prompt. + prompt detection checks for "> ", ">\n", ">\r\n" at end of raw bytes, + and "\n>" in the stripped text. bare ">" with no preceding newline + is only stripped when the raw bytes confirm a prompt pattern. + + idle timeout: 100ms (returns once data stops flowing) + overall deadline: 5 seconds per response + this was tricky to get right — see docs/lessons/dfrotz-prompt-detection.txt + + +escape commands +--------------- + +prefixed with :: to avoid conflicting with game input. + + ::quit exit game (auto-saves first) + ::save force save to disk + ::help show available escape commands + + +play command (commands/play.py) +------------------------------- + +entry point. registered as a normal-mode command. + + play list available stories + play zork start zork (exact or prefix match) + +story discovery: scans content/stories/ for .z3, .z5, .z8, .zblorb files. +prefix matching: "zork" matches "zork1.z3". + +on start: + 1. create IFSession with story path + 2. spawn dfrotz, read intro text + 3. push "if" onto player.mode_stack + 4. if save file exists, auto-restore + 5. broadcast intro/restored text to spectators + + +server integration (server.py) +------------------------------ + +the shell loop checks player.mode and routes input: + + if player.mode == "if" and player.if_session: + route to if_session.handle_input() + +on done (::quit): + stop session, pop mode stack, send "you leave the terminal." + +on disconnect: + session cleaned up in finally block (auto-saves) + + +spectator broadcasting +====================== + +broadcast_to_spectators() in if_session.py iterates the global players +dict, finds everyone at the same (x,y) who isn't the playing player, +sends them formatted output: + + [Jared's terminal] + > open mailbox + Opening the small mailbox reveals a leaflet. + +triggered on: game start/restore, every game output, and when the +player leaves the terminal. + + +save/restore +============ + +saves stored at: data/if_saves/{player_name}/{game_name}.qzl +player names sanitized (non-alphanumeric chars replaced with _). +quetzal format (.qzl) — z-machine standard. + +save flow: + send "save\n" to dfrotz -> read filename prompt -> send path -> + read confirmation. auto-confirms "overwrite" if file exists. + checks for "Failed" in response. + +restore flow: + on "play zork" with existing save file, sends "restore\n" to dfrotz, + provides save path, reads restored game text. + +double-save prevention: + _saved flag prevents saving twice (::quit triggers save, then stop() + would save again). flag resets on regular game input. + + +file layout +=========== + + src/mudlib/if_session.py IFSession class + broadcast_to_spectators + src/mudlib/commands/play.py play command + content/stories/zork1.z3 bundled story file + data/if_saves/ per-player save files (gitignored) + + +what's not here (yet) +===================== + +- terminal room objects (currently just the "play" command) +- embedded interpreter (levels 2-5) +- multiplayer z-machine +- ghost presence in IF rooms + +see if-journey.rst for the bigger picture. diff --git a/docs/lessons/dfrotz-prompt-detection.txt b/docs/lessons/dfrotz-prompt-detection.txt new file mode 100644 index 0000000..0d1c75a --- /dev/null +++ b/docs/lessons/dfrotz-prompt-detection.txt @@ -0,0 +1,85 @@ +dfrotz prompt detection — reading output from a z-machine subprocess +==================================================================== + +the problem +=========== + +dfrotz writes game text to stdout, then displays a ">" prompt and waits +for input. there's no clean "end of response" delimiter — just the prompt +character appearing at the end of the output. + +this sounds simple. it wasn't. + + +what made it hard +================= + +1. the prompt format varies: + - "> " (with trailing space) — most common + - ">\n" or ">\r\n" — sometimes + - bare ">" with no preceding newline — edge case but real + +2. the ">" character can appear IN game text: + - a bare ">" at end of text isn't always the prompt + - only strip it when the raw bytes show trailing whitespace after it + (confirming it's the prompt pattern, not game content) + +3. chunked I/O: + - data arrives in arbitrary chunks (not line-by-line) + - the prompt might be split across chunks + - can't just check the last character of each read + +4. timing: + - after writing a command, dfrotz takes variable time to respond + - short responses (one line) arrive fast + - long responses (room descriptions) come in multiple chunks + - no way to know in advance how much output to expect + + +what we do +========== + +_read_response() in if_session.py uses a chunked reader with dual checks: + + read up to 1024 bytes per chunk + after each chunk, check the accumulated buffer for prompt patterns + idle timeout of 100ms — once data stops flowing, return what we have + overall deadline of 5 seconds — safety net + +prompt detection checks (in order): + 1. stripped text ends with "\n>" — always a prompt, strip it + 2. stripped text is just ">" — empty response, return "" + 3. raw bytes end with "> " or ">\n" AND stripped text ends with ">" + — prompt confirmed by trailing whitespace, strip it + +the key insight: "\n>" is unambiguously a prompt (game text doesn't end +that way). bare ">" is ambiguous — only treat it as a prompt when the +raw bytes have trailing whitespace confirming the prompt pattern. + + +what we tried that didn't work +============================== + +- reading until timeout only: too slow (100ms wait after every response) + or too fast (miss data that's still coming). need both prompt detection + AND timeout as fallback. + +- reading line by line: dfrotz doesn't always end the prompt with a + newline. sometimes the ">" has a trailing space and nothing else. + readline() hangs waiting for \n that never comes. + +- checking only the last character: ">" appears in game text. false + positives everywhere. + + +the fix pattern +=============== + +dual-mode detection: fast path checks for prompt patterns after each +chunk (instant return when found), slow path falls back to idle timeout +(catches edge cases where prompt detection fails). overall deadline +prevents infinite hangs. + +this pattern works for any subprocess that uses a prompt character to +signal readiness. the specific prompt patterns are dfrotz-specific, but +the chunk-accumulate-check loop is reusable.