Compare commits
29 commits
af941b329b
...
05c9a48bb3
| Author | SHA1 | Date | |
|---|---|---|---|
| 05c9a48bb3 | |||
| b90e61c4fc | |||
| 8dc2d4b934 | |||
| de58209fd0 | |||
| da176a1363 | |||
| e8f16ca18a | |||
| 43fce6a4ed | |||
| 76488139c8 | |||
| bb1f9bbbd8 | |||
| 8893525647 | |||
| 57afe9a3ce | |||
| 6879da0964 | |||
| 5f0fec14ef | |||
| e2bd260538 | |||
| 6ea82a8496 | |||
| 057a746687 | |||
| 3dd095b9ea | |||
| c3f8c8cf12 | |||
| 2ac2335b18 | |||
| c246732b86 | |||
| bc69f46d1a | |||
| 6308248d14 | |||
| 8a8e3dd6e8 | |||
| b133f2febe | |||
| dc342224b1 | |||
| d210033f33 | |||
| bc5e829f6b | |||
| 4b52051bed | |||
| 6fd19d769b |
14 changed files with 2245 additions and 4 deletions
17
.dockerignore
Normal file
17
.dockerignore
Normal 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
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ build
|
|||
data
|
||||
.worktrees
|
||||
.testmondata
|
||||
*.z*
|
||||
|
|
|
|||
28
Dockerfile
Normal file
28
Dockerfile
Normal 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
13
compose.yml
Normal 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:
|
||||
133
docs/how/utf8-design-lesson.rst
Normal file
133
docs/how/utf8-design-lesson.rst
Normal 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
264
docs/plans/if-terminal.txt
Normal 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
101
src/mudlib/commands/play.py
Normal 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
229
src/mudlib/if_session.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
121
tests/test_if_mode.py
Normal 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
903
tests/test_if_session.py
Normal 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
195
tests/test_if_spectator.py
Normal 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
204
tests/test_play_command.py
Normal 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()
|
||||
Loading…
Reference in a new issue