Compare commits

..

29 commits

Author SHA1 Message Date
05c9a48bb3
Strip dfrotz prompt even with trailing whitespace 2026-02-09 17:28:28 -05:00
b90e61c4fc
Add tests for save overwrite auto-confirm and failure detection 2026-02-09 17:11:59 -05:00
8dc2d4b934
Auto-confirm overwrite and clean up save feedback 2026-02-09 17:10:30 -05:00
de58209fd0
Add test for chunked IF response reading 2026-02-09 17:07:43 -05:00
da176a1363
Use get_running_loop() instead of deprecated get_event_loop() 2026-02-09 17:07:27 -05:00
e8f16ca18a
Speed up IF reads with chunked I/O and short idle timeout 2026-02-09 17:05:27 -05:00
43fce6a4ed
Fix line length issues in IF session tests 2026-02-09 16:45:36 -05:00
76488139c8
Add error handling for process communication failures in IF save/restore 2026-02-09 16:45:12 -05:00
bb1f9bbbd8
Prevent double-save when quitting IF sessions 2026-02-09 16:44:13 -05:00
8893525647
Sanitize player names in IF save paths to prevent path traversal 2026-02-09 16:43:09 -05:00
57afe9a3ce
Wire restore into play command
When starting an IF game, check for existing save file and restore
if present. Shows 'restoring saved game...' message and broadcasts
restored game state to spectators.

Also cleaned up redundant tests that didn't properly mock the
auto-save functionality now present in ::quit and stop().
2026-02-09 16:39:15 -05:00
6879da0964
Wire ::save escape command 2026-02-09 16:32:50 -05:00
5f0fec14ef
Add restore functionality to IF sessions 2026-02-09 16:32:23 -05:00
e2bd260538
Add save functionality to IF sessions 2026-02-09 16:31:47 -05:00
6ea82a8496
Add save directory helpers for IF sessions 2026-02-09 16:31:12 -05:00
057a746687
Wire up spectator broadcasting in server and play command
- server.py: broadcast IF output to spectators after each input (skip :: escape commands)
- server.py: broadcast leave message when player exits IF mode
- play.py: broadcast game intro text when player starts a game

Spectators at the same x,y coordinates now see formatted output with
[PlayerName's terminal] header and game text.
2026-02-09 16:25:39 -05:00
3dd095b9ea
Add broadcast_to_spectators helper
Helper function sends messages to all players at the same x,y coordinates
as the source player, skipping the source player themselves. Used for IF
spectator broadcasting.
2026-02-09 16:24:48 -05:00
c3f8c8cf12
Add spectator broadcasting tests for IF mode
Tests verify:
- Spectators at same location see IF output with player name header
- Spectators at different locations see nothing
- Game start intro broadcasts to spectators
- broadcast_to_spectators skips the playing player
- Multiple spectators all receive messages

Tests currently fail as broadcast_to_spectators not yet implemented.
2026-02-09 16:24:07 -05:00
2ac2335b18
Ignore z games 2026-02-09 16:23:02 -05:00
c246732b86
Add mention of ::help 2026-02-09 16:14:34 -05:00
bc69f46d1a
List stories when cannot find 2026-02-09 16:14:21 -05:00
6308248d14
Fix IF session cleanup on failure and player disconnect 2026-02-09 16:11:28 -05:00
8a8e3dd6e8
Fix line-too-long lint errors in IF mode tests 2026-02-09 16:10:29 -05:00
b133f2febe
Add play command for starting interactive fiction games 2026-02-09 16:10:29 -05:00
dc342224b1
Wire IF mode into server shell loop and player model 2026-02-09 15:57:24 -05:00
d210033f33
Add IFSession class for interactive fiction subprocess management
TDD implementation of IFSession that manages a dfrotz subprocess.
IFResponse dataclass follows the editor pattern with output/done fields.
IFSession handles spawning dfrotz, routing input, and detecting the prompt.
Escape commands (::quit, ::help) are handled without sending to dfrotz.
2026-02-09 15:54:47 -05:00
bc5e829f6b
Add a terminal plan 2026-02-09 15:48:04 -05:00
4b52051bed
Add a doc on utf8 design 2026-02-09 15:47:57 -05:00
6fd19d769b
Add a docker container solution 2026-02-09 12:34:56 -05:00
14 changed files with 2245 additions and 4 deletions

17
.dockerignore Normal file
View file

@ -0,0 +1,17 @@
__pycache__
*.pyc
.git
.gitignore
.worktrees
.testmondata
repos
build
data
*.egg-info
.ruff_cache
.pytest_cache
docs
tests
TODO.md
DREAMBOOK.md
dbzfe.log

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ build
data
.worktrees
.testmondata
*.z*

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# frotz provides dfrotz (z-machine interpreter for interactive fiction)
RUN apt-get update \
&& apt-get install -y --no-install-recommends frotz \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# install deps first (better layer caching)
COPY pyproject.toml ./
# replace local-path telnetlib3 dep with PyPI version for container build
RUN sed -i 's|telnetlib3 @ file:///home/jtm/src/telnetlib3|telnetlib3>=2.3.0|' pyproject.toml \
&& uv sync --no-dev --no-install-project
# copy project source and content
COPY src/ src/
COPY content/ content/
COPY worlds/ worlds/
RUN uv sync --no-dev
EXPOSE 6789
ENV MUD_HOST=0.0.0.0
CMD ["uv", "run", "python", "-m", "mudlib"]

13
compose.yml Normal file
View file

@ -0,0 +1,13 @@
services:
mud:
build: .
ports:
- "6789:6789"
volumes:
- mud-data:/app/data
- mud-cache:/app/build
restart: unless-stopped
volumes:
mud-data:
mud-cache:

View file

@ -0,0 +1,133 @@
utf-8's ascii bias and what it means for muds
==============================================
the short version
-----------------
utf-8 was designed to be backwards compatible with ascii (a 1963 american
standard). that compatibility is baked into the bit structure of every byte.
english text passes through at 1 byte per character with zero overhead. every
other language pays extra:
ascii (english letters, digits) 1 byte
latin accented chars (e, o, n) 2 bytes
cjk (chinese, japanese, korean) 3 bytes
emoji, historical scripts 4 bytes
compare to native cjk encodings like big5 or gbk where those same characters
are 2 bytes. utf-8 makes cjk text ~50% larger than its native encoding. the
entire first byte's high bit (0xxxxxxx) is reserved for those 128 ascii
characters, which are overwhelmingly english/american.
why this matters for muds
-------------------------
old muds from the 80s/90s used latin-1 (iso 8859-1), which encodes accented
characters (e, a, c, o) as single bytes in the 128-255 range. latin-1 worked
fine when every terminal also spoke latin-1.
utf-8 replaced latin-1 as the default everywhere, but utf-8 is only backwards
compatible with the ascii subset (bytes 0-127). the upper half of latin-1 is
encoded differently in utf-8 (as 2-byte sequences). when a utf-8 terminal
connects to a latin-1 mud, the accented characters come through as mojibake.
nobody goes back to update ancient mud codebases, so the accented characters
just broke. the path of least resistance is to strip out accents and write in
pure ascii. swedish scene folks had a term for it: "dumb swedish" -- writing
swedish without accented characters, like someone who doesn't know the language
properly. same thing happens with portuguese, french, german, any language that
relied on latin-1's upper range.
these muds aren't limited to ascii by design. they used latin-1 just fine for
years. the problem is that utf-8 broke compatibility with those bytes, and since
muds write to raw sockets, there are several layers working against getting
printf("swedish") to come out correctly on the other end.
the history
-----------
utf-8 was designed by ken thompson on a placemat in a new jersey diner one
evening in september 1992. rob pike was there cheering him on. they went back
to bell labs after dinner, and by the following monday they had plan 9 running
(and only running) utf-8. the full system conversion took less than a week.
the key design criterion that distinguished their version from the competing
fss-utf proposal was #6: "it should be possible to find the start of a character
efficiently starting from an arbitrary location in a byte stream." the original
proposal lacked self-synchronization. thompson and pike's version has it -- any
byte that doesn't start with 10xxxxxx is the start of a character.
the bit packing:
0xxxxxxx 1 byte, 7 free bits
110xxxxx 10xxxxxx 2 bytes, 11 free bits
1110xxxx 10xxxxxx 10xxxxxx 3 bytes, 16 free bits
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 4 bytes, 21 free bits
the number of leading 1s in the first byte tells you how many bytes are in the
sequence. simple, self-synchronizing, ascii-compatible. it won because of that
ascii compatibility -- pragmatic adoption beat fairness to non-latin scripts.
the alternative was utf-16 (2 bytes for most characters, fairer to cjk), but
it's not ascii-compatible at all. pragmatism won.
source: rob pike's email from april 2003, correcting the record that ibm
designed utf-8. "UTF-8 was designed, in front of my eyes, on a placemat in a
New Jersey diner one night in September or so 1992."
the encoding design space
-------------------------
utf-8 couldn't have done the fairness thing without giving up the one property
that made it win. the entire first byte design (0xxxxxxx = ascii) is what makes
it backwards compatible. give that up to make room for 2-byte cjk and you
basically reinvent utf-16 but worse.
the three real options:
utf-8: english wins, everyone else pays more. but ascii-compatible, so every
existing unix tool, every C string function, every file path, every
null-terminated string just works. that's why it won.
utf-16: roughly fair across living languages. the entire basic multilingual
plane (latin, cyrillic, arabic, hebrew, greek, cjk -- basically everything
people actually type) is 2 bytes flat. supplementary stuff (emoji, historical
scripts) goes to 4 bytes via surrogate pairs. cjk goes from 3 bytes (utf-8)
down to 2, english goes from 1 byte up to 2. java and windows chose this
internally. but it breaks every C string assumption (null bytes everywhere in
english text), has byte-order issues (big endian? little endian? here's a BOM
to sort it out), and it's STILL variable-length because of surrogate pairs, so
you don't even get O(1) indexing.
utf-32: perfectly fair, perfectly wasteful. everything is 4 bytes. dead simple
to index into. but english text is 4x larger, cjk is 2x larger than their
native encodings. nobody wants that for storage or transmission.
thompson and pike's design criteria were about unix filesystem safety and ascii
compatibility. fairness across scripts wasn't on the list -- criterion #1 was
"don't break /" and criterion #2 was "no ascii bytes hiding inside multibyte
sequences." the encoding is optimized for a world where the existing
infrastructure was ascii, and the goal was to extend it without breaking
anything.
the irony is that utf-16 was supposed to be the "real" unicode encoding (it was
originally fixed-width at 2 bytes when unicode only had 65536 codepoints), and
utf-8 was supposed to be the filesystem-safe hack. but utf-8's unix
compatibility made it take over the web, and utf-16 got stuck as an internal
representation in java and windows.
what we do about it
-------------------
telnetlib3 handles charset negotiation (see charset-vs-mtts.rst). we default to
utf-8 and detect client capabilities via mtts. for clients that don't negotiate,
we should assume utf-8 and accept that legacy latin-1 clients will see garbage
for anything outside ascii. that's the world we live in.
see also
--------
- charset-vs-mtts.rst in this directory
- rob pike's utf-8 history email (2003): search "UTF-8 history rob pike"
- the original fss-utf proposal from ken thompson's archives (sep 2 1992)
- unicode.org utf-8 spec

264
docs/plans/if-terminal.txt Normal file
View file

@ -0,0 +1,264 @@
if-terminal — playing interactive fiction from the mud
======================================================
goal: sit at a terminal in the mud, boot up zork, play it. other players
in the room see text scrolling. single player for now, but designed so
spectating and "follow" interactions aren't foreclosed.
references:
docs/how/if-journey.rst — five levels of integration, interpreter audits
docs/how/if-integration.txt — original research, subprocess vs embed analysis
docs/how/mojozork-audit.rst — multiplayer z-machine proof (reference only)
docs/how/viola-embedding-audit.rst — viola interpreter audit
docs/how/zvm-embedding-audit.rst — zvm interpreter audit
what "playing zork" means
=========================
a player walks into a room. there's something there — a terminal, a cabinet,
a glowing orb, whatever. they type a command to start playing. their input
now routes to dfrotz (z-machine interpreter) instead of the mud command
dispatcher. they're IN zork.
they're still a mud entity in the room. they haven't vanished. other players
see them sitting at the terminal. when they quit zork, they're back to normal
mud mode. their zork save persists.
the spectator question
======================
when player A is playing zork, player B walks into the room and sees:
Jared is here, hunched over a glowing terminal.
> open mailbox
Opening the small mailbox reveals a leaflet.
player B sees the output scrolling. they're spectating. they didn't choose
to — they just see it because they're in the room.
this is level 1 from if-journey.rst. the IF world is opaque (we don't know
what room the player is in, we can't inject items), but the TEXT is visible.
subprocess dfrotz, text piped in/out, broadcast to room.
the follow question
===================
"follow <player>" doesn't exist yet. when it does:
- following player A means you auto-move when they move between rooms
- if A sits at a terminal and enters zork, you DON'T auto-enter zork
- you're in the same room, you see the text (spectator mode)
- if A gets up from the terminal and walks away, you follow them out
this is the right default. entering zork is a deliberate choice, not
something that happens because you're following someone.
future possibility (NOT this plan): two players both playing zork in the
same room. each has their own dfrotz process. each sees their own game.
they see "ghost" indicators of each other — "a faint shimmer suggests
Jared is nearby" — but can't truly interact through zork's parser. this
is cosmetic multiplayer, not real shared-world (level 4). it's achievable
with level 1 architecture: just track which IF room each player is in
(parse the room name from output) and show ghosts when rooms match. a
fun enhancement that doesn't require embedded interpreters.
what we're building
===================
phase 0 — prerequisites
------------------------
- install dfrotz (user action: sudo dnf install frotz or equivalent)
- zork1.z3 is already at content/stories/zork1.z3 (from mojozork, free release)
- verify dfrotz can run the story file standalone
phase 1 — if session handler
------------------------------
the core. equivalent of editor.py but for interactive fiction.
new file: src/mudlib/if_session.py
IFSession class:
process: asyncio.subprocess.Process (dfrotz child)
player: Player (who's playing)
spectators: list[Player] (others in room, updated dynamically)
story_path: str (path to .z3/.z5 file)
game_name: str ("zork1")
save_dir: str (per-player save directory)
async start():
spawn dfrotz as async subprocess (stdin=PIPE, stdout=PIPE, stderr=PIPE)
read initial output (game intro text)
broadcast intro to player + spectators
async handle_input(text: str) -> IFResponse:
if text is a mud escape command (e.g. "::quit", "::save"):
handle at mud layer (quit session, force save, etc)
else:
write text to dfrotz stdin
read response from dfrotz stdout
broadcast response to player + spectators
return IFResponse(output=response, done=False)
on "::quit" or dfrotz process exit:
return IFResponse(output=goodbye_text, done=True)
async stop():
save game state (send "save" to dfrotz, capture file)
terminate dfrotz process
cleanup
dfrotz flags:
-p (plain text, no formatting escapes)
-w N (screen width, match terminal or default 80)
possibly -h N (screen height)
escape prefix "::":
::quit — leave the terminal, return to mud
::save — force save (dfrotz save command)
::help — show escape commands
regular input goes straight to dfrotz
phase 2 — mode stack integration
----------------------------------
wire IFSession into the existing mode stack, following the editor pattern.
server.py shell loop addition:
elif player.mode == "if" and player.if_session:
response = await player.if_session.handle_input(inp)
if response.output:
await player.send(response.output)
if response.done:
player.if_session = None
player.mode_stack.pop()
player.py addition:
if_session: IFSession | None = None
entering IF mode:
player.if_session = IFSession(player, story_path, ...)
await player.if_session.start()
player.mode_stack.append("if")
phase 3 — the play command
----------------------------
how a player starts a game. for now, a simple command. terminal objects
come later.
play zork
this:
1. looks up "zork" in available stories (content/stories/)
2. creates IFSession with the story file
3. pushes "if" mode onto mode stack
4. player sees zork's intro text
later this becomes "use terminal" or an interaction with a room object.
for now, a direct command keeps things simple and testable.
phase 4 — spectator broadcasting
----------------------------------
when a player is in IF mode, other players in the same room see output.
on IFSession output:
send to player (the one playing)
for each other player in the same room:
send formatted version:
[Jared's terminal]
> open mailbox
Opening the small mailbox reveals a leaflet.
spectator list updates when players enter/leave the room.
"look" in the room shows:
Jared is here, playing on a glowing terminal.
this needs the room-local broadcasting pattern mentioned in if-integration.txt.
it's the first use case for ambient output in a room.
phase 5 — save/restore persistence
------------------------------------
player's zork progress persists across sessions.
- saves stored per-player per-game (e.g. saves/jared/zork1.sav)
- on "play zork": check for existing save, offer to restore
- on "::quit": auto-save before exiting
- dfrotz handles save/restore natively via its save command
- we just need to manage the save file locations
could use SQLite blobs (like the persistence doc suggests) or just
filesystem saves. filesystem is simpler for dfrotz since it already
writes save files. start with filesystem.
what we're NOT building (yet)
=============================
- embedded interpreter (levels 2-5). dfrotz subprocess is the whole story.
- multiplayer z-machine. one player per dfrotz process.
- terminal room objects. "play" command is the entry point for now.
- follow command. not in scope but the design accommodates it.
- ghost presence in IF rooms. cool idea, deferred.
- item bridge between IF and mud worlds. level 5, way out.
- non-zork games. architecture supports any z-machine game but we test
with zork1 only.
open questions
==============
1. dfrotz output reading
dfrotz doesn't have a clean "here's one response" delimiter. it writes
to stdout and then waits for input. we need to detect when it's done
writing. options: read with timeout, look for the ">" prompt, or use
expect-style pattern matching. needs experimentation.
2. save file management
dfrotz save files are opaque blobs. where do we store them? how do we
name them? per-player directory under a saves/ dir? or blob them into
sqlite?
3. screen width
dfrotz formats text to a width. do we detect the player's terminal
width (NAWS) and pass it to dfrotz? or use a fixed width? if the
player resizes, can we tell dfrotz?
4. escape prefix
"::" is arbitrary. could conflict with game input (unlikely for IF
but possible). alternatives: ".", "/", or a control character.
5. spectator bandwidth
if 10 people are in the room, every zork output goes to 10 players.
is this a performance concern? probably not at our scale, but worth
noting.
estimated phases and what each delivers
========================================
phase 0: can run zork standalone in a terminal
phase 1-2: can play zork inside the mud ("play zork", input routed, "::quit" to leave)
phase 3: "play zork" is a real command, discoverable
phase 4: others in the room see the game text
phase 5: progress saves between sessions
phases 1-3 are the MVP. a player can sit in the mud, play zork, and quit.
phases 4-5 make it social and persistent.

101
src/mudlib/commands/play.py Normal file
View file

@ -0,0 +1,101 @@
"""Play interactive fiction games."""
import pathlib
from mudlib.commands import CommandDefinition, register
from mudlib.if_session import IFSession, broadcast_to_spectators
from mudlib.player import Player
# Story files directory
_stories_dir = pathlib.Path(__file__).resolve().parents[3] / "content" / "stories"
# Map of game name -> file extension for lookup
_STORY_EXTENSIONS = (".z3", ".z5", ".z8", ".zblorb")
def _find_story(name: str) -> pathlib.Path | None:
"""Find a story file by name in content/stories/."""
# exact match first
for ext in _STORY_EXTENSIONS:
path = _stories_dir / f"{name}{ext}"
if path.exists():
return path
# prefix match (e.g. "zork" matches "zork1.z3")
if _stories_dir.exists():
for path in sorted(_stories_dir.iterdir()):
if path.stem.startswith(name) and path.suffix in _STORY_EXTENSIONS:
return path
return None
def _list_stories() -> list[str]:
"""Return available story names."""
if not _stories_dir.exists():
return []
return sorted(
p.stem for p in _stories_dir.iterdir() if p.suffix in _STORY_EXTENSIONS
)
async def cmd_play(player: Player, args: str) -> None:
"""Start playing an interactive fiction game."""
game_name = args.strip().lower()
if not game_name:
stories = _list_stories()
if stories:
names = ", ".join(stories)
await player.send(f"play what? available: {names}\r\n")
else:
await player.send("play what? (no stories installed)\r\n")
return
story_path = _find_story(game_name)
if not story_path:
stories = _list_stories()
if stories:
names = ", ".join(stories)
msg = f"no story '{game_name}'. available: {names}\r\n"
else:
msg = f"no story found for '{game_name}'.\r\n"
await player.send(msg)
return
# Create and start IF session
session = IFSession(player, str(story_path), game_name)
try:
intro = await session.start()
except FileNotFoundError:
await session.stop()
await player.send("error: dfrotz not found. cannot play IF games.\r\n")
return
except OSError as e:
await session.stop()
await player.send(f"error starting game: {e}\r\n")
return
player.if_session = session
player.mode_stack.append("if")
await player.send("(type ::help for escape commands)\r\n")
# Check for saved game
if session.save_path.exists():
await player.send("restoring saved game...\r\n")
restored_text = await session._do_restore()
if restored_text:
await player.send(restored_text + "\r\n")
# Broadcast restored text to spectators
spectator_msg = f"[{player.name}'s terminal]\r\n{restored_text}\r\n"
await broadcast_to_spectators(player, spectator_msg)
elif intro:
await player.send(intro + "\r\n")
# Broadcast intro to spectators
spectator_msg = f"[{player.name}'s terminal]\r\n{intro}\r\n"
await broadcast_to_spectators(player, spectator_msg)
register(
CommandDefinition(
"play", cmd_play, mode="normal", help="play an interactive fiction game"
)
)

229
src/mudlib/if_session.py Normal file
View file

@ -0,0 +1,229 @@
"""Interactive fiction session management via dfrotz subprocess."""
import asyncio
import re
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from mudlib.player import Player
@dataclass
class IFResponse:
"""Response from IF session input handling."""
output: str
done: bool
class IFSession:
"""Manages an interactive fiction session via dfrotz subprocess."""
def __init__(self, player, story_path: str, game_name: str = ""):
self.player = player
self.story_path = story_path
self.game_name = game_name or Path(story_path).stem
self.process: asyncio.subprocess.Process | None = None
# data/ directory is at project root (2 levels up from src/mudlib/)
self._data_dir = Path(__file__).resolve().parents[2] / "data"
# Track whether we've saved (prevents double-save on quit+stop)
self._saved = False
@property
def save_path(self) -> Path:
"""Return path to save file for this player/game combo."""
# Sanitize player name to prevent path traversal attacks
# Account creation doesn't validate names, so defensive check here
safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", self.player.name)
return self._data_dir / "if_saves" / safe_name / f"{self.game_name}.qzl"
def _ensure_save_dir(self) -> None:
"""Create save directory if it doesn't exist."""
self.save_path.parent.mkdir(parents=True, exist_ok=True)
async def start(self) -> str:
"""Spawn dfrotz and return intro text."""
self.process = await asyncio.create_subprocess_exec(
"dfrotz",
"-p", # plain ASCII, no formatting
"-w",
"80", # screen width
"-m", # no MORE prompts
self.story_path,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Read intro text until we see the prompt
intro = await self._read_response()
return intro
async def handle_input(self, text: str) -> IFResponse:
"""Handle player input. Route to dfrotz or handle escape commands."""
# Check for escape commands (:: prefix)
if text.lower() == "::quit":
# Auto-save before quitting
await self._do_save()
return IFResponse(output="game saved.", done=True)
if text.lower() == "::help":
help_text = """escape commands:
::quit - exit the game
::save - save game progress
::help - show this help"""
return IFResponse(output=help_text, done=False)
if text.lower() == "::save":
confirmation = await self._do_save()
return IFResponse(output=confirmation, done=False)
# Regular game input - send to dfrotz
# Reset saved flag since game state has changed
self._saved = False
if self.process and self.process.stdin:
stripped = text.strip()
self.process.stdin.write(f"{stripped}\n".encode())
await self.process.stdin.drain()
# Read response
output = await self._read_response()
return IFResponse(output=output, done=False)
# Process not running
return IFResponse(output="error: game not running", done=True)
async def stop(self):
"""Terminate dfrotz process."""
if self.process and self.process.returncode is None:
# Auto-save before terminating
await self._do_save()
self.process.terminate()
await self.process.wait()
async def _do_save(self) -> str:
"""Save game state to disk. Returns confirmation message."""
# Skip if already saved (prevents double-save on quit+stop)
if self._saved:
return "already saved"
if not self.process or not self.process.stdin:
return "error: game not running"
try:
# Ensure save directory exists
self._ensure_save_dir()
# Send "save" command to dfrotz
self.process.stdin.write(b"save\n")
await self.process.stdin.drain()
# Read filename prompt
await self._read_response()
# Send save file path
save_path_str = str(self.save_path)
self.process.stdin.write(f"{save_path_str}\n".encode())
await self.process.stdin.drain()
# Read response - might be "Ok." or "Overwrite existing file?"
response = await self._read_response()
# Auto-confirm overwrite if file already exists
if "overwrite" in response.lower():
self.process.stdin.write(b"yes\n")
await self.process.stdin.drain()
response = await self._read_response()
# Check for failure
if "failed" in response.lower():
return "error: save failed"
# Mark as saved
self._saved = True
return "saved."
except Exception as e:
return f"error: save failed ({e})"
async def _do_restore(self) -> str:
"""Restore game state from disk. Returns game text or empty string."""
# Check if save file exists
if not self.save_path.exists():
return ""
if not self.process or not self.process.stdin:
return ""
try:
# Send "restore" command to dfrotz
self.process.stdin.write(b"restore\n")
await self.process.stdin.drain()
# Read filename prompt
await self._read_response()
# Send save file path
save_path_str = str(self.save_path)
self.process.stdin.write(f"{save_path_str}\n".encode())
await self.process.stdin.drain()
# Read game text response
game_text = await self._read_response()
return game_text
except Exception:
# If restore fails, return empty string (player starts fresh)
return ""
async def _read_response(self) -> str:
"""Read dfrotz output until the '>' prompt appears."""
if not self.process or not self.process.stdout:
return ""
buf = b""
idle_timeout = 0.1 # return quickly once data stops flowing
loop = asyncio.get_running_loop()
overall_deadline = loop.time() + 5.0
try:
while True:
remaining = overall_deadline - loop.time()
if remaining <= 0:
break
timeout = min(idle_timeout, remaining)
try:
chunk = await asyncio.wait_for(
self.process.stdout.read(1024), timeout=timeout
)
except TimeoutError:
# No data for idle_timeout - dfrotz is done talking
break
if not chunk:
break
buf += chunk
# Check for prompt at end of buffer
# dfrotz prompt is "\n> " (with trailing space)
text = buf.decode("latin-1").rstrip()
if text.endswith("\n>"):
return text[:-2].rstrip()
if text == ">":
return ""
except TimeoutError:
pass
text = buf.decode("latin-1").rstrip()
if text.endswith("\n>"):
return text[:-2].rstrip()
if text == ">":
return ""
return text
async def broadcast_to_spectators(player: "Player", message: str) -> None:
"""Send message to all other players at the same location."""
from mudlib.player import players
for other in players.values():
if other.name != player.name and other.x == player.x and other.y == player.y:
await other.send(message)

View file

@ -10,6 +10,7 @@ from mudlib.entity import Entity
if TYPE_CHECKING:
from mudlib.editor import Editor
from mudlib.if_session import IFSession
@dataclass
@ -22,6 +23,7 @@ class Player(Entity):
mode_stack: list[str] = field(default_factory=lambda: ["normal"])
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
editor: Editor | None = None
if_session: IFSession | None = None
@property
def mode(self) -> str:

View file

@ -2,6 +2,7 @@
import asyncio
import logging
import os
import pathlib
import time
import tomllib
@ -17,6 +18,7 @@ import mudlib.commands.fly
import mudlib.commands.help
import mudlib.commands.look
import mudlib.commands.movement
import mudlib.commands.play
import mudlib.commands.quit
import mudlib.commands.reload
import mudlib.commands.spawn
@ -25,6 +27,7 @@ from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat
from mudlib.content import load_commands
from mudlib.effects import clear_expired
from mudlib.if_session import broadcast_to_spectators
from mudlib.mob_ai import process_mobs
from mudlib.mobs import load_mob_templates, mob_templates
from mudlib.player import Player, players
@ -43,7 +46,8 @@ from mudlib.world.terrain import World
log = logging.getLogger(__name__)
PORT = 6789
HOST = os.environ.get("MUD_HOST", "127.0.0.1")
PORT = int(os.environ.get("MUD_PORT", "6789"))
TICK_RATE = 10 # ticks per second
TICK_INTERVAL = 1.0 / TICK_RATE
AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves
@ -310,6 +314,8 @@ async def shell(
# Show appropriate prompt based on mode
if player.mode == "editor" and player.editor:
_writer.write(f" {player.editor.cursor + 1}> ")
elif player.mode == "if" and player.if_session:
_writer.write("> ")
else:
_writer.write("mud> ")
await _writer.drain()
@ -319,7 +325,7 @@ async def shell(
break
command = inp.strip()
if not command and player.mode != "editor":
if not command and player.mode not in ("editor", "if"):
continue
# Handle editor mode
@ -330,6 +336,27 @@ async def shell(
if response.done:
player.editor = None
player.mode_stack.pop()
# Handle IF mode
elif player.mode == "if" and player.if_session:
response = await player.if_session.handle_input(command)
if response.output:
await player.send(response.output)
# Broadcast to spectators unless it's an escape command
if not command.startswith("::"):
spectator_msg = (
f"[{player.name}'s terminal]\r\n"
f"> {command}\r\n"
f"{response.output}"
)
await broadcast_to_spectators(player, spectator_msg)
if response.done:
await player.if_session.stop()
player.if_session = None
player.mode_stack.pop()
await player.send("you leave the terminal.\r\n")
# Notify spectators
leave_msg = f"{player.name} steps away from the terminal.\r\n"
await broadcast_to_spectators(player, leave_msg)
else:
# Dispatch normal command
await mudlib.commands.dispatch(player, command)
@ -338,6 +365,9 @@ async def shell(
if _writer.is_closing():
break
finally:
# Clean up IF session if player was playing
if player.if_session:
await player.if_session.stop()
# Save player state on disconnect (if not already saved by quit command)
if player_name in players:
save_player(player)
@ -414,9 +444,9 @@ async def run_server() -> None:
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
# telnetlib3 still waits for the full timeout. 0.5s is plenty.
server = await telnetlib3.create_server(
host="127.0.0.1", port=PORT, shell=shell, connect_maxwait=0.5
host=HOST, port=PORT, shell=shell, connect_maxwait=0.5
)
log.info("listening on 127.0.0.1:%d", PORT)
log.info("listening on %s:%d", HOST, PORT)
loop_task = asyncio.create_task(game_loop())

121
tests/test_if_mode.py Normal file
View file

@ -0,0 +1,121 @@
"""Tests for IF mode integration with player model and server shell."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.if_session import IFResponse, IFSession
from mudlib.player import Player
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer):
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
def test_player_has_if_session_attribute(player):
"""Player has if_session attribute that defaults to None."""
assert hasattr(player, "if_session")
assert player.if_session is None
def test_player_if_session_can_be_set(player):
"""Player.if_session can be set to an IFSession instance."""
session = IFSession(player, "/path/to/story.z5", "story")
player.if_session = session
assert player.if_session == session
@pytest.mark.asyncio
async def test_if_mode_routes_input_to_if_session(player):
"""Input routes to if_session.handle_input in IF mode."""
# Create a mock IF session
mock_session = MagicMock()
mock_session.handle_input = AsyncMock(
return_value=IFResponse(output="You see a room.\r\n", done=False)
)
player.if_session = mock_session
player.mode_stack.append("if")
# Simulate input routing (what shell loop should do)
if player.mode == "if" and player.if_session:
response = await player.if_session.handle_input("look")
assert response.output == "You see a room.\r\n"
assert response.done is False
mock_session.handle_input.assert_called_once_with("look")
@pytest.mark.asyncio
async def test_if_mode_done_clears_session_and_pops_mode(player):
"""When handle_input returns done=True, mode pops and if_session is cleared."""
# Create a mock IF session that returns done=True
mock_session = MagicMock()
mock_session.handle_input = AsyncMock(
return_value=IFResponse(output="Goodbye.\r\n", done=True)
)
mock_session.stop = AsyncMock()
player.if_session = mock_session
player.mode_stack.append("if")
# Simulate shell loop handling done response
if player.mode == "if" and player.if_session:
response = await player.if_session.handle_input("::quit")
if response.done:
await player.if_session.stop()
player.if_session = None
player.mode_stack.pop()
assert player.mode == "normal"
assert player.if_session is None
mock_session.stop.assert_called_once()
@pytest.mark.asyncio
async def test_mode_stack_push_and_pop_for_if(player):
"""Test mode stack mechanics for IF mode."""
assert player.mode_stack == ["normal"]
assert player.mode == "normal"
# Enter IF mode
player.mode_stack.append("if")
assert player.mode == "if"
assert player.mode_stack == ["normal", "if"]
# Exit IF mode
player.mode_stack.pop()
assert player.mode == "normal"
assert player.mode_stack == ["normal"]
@pytest.mark.asyncio
async def test_empty_input_allowed_in_if_mode(player):
"""Empty input is allowed in IF mode."""
# Create a mock IF session
mock_session = MagicMock()
mock_session.handle_input = AsyncMock(
return_value=IFResponse(output="Time passes.\r\n", done=False)
)
player.if_session = mock_session
player.mode_stack.append("if")
# Empty input should still be passed to IF session
response = await player.if_session.handle_input("")
assert response.output == "Time passes.\r\n"
mock_session.handle_input.assert_called_once_with("")

903
tests/test_if_session.py Normal file
View file

@ -0,0 +1,903 @@
"""Tests for IF session management."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mudlib.if_session import IFResponse, IFSession
@pytest.mark.asyncio
async def test_if_response_dataclass():
"""IFResponse dataclass can be created."""
response = IFResponse(output="test output", done=False)
assert response.output == "test output"
assert response.done is False
@pytest.mark.asyncio
async def test_if_response_done():
"""IFResponse can signal completion."""
response = IFResponse(output="", done=True)
assert response.done is True
@pytest.mark.asyncio
async def test_if_session_init():
"""IFSession can be initialized."""
player = MagicMock()
session = IFSession(player, "/path/to/story.z5", "story")
assert session.player == player
assert session.story_path == "/path/to/story.z5"
assert session.game_name == "story"
assert session.process is None
@pytest.mark.asyncio
async def test_if_session_init_infers_game_name():
"""IFSession infers game_name from story_path if not provided."""
player = MagicMock()
session = IFSession(player, "/path/to/zork.z5")
assert session.game_name == "zork"
@pytest.mark.asyncio
async def test_start_spawns_subprocess_and_returns_intro():
"""start() spawns dfrotz subprocess and returns intro text."""
player = MagicMock()
session = IFSession(player, "/path/to/story.z5")
# Mock the subprocess
mock_process = AsyncMock()
mock_process.stdout = AsyncMock()
mock_process.stdin = AsyncMock()
# Simulate dfrotz output: intro text followed by ">" prompt
intro_bytes = b"Welcome to the story!\nYou are in a room.\n>"
async def read_side_effect(n):
nonlocal intro_bytes
if intro_bytes:
byte = intro_bytes[:1]
intro_bytes = intro_bytes[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
intro = await session.start()
assert session.process == mock_process
assert "Welcome to the story!" in intro
assert "You are in a room." in intro
# The prompt should be stripped from the output
assert intro.strip().endswith("room.")
@pytest.mark.asyncio
async def test_handle_input_sends_to_dfrotz():
"""handle_input() sends regular input to dfrotz and returns response."""
player = MagicMock()
session = IFSession(player, "/path/to/story.z5")
# Mock process
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate dfrotz response
response_bytes = b"You move north.\n>"
async def read_side_effect(n):
nonlocal response_bytes
if response_bytes:
byte = response_bytes[:1]
response_bytes = response_bytes[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
response = await session.handle_input("north")
assert response.output == "You move north."
assert response.done is False
# Verify stdin.write was called
mock_process.stdin.write.assert_called()
mock_process.stdin.drain.assert_called()
@pytest.mark.asyncio
async def test_handle_input_help_returns_help_text():
"""handle_input('::help') returns help text listing escape commands."""
player = MagicMock()
session = IFSession(player, "/path/to/story.z5")
session.process = AsyncMock()
response = await session.handle_input("::help")
assert response.done is False
assert "::quit" in response.output
assert "::help" in response.output
@pytest.mark.asyncio
async def test_stop_when_no_process():
"""stop() does nothing if process is None."""
player = MagicMock()
session = IFSession(player, "/path/to/story.z5")
session.process = None
# Should not raise
await session.stop()
@pytest.mark.asyncio
async def test_stop_when_already_terminated():
"""stop() handles already-terminated process gracefully."""
player = MagicMock()
session = IFSession(player, "/path/to/story.z5")
# Mock process that's already done
mock_process = AsyncMock()
mock_process.returncode = 0
session.process = mock_process
await session.stop()
# Should not call terminate on already-finished process
mock_process.terminate.assert_not_called()
@pytest.mark.asyncio
async def test_read_response_detects_prompt():
"""_read_response() reads until '>' prompt appears."""
player = MagicMock()
session = IFSession(player, "/path/to/story.z5")
# Mock process
mock_process = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate multi-line output with prompt
output_bytes = b"Line 1\nLine 2\nLine 3\n>"
async def read_side_effect(n):
nonlocal output_bytes
if output_bytes:
byte = output_bytes[:1]
output_bytes = output_bytes[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
result = await session._read_response()
assert "Line 1" in result
assert "Line 2" in result
assert "Line 3" in result
# Prompt should be stripped
assert not result.strip().endswith(">")
@pytest.mark.asyncio
async def test_handle_input_strips_whitespace():
"""handle_input() strips input before sending to dfrotz."""
player = MagicMock()
session = IFSession(player, "/path/to/story.z5")
# Mock process
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate response
response_bytes = b"ok\n>"
async def read_side_effect(n):
nonlocal response_bytes
if response_bytes:
byte = response_bytes[:1]
response_bytes = response_bytes[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
await session.handle_input(" look ")
# Check that write was called with stripped input + newline
calls = mock_process.stdin.write.call_args_list
assert len(calls) == 1
written = calls[0][0][0]
assert written == b"look\n"
@pytest.mark.asyncio
async def test_handle_input_empty_string():
"""handle_input() with empty string sends newline to dfrotz."""
player = MagicMock()
session = IFSession(player, "/path/to/story.z5")
# Mock process
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate response
response_bytes = b"ok\n>"
async def read_side_effect(n):
nonlocal response_bytes
if response_bytes:
byte = response_bytes[:1]
response_bytes = response_bytes[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
await session.handle_input("")
# Should still write a newline
mock_process.stdin.write.assert_called()
def test_save_path_property(tmp_path):
"""save_path returns correct path for player/game combo."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
# Override data_dir for testing
session._data_dir = tmp_path
save_path = session.save_path
assert save_path == tmp_path / "if_saves" / "tester" / "zork.qzl"
def test_save_path_sanitizes_malicious_names(tmp_path):
"""save_path sanitizes player names to prevent path traversal."""
player = MagicMock()
player.name = "../../etc/passwd"
session = IFSession(player, "/path/to/zork.z5", "zork")
# Override data_dir for testing
session._data_dir = tmp_path
save_path = session.save_path
# Should sanitize to replace non-alphanumeric chars with underscores
# "../../etc/passwd" becomes "______etc_passwd"
assert ".." not in str(save_path)
assert save_path == tmp_path / "if_saves" / "______etc_passwd" / "zork.qzl"
# Verify it's still within the if_saves directory
assert tmp_path / "if_saves" in save_path.parents
def test_ensure_save_dir_creates_directories(tmp_path):
"""_ensure_save_dir() creates parent directories."""
player = MagicMock()
player.name = "alice"
session = IFSession(player, "/path/to/story.z5", "story")
# Override data_dir for testing
session._data_dir = tmp_path
# Directory shouldn't exist yet
expected_dir = tmp_path / "if_saves" / "alice"
assert not expected_dir.exists()
# Call _ensure_save_dir
session._ensure_save_dir()
# Now it should exist
assert expected_dir.exists()
assert expected_dir.is_dir()
@pytest.mark.asyncio
async def test_do_save_sends_save_command(tmp_path):
"""_do_save() sends save command and filepath to dfrotz."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Mock process
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate dfrotz responses: first the filename prompt, then confirmation
responses = [
b"Enter saved game to store: \n>",
b"Ok.\n>",
]
response_data = b"".join(responses)
async def read_side_effect(n):
nonlocal response_data
if response_data:
byte = response_data[:1]
response_data = response_data[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
result = await session._do_save()
# Should have written "save\n" then the filepath
calls = mock_process.stdin.write.call_args_list
assert len(calls) == 2
assert calls[0][0][0] == b"save\n"
assert str(session.save_path) in calls[1][0][0].decode()
assert b"\n" in calls[1][0][0]
# Result should contain confirmation
assert "saved" in result.lower()
@pytest.mark.asyncio
async def test_do_save_creates_save_directory(tmp_path):
"""_do_save() ensures save directory exists."""
player = MagicMock()
player.name = "alice"
session = IFSession(player, "/path/to/story.z5", "story")
session._data_dir = tmp_path
# Mock process
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate dfrotz responses
responses = [b"Enter saved game: \n>", b"Ok.\n>"]
response_data = b"".join(responses)
async def read_side_effect(n):
nonlocal response_data
if response_data:
byte = response_data[:1]
response_data = response_data[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
# Directory shouldn't exist yet
expected_dir = tmp_path / "if_saves" / "alice"
assert not expected_dir.exists()
await session._do_save()
# Now it should exist
assert expected_dir.exists()
@pytest.mark.asyncio
async def test_do_restore_returns_empty_if_no_save(tmp_path):
"""_do_restore() returns empty string if no save file exists."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Mock process (even though we won't use it)
session.process = MagicMock()
# No save file exists
assert not session.save_path.exists()
result = await session._do_restore()
assert result == ""
@pytest.mark.asyncio
async def test_do_restore_sends_restore_command(tmp_path):
"""_do_restore() sends restore command and filepath to dfrotz."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Create a fake save file
session._ensure_save_dir()
session.save_path.write_text("fake save data")
# Mock process
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate dfrotz responses: filename prompt, then game text
responses = [
b"Enter saved game to load: \n>",
b"West of House\nYou are standing in an open field.\n>",
]
response_data = b"".join(responses)
async def read_side_effect(n):
nonlocal response_data
if response_data:
byte = response_data[:1]
response_data = response_data[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
result = await session._do_restore()
# Should have written "restore\n" then the filepath
calls = mock_process.stdin.write.call_args_list
assert len(calls) == 2
assert calls[0][0][0] == b"restore\n"
assert str(session.save_path) in calls[1][0][0].decode()
# Result should contain game text
assert "West of House" in result
assert "open field" in result
@pytest.mark.asyncio
async def test_handle_input_save_triggers_save(tmp_path):
"""handle_input('::save') triggers save and returns confirmation."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Mock process
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate dfrotz save responses
responses = [b"Enter saved game: \n>", b"Ok.\n>"]
response_data = b"".join(responses)
async def read_side_effect(n):
nonlocal response_data
if response_data:
byte = response_data[:1]
response_data = response_data[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
response = await session.handle_input("::save")
assert response.done is False
assert "saved" in response.output.lower()
# Verify save command was sent
assert mock_process.stdin.write.call_args_list[0][0][0] == b"save\n"
@pytest.mark.asyncio
async def test_handle_input_quit_saves_before_exit(tmp_path):
"""handle_input('::quit') saves game before returning done=True."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Mock process
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate dfrotz save responses
responses = [b"Enter saved game: \n>", b"Ok.\n>"]
response_data = b"".join(responses)
async def read_side_effect(n):
nonlocal response_data
if response_data:
byte = response_data[:1]
response_data = response_data[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
response = await session.handle_input("::quit")
assert response.done is True
assert "saved" in response.output.lower()
# Verify save command was sent
assert mock_process.stdin.write.call_args_list[0][0][0] == b"save\n"
@pytest.mark.asyncio
async def test_stop_saves_before_terminating(tmp_path):
"""stop() saves game before terminating process."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Mock process
mock_process = MagicMock()
mock_process.returncode = None
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
mock_process.terminate = MagicMock()
mock_process.wait = AsyncMock()
session.process = mock_process
# Simulate dfrotz save responses
responses = [b"Enter saved game: \n>", b"Ok.\n>"]
response_data = b"".join(responses)
async def read_side_effect(n):
nonlocal response_data
if response_data:
byte = response_data[:1]
response_data = response_data[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
await session.stop()
# Verify save was called before terminate
assert mock_process.stdin.write.call_args_list[0][0][0] == b"save\n"
mock_process.terminate.assert_called_once()
mock_process.wait.assert_called_once()
@pytest.mark.asyncio
async def test_quit_then_stop_does_not_double_save(tmp_path):
"""stop() after ::quit doesn't save again (prevents double-save)."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Mock process
mock_process = MagicMock()
mock_process.returncode = None
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
mock_process.terminate = MagicMock()
mock_process.wait = AsyncMock()
session.process = mock_process
# Simulate dfrotz save responses (only expect one save)
responses = [b"Enter saved game: \n>", b"Ok.\n>"]
response_data = b"".join(responses)
async def read_side_effect(n):
nonlocal response_data
if response_data:
byte = response_data[:1]
response_data = response_data[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
# First: handle ::quit (should save)
await session.handle_input("::quit")
# Verify save was called once
save_calls = [
call
for call in mock_process.stdin.write.call_args_list
if call[0][0] == b"save\n"
]
assert len(save_calls) == 1
# Second: call stop() (should NOT save again)
await session.stop()
# Verify save was still only called once
save_calls = [
call
for call in mock_process.stdin.write.call_args_list
if call[0][0] == b"save\n"
]
assert len(save_calls) == 1, "stop() should not save again after ::quit"
mock_process.terminate.assert_called_once()
@pytest.mark.asyncio
async def test_regular_input_resets_save_flag(tmp_path):
"""Regular input resets the saved flag so subsequent saves work."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Mock process
mock_process = MagicMock()
mock_process.returncode = None
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate dfrotz responses
responses = [
b"Enter saved game: \n>",
b"Ok.\n>", # first save
b"You move north.\n>", # regular input
b"Enter saved game: \n>",
b"Ok.\n>", # second save
]
response_data = b"".join(responses)
async def read_side_effect(n):
nonlocal response_data
if response_data:
byte = response_data[:1]
response_data = response_data[1:]
return byte
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
# First: save
await session.handle_input("::save")
# Second: regular input (resets saved flag)
await session.handle_input("north")
# Third: save again (should work)
await session.handle_input("::save")
# Verify save was called twice
save_calls = [
call
for call in mock_process.stdin.write.call_args_list
if call[0][0] == b"save\n"
]
assert len(save_calls) == 2, "should be able to save again after regular input"
@pytest.mark.asyncio
async def test_do_save_handles_process_communication_failure(tmp_path):
"""_do_save() returns error message when process communication fails."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Mock process that raises an exception
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock(side_effect=BrokenPipeError("process died"))
session.process = mock_process
result = await session._do_save()
assert "error" in result.lower()
assert "save" in result.lower() or "failed" in result.lower()
@pytest.mark.asyncio
async def test_do_save_handles_stdout_read_failure(tmp_path):
"""_do_save() returns error message when reading response fails."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Mock process where writing succeeds but reading fails
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
mock_process.stdout.read = AsyncMock(side_effect=OSError("read failed"))
session.process = mock_process
result = await session._do_save()
assert "error" in result.lower()
@pytest.mark.asyncio
async def test_do_restore_handles_process_communication_failure(tmp_path):
"""_do_restore() returns empty string when process communication fails."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Create a save file so we get past the existence check
session._ensure_save_dir()
session.save_path.write_text("fake save data")
# Mock process that raises an exception
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock(side_effect=BrokenPipeError("process died"))
session.process = mock_process
result = await session._do_restore()
# Should return empty string on failure (player starts fresh)
assert result == ""
@pytest.mark.asyncio
async def test_do_restore_handles_stdout_read_failure(tmp_path):
"""_do_restore() returns empty string when reading response fails."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Create a save file
session._ensure_save_dir()
session.save_path.write_text("fake save data")
# Mock process where writing succeeds but reading fails
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
mock_process.stdout.read = AsyncMock(side_effect=OSError("read failed"))
session.process = mock_process
result = await session._do_restore()
# Should return empty string on failure (player starts fresh)
assert result == ""
@pytest.mark.asyncio
async def test_read_response_handles_chunked_data():
"""_read_response() correctly handles data arriving in chunks."""
player = MagicMock()
session = IFSession(player, "/path/to/story.z5")
mock_process = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Data arrives in multi-byte chunks, last chunk includes prompt
chunks = [
b"Welcome to Zork.\n",
b"West of House\nYou are standing ",
b"in an open field.\n>",
]
chunk_iter = iter(chunks)
async def read_chunked(n):
try:
return next(chunk_iter)
except StopIteration:
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_chunked)
result = await session._read_response()
assert "Welcome to Zork" in result
assert "open field" in result
assert not result.strip().endswith(">")
@pytest.mark.asyncio
async def test_do_save_auto_confirms_overwrite(tmp_path):
"""_do_save() auto-confirms with 'yes' when overwrite prompt appears."""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Mock process
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate dfrotz responses: prompt, overwrite question, confirmation
responses = [
b"Enter saved game to store: \n>",
b"Overwrite existing file?\n>",
b"Ok.\n>",
]
response_iter = iter(responses)
async def read_side_effect(n):
try:
return next(response_iter)
except StopIteration:
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
result = await session._do_save()
# Should have written "save\n", filepath, "yes\n"
calls = mock_process.stdin.write.call_args_list
assert len(calls) == 3
assert calls[0][0][0] == b"save\n"
assert str(session.save_path) in calls[1][0][0].decode()
assert calls[2][0][0] == b"yes\n"
# Result should contain "saved"
assert "saved" in result.lower()
assert session._saved is True
@pytest.mark.asyncio
async def test_do_save_detects_failure(tmp_path):
"""_do_save() returns error when dfrotz responds with 'Failed.'"""
player = MagicMock()
player.name = "tester"
session = IFSession(player, "/path/to/zork.z5", "zork")
session._data_dir = tmp_path
# Mock process
mock_process = MagicMock()
mock_process.stdin = MagicMock()
mock_process.stdin.write = MagicMock()
mock_process.stdin.drain = AsyncMock()
mock_process.stdout = AsyncMock()
session.process = mock_process
# Simulate dfrotz responses: prompt, then failure
responses = [
b"Enter saved game to store: \n>",
b"Failed.\n>",
]
response_iter = iter(responses)
async def read_side_effect(n):
try:
return next(response_iter)
except StopIteration:
return b""
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
result = await session._do_save()
# Result should contain error and failed
assert "error" in result.lower()
assert "failed" in result.lower()
# Should NOT be marked as saved
assert session._saved is False

195
tests/test_if_spectator.py Normal file
View file

@ -0,0 +1,195 @@
"""Tests for spectator broadcasting in IF mode."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.if_session import IFResponse
from mudlib.player import Player, players
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture(autouse=True)
def clear_players():
"""Clear players registry before and after each test."""
players.clear()
yield
players.clear()
@pytest.fixture
def player_a():
"""Player A at (5, 5) who will be playing IF."""
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
return Player(name="PlayerA", x=5, y=5, reader=reader, writer=writer)
@pytest.fixture
def player_b():
"""Player B at (5, 5) who will be spectating."""
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
return Player(name="PlayerB", x=5, y=5, reader=reader, writer=writer)
@pytest.fixture
def player_c():
"""Player C at different coords (10, 10)."""
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
return Player(name="PlayerC", x=10, y=10, reader=reader, writer=writer)
@pytest.mark.asyncio
async def test_spectator_sees_if_output(player_a, player_b):
"""Spectator at same location sees IF output with header and input."""
# Register both players
players[player_a.name] = player_a
players[player_b.name] = player_b
# Create mock IF session for player A
mock_session = MagicMock()
mock_session.handle_input = AsyncMock(
return_value=IFResponse(
output="Opening the small mailbox reveals a leaflet.", done=False
)
)
player_a.if_session = mock_session
player_a.mode_stack.append("if")
# Import the broadcast function (will be created in implementation)
from mudlib.if_session import broadcast_to_spectators
# Player A sends input
command = "open mailbox"
response = await player_a.if_session.handle_input(command)
await player_a.send(response.output)
# Broadcast to spectators
spectator_msg = f"[{player_a.name}'s terminal]\r\n> {command}\r\n{response.output}"
await broadcast_to_spectators(player_a, spectator_msg)
# Player B should have received the message
assert player_b.writer.write.called
calls = player_b.writer.write.call_args_list
sent_text = "".join(call[0][0] for call in calls)
assert "[PlayerA's terminal]" in sent_text
assert "> open mailbox" in sent_text
assert "Opening the small mailbox reveals a leaflet." in sent_text
@pytest.mark.asyncio
async def test_spectator_not_on_same_tile_sees_nothing(player_a, player_c):
"""Spectator at different location does not see IF output."""
# Register both players (player_c is at different coords)
players[player_a.name] = player_a
players[player_c.name] = player_c
# Create mock IF session for player A
mock_session = MagicMock()
mock_session.handle_input = AsyncMock(
return_value=IFResponse(output="You open the door.", done=False)
)
player_a.if_session = mock_session
player_a.mode_stack.append("if")
from mudlib.if_session import broadcast_to_spectators
# Player A sends input
command = "open door"
response = await player_a.if_session.handle_input(command)
await player_a.send(response.output)
# Broadcast to spectators
spectator_msg = f"[{player_a.name}'s terminal]\r\n> {command}\r\n{response.output}"
await broadcast_to_spectators(player_a, spectator_msg)
# Player C should NOT have received anything
assert not player_c.writer.write.called
@pytest.mark.asyncio
async def test_spectator_sees_game_start(player_a, player_b):
"""Spectator sees formatted intro text when player starts game."""
# Register both players
players[player_a.name] = player_a
players[player_b.name] = player_b
from mudlib.if_session import broadcast_to_spectators
# Simulate game start with intro text
intro = "ZORK I: The Great Underground Empire\nCopyright (c) 1981"
spectator_msg = f"[{player_a.name}'s terminal]\r\n{intro}\r\n"
await broadcast_to_spectators(player_a, spectator_msg)
# Player B should see the intro
assert player_b.writer.write.called
calls = player_b.writer.write.call_args_list
sent_text = "".join(call[0][0] for call in calls)
assert "[PlayerA's terminal]" in sent_text
assert "ZORK I: The Great Underground Empire" in sent_text
@pytest.mark.asyncio
async def test_broadcast_to_spectators_skips_self(player_a, player_b):
"""broadcast_to_spectators does not send to the playing player."""
# Register both players
players[player_a.name] = player_a
players[player_b.name] = player_b
from mudlib.if_session import broadcast_to_spectators
message = "[PlayerA's terminal]\r\n> look\r\nYou see a room.\r\n"
await broadcast_to_spectators(player_a, message)
# Player A should NOT have received the message
assert not player_a.writer.write.called
# Player B should have received it
assert player_b.writer.write.called
@pytest.mark.asyncio
async def test_multiple_spectators(player_a, player_b):
"""Multiple spectators at same location all see IF output."""
# Create a third player at same location
writer_d = MagicMock()
writer_d.write = MagicMock()
writer_d.drain = AsyncMock()
reader_d = MagicMock()
player_d = Player(name="PlayerD", x=5, y=5, reader=reader_d, writer=writer_d)
# Register all players
players[player_a.name] = player_a
players[player_b.name] = player_b
players[player_d.name] = player_d
from mudlib.if_session import broadcast_to_spectators
message = "[PlayerA's terminal]\r\n> inventory\r\nYou are empty-handed.\r\n"
await broadcast_to_spectators(player_a, message)
# Both spectators should see the message
assert player_b.writer.write.called
assert player_d.writer.write.called
# Player A should not
assert not player_a.writer.write.called

204
tests/test_play_command.py Normal file
View file

@ -0,0 +1,204 @@
"""Tests for the play command."""
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def player(mock_writer):
from mudlib.player import Player
return Player(name="tester", x=5, y=5, writer=mock_writer)
def test_play_command_registered():
"""Verify play command is registered."""
import mudlib.commands.play # noqa: F401
from mudlib import commands
assert "play" in commands._registry
cmd = commands._registry["play"]
assert cmd.name == "play"
assert cmd.mode == "normal"
@pytest.mark.asyncio
async def test_play_no_args(player):
"""Playing with no args sends usage message."""
from mudlib.commands.play import cmd_play
await cmd_play(player, "")
player.writer.write.assert_called()
output = player.writer.write.call_args[0][0]
assert "play what?" in output.lower()
@pytest.mark.asyncio
async def test_play_unknown_story(player):
"""Playing unknown story sends error message."""
from mudlib.commands.play import cmd_play
await cmd_play(player, "nosuchgame")
player.writer.write.assert_called()
output = player.writer.write.call_args[0][0]
assert "no story" in output.lower()
assert "nosuchgame" in output.lower()
@pytest.mark.asyncio
async def test_play_enters_if_mode(player):
"""Playing a valid story enters IF mode and creates session."""
from pathlib import Path
from mudlib.commands.play import cmd_play
# Mock IFSession
mock_session = Mock()
mock_session.start = AsyncMock(return_value="Welcome to Zork!")
mock_session.save_path = Mock(spec=Path)
mock_session.save_path.exists = Mock(return_value=False)
with patch("mudlib.commands.play.IFSession") as MockIFSession:
MockIFSession.return_value = mock_session
# Ensure story file exists check passes
with patch("mudlib.commands.play._find_story") as mock_find:
mock_find.return_value = "/fake/path/zork1.z3"
await cmd_play(player, "zork1")
# Verify session was created and started
mock_session.start.assert_called_once()
# Verify mode was pushed
assert "if" in player.mode_stack
# Verify session was attached to player
assert player.if_session is mock_session
# Verify intro was sent
player.writer.write.assert_called()
output = player.writer.write.call_args[0][0]
assert "Welcome to Zork!" in output
@pytest.mark.asyncio
async def test_play_handles_dfrotz_missing(player):
"""Playing when dfrotz is missing sends error."""
from pathlib import Path
from mudlib.commands.play import cmd_play
# Mock IFSession to raise FileNotFoundError on start
mock_session = Mock()
mock_session.start = AsyncMock(side_effect=FileNotFoundError())
mock_session.stop = AsyncMock()
mock_session.save_path = Mock(spec=Path)
mock_session.save_path.exists = Mock(return_value=False)
with patch("mudlib.commands.play.IFSession") as MockIFSession:
MockIFSession.return_value = mock_session
with patch("mudlib.commands.play._find_story") as mock_find:
mock_find.return_value = "/fake/path/zork1.z3"
await cmd_play(player, "zork1")
# Verify error message was sent
player.writer.write.assert_called()
output = player.writer.write.call_args[0][0]
assert "dfrotz not found" in output.lower()
# Verify mode was NOT pushed
assert "if" not in player.mode_stack
# Verify session was NOT attached
assert player.if_session is None
# Verify session.stop() was called
mock_session.stop.assert_called_once()
@pytest.mark.asyncio
async def test_play_restores_save_if_exists(player):
"""Playing restores saved game if save file exists."""
from pathlib import Path
from mudlib.commands.play import cmd_play
# Mock IFSession
mock_session = Mock()
mock_session.start = AsyncMock(return_value="Welcome to Zork!")
mock_session._do_restore = AsyncMock(
return_value="West of House\nYou are standing in an open field."
)
mock_session.save_path = Mock(spec=Path)
mock_session.save_path.exists = Mock(return_value=True)
with patch("mudlib.commands.play.IFSession") as MockIFSession:
MockIFSession.return_value = mock_session
with patch("mudlib.commands.play._find_story") as mock_find:
mock_find.return_value = "/fake/path/zork1.z3"
await cmd_play(player, "zork1")
# Verify restore was called
mock_session._do_restore.assert_called_once()
# Verify session was created and started
mock_session.start.assert_called_once()
# Verify mode was pushed
assert "if" in player.mode_stack
# Verify restored text was sent
calls = [call[0][0] for call in player.writer.write.call_args_list]
full_output = "".join(calls)
assert "restoring" in full_output.lower()
assert "West of House" in full_output
assert "open field" in full_output
@pytest.mark.asyncio
async def test_play_no_restore_if_no_save(player):
"""Playing does not restore if no save file exists."""
from pathlib import Path
from mudlib.commands.play import cmd_play
# Mock IFSession
mock_session = Mock()
mock_session.start = AsyncMock(return_value="Welcome to Zork!")
mock_session._do_restore = AsyncMock(return_value="")
mock_session.save_path = Mock(spec=Path)
mock_session.save_path.exists = Mock(return_value=False)
with patch("mudlib.commands.play.IFSession") as MockIFSession:
MockIFSession.return_value = mock_session
with patch("mudlib.commands.play._find_story") as mock_find:
mock_find.return_value = "/fake/path/zork1.z3"
await cmd_play(player, "zork1")
# Verify restore was NOT called
mock_session._do_restore.assert_not_called()
# Verify session was created and started
mock_session.start.assert_called_once()
# Verify intro was sent but not restore message
calls = [call[0][0] for call in player.writer.write.call_args_list]
full_output = "".join(calls)
assert "Welcome to Zork!" in full_output
assert "restoring" not in full_output.lower()