Update documents with new IF system

This commit is contained in:
Jared Miller 2026-02-09 17:59:47 -05:00
parent 108091bfae
commit 47ef606e7f
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 245 additions and 5 deletions

View file

@ -56,6 +56,7 @@ Update docs when:
- content loading: TOML definitions for commands and combat moves, loaded at startup - content loading: TOML definitions for commands and combat moves, loaded at startup
- entity model: Entity base class, Player and Mob subclasses sharing common interface - entity model: Entity base class, Player and Mob subclasses sharing common interface
- editor mode: in-world text editor with syntax highlighting and search/replace - 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 ## Style

View file

@ -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. 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 Level 2 — inspectable world
~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -174,12 +174,12 @@ Input routing
Room-local broadcasting 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 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 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. - [ ] 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. - [ ] 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 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/if-integration.txt`` — original research and integration plan (predates audits)
``docs/how/viola-embedding-audit.rst`` — detailed viola architecture audit ``docs/how/viola-embedding-audit.rst`` — detailed viola architecture audit

152
docs/how/if-terminal.txt Normal file
View file

@ -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 <story_path>
-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.

View file

@ -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.