Compare commits
No commits in common. "05c9a48bb350351b2a9e30cd41872e5b4af935f0" and "af941b329bf8ee1c0e0a304ffe5f7423b80682c4" have entirely different histories.
05c9a48bb3
...
af941b329b
14 changed files with 4 additions and 2245 deletions
|
|
@ -1,17 +0,0 @@
|
||||||
__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,4 +4,3 @@ build
|
||||||
data
|
data
|
||||||
.worktrees
|
.worktrees
|
||||||
.testmondata
|
.testmondata
|
||||||
*.z*
|
|
||||||
|
|
|
||||||
28
Dockerfile
28
Dockerfile
|
|
@ -1,28 +0,0 @@
|
||||||
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
13
compose.yml
|
|
@ -1,13 +0,0 @@
|
||||||
services:
|
|
||||||
mud:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "6789:6789"
|
|
||||||
volumes:
|
|
||||||
- mud-data:/app/data
|
|
||||||
- mud-cache:/app/build
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mud-data:
|
|
||||||
mud-cache:
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
"""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"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
"""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,7 +10,6 @@ from mudlib.entity import Entity
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mudlib.editor import Editor
|
from mudlib.editor import Editor
|
||||||
from mudlib.if_session import IFSession
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -23,7 +22,6 @@ class Player(Entity):
|
||||||
mode_stack: list[str] = field(default_factory=lambda: ["normal"])
|
mode_stack: list[str] = field(default_factory=lambda: ["normal"])
|
||||||
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
|
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
|
||||||
editor: Editor | None = None
|
editor: Editor | None = None
|
||||||
if_session: IFSession | None = None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import pathlib
|
import pathlib
|
||||||
import time
|
import time
|
||||||
import tomllib
|
import tomllib
|
||||||
|
|
@ -18,7 +17,6 @@ import mudlib.commands.fly
|
||||||
import mudlib.commands.help
|
import mudlib.commands.help
|
||||||
import mudlib.commands.look
|
import mudlib.commands.look
|
||||||
import mudlib.commands.movement
|
import mudlib.commands.movement
|
||||||
import mudlib.commands.play
|
|
||||||
import mudlib.commands.quit
|
import mudlib.commands.quit
|
||||||
import mudlib.commands.reload
|
import mudlib.commands.reload
|
||||||
import mudlib.commands.spawn
|
import mudlib.commands.spawn
|
||||||
|
|
@ -27,7 +25,6 @@ from mudlib.combat.commands import register_combat_commands
|
||||||
from mudlib.combat.engine import process_combat
|
from mudlib.combat.engine import process_combat
|
||||||
from mudlib.content import load_commands
|
from mudlib.content import load_commands
|
||||||
from mudlib.effects import clear_expired
|
from mudlib.effects import clear_expired
|
||||||
from mudlib.if_session import broadcast_to_spectators
|
|
||||||
from mudlib.mob_ai import process_mobs
|
from mudlib.mob_ai import process_mobs
|
||||||
from mudlib.mobs import load_mob_templates, mob_templates
|
from mudlib.mobs import load_mob_templates, mob_templates
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
|
|
@ -46,8 +43,7 @@ from mudlib.world.terrain import World
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
HOST = os.environ.get("MUD_HOST", "127.0.0.1")
|
PORT = 6789
|
||||||
PORT = int(os.environ.get("MUD_PORT", "6789"))
|
|
||||||
TICK_RATE = 10 # ticks per second
|
TICK_RATE = 10 # ticks per second
|
||||||
TICK_INTERVAL = 1.0 / TICK_RATE
|
TICK_INTERVAL = 1.0 / TICK_RATE
|
||||||
AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves
|
AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves
|
||||||
|
|
@ -314,8 +310,6 @@ async def shell(
|
||||||
# Show appropriate prompt based on mode
|
# Show appropriate prompt based on mode
|
||||||
if player.mode == "editor" and player.editor:
|
if player.mode == "editor" and player.editor:
|
||||||
_writer.write(f" {player.editor.cursor + 1}> ")
|
_writer.write(f" {player.editor.cursor + 1}> ")
|
||||||
elif player.mode == "if" and player.if_session:
|
|
||||||
_writer.write("> ")
|
|
||||||
else:
|
else:
|
||||||
_writer.write("mud> ")
|
_writer.write("mud> ")
|
||||||
await _writer.drain()
|
await _writer.drain()
|
||||||
|
|
@ -325,7 +319,7 @@ async def shell(
|
||||||
break
|
break
|
||||||
|
|
||||||
command = inp.strip()
|
command = inp.strip()
|
||||||
if not command and player.mode not in ("editor", "if"):
|
if not command and player.mode != "editor":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Handle editor mode
|
# Handle editor mode
|
||||||
|
|
@ -336,27 +330,6 @@ async def shell(
|
||||||
if response.done:
|
if response.done:
|
||||||
player.editor = None
|
player.editor = None
|
||||||
player.mode_stack.pop()
|
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:
|
else:
|
||||||
# Dispatch normal command
|
# Dispatch normal command
|
||||||
await mudlib.commands.dispatch(player, command)
|
await mudlib.commands.dispatch(player, command)
|
||||||
|
|
@ -365,9 +338,6 @@ async def shell(
|
||||||
if _writer.is_closing():
|
if _writer.is_closing():
|
||||||
break
|
break
|
||||||
finally:
|
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)
|
# Save player state on disconnect (if not already saved by quit command)
|
||||||
if player_name in players:
|
if player_name in players:
|
||||||
save_player(player)
|
save_player(player)
|
||||||
|
|
@ -444,9 +414,9 @@ async def run_server() -> None:
|
||||||
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
||||||
# telnetlib3 still waits for the full timeout. 0.5s is plenty.
|
# telnetlib3 still waits for the full timeout. 0.5s is plenty.
|
||||||
server = await telnetlib3.create_server(
|
server = await telnetlib3.create_server(
|
||||||
host=HOST, port=PORT, shell=shell, connect_maxwait=0.5
|
host="127.0.0.1", port=PORT, shell=shell, connect_maxwait=0.5
|
||||||
)
|
)
|
||||||
log.info("listening on %s:%d", HOST, PORT)
|
log.info("listening on 127.0.0.1:%d", PORT)
|
||||||
|
|
||||||
loop_task = asyncio.create_task(game_loop())
|
loop_task = asyncio.create_task(game_loop())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
"""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("")
|
|
||||||
|
|
@ -1,903 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
"""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