Compare commits
7 commits
61dd321b86
...
305f25e77a
| Author | SHA1 | Date | |
|---|---|---|---|
| 305f25e77a | |||
| 42bb3fa046 | |||
| 86c2c46dfa | |||
| f2d52c4936 | |||
| 1fa21e20b0 | |||
| 2bab61ef8c | |||
| ed6ffbdc5d |
17 changed files with 2665 additions and 4 deletions
6
content/mobs/goblin.toml
Normal file
6
content/mobs/goblin.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
name = "goblin"
|
||||||
|
description = "a snarling goblin with a crude club"
|
||||||
|
pl = 50.0
|
||||||
|
stamina = 40.0
|
||||||
|
max_stamina = 40.0
|
||||||
|
moves = ["punch left", "punch right", "sweep"]
|
||||||
6
content/mobs/training_dummy.toml
Normal file
6
content/mobs/training_dummy.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
name = "training dummy"
|
||||||
|
description = "a battered wooden training dummy"
|
||||||
|
pl = 200.0
|
||||||
|
stamina = 100.0
|
||||||
|
max_stamina = 100.0
|
||||||
|
moves = []
|
||||||
676
docs/how/if-integration.txt
Normal file
676
docs/how/if-integration.txt
Normal file
|
|
@ -0,0 +1,676 @@
|
||||||
|
interactive fiction integration — hosting z-machine games in a MUD
|
||||||
|
====================================================================
|
||||||
|
|
||||||
|
where this fits. the dreambook calls for IF games as in-world content — a room
|
||||||
|
in the MUD becomes a portal to a z-machine adventure. players enter IF mode,
|
||||||
|
play through a self-contained story, and other players see "jared is playing
|
||||||
|
zork in the arcade." fully isolated from world noise, but visible to
|
||||||
|
spectators in the same room.
|
||||||
|
|
||||||
|
the architecture is ready. we have the mode stack, input routing, editor mode
|
||||||
|
pattern. the missing pieces:
|
||||||
|
- z-machine interpreter (either embedded or subprocess)
|
||||||
|
- room-local spectator broadcasting (others see your game output)
|
||||||
|
- "terminal" game object concept (a room object that hosts IF sessions)
|
||||||
|
- save/restore integration (quetzal snapshots to sqlite)
|
||||||
|
|
||||||
|
this doc synthesizes research on three z-machine implementations and proposes
|
||||||
|
integration paths.
|
||||||
|
|
||||||
|
|
||||||
|
the z-machine landscape
|
||||||
|
=======================
|
||||||
|
|
||||||
|
three implementations evaluated:
|
||||||
|
|
||||||
|
sussman/zvm (python, pure stdlib):
|
||||||
|
- versions 1-5 claimed, but only ~40% of opcodes implemented
|
||||||
|
- interpreter HALTS on any unimplemented opcode — can't gracefully degrade
|
||||||
|
- excellent pluggable I/O: abstract ZUI, ZScreen, ZInputStream, ZOutputStream
|
||||||
|
- quetzal save/restore infrastructure exists but opcodes are stubbed
|
||||||
|
- pure python, zero dependencies — perfect for embedding
|
||||||
|
- blocking run loop, not async-compatible
|
||||||
|
- clean API: ZMachine(story_bytes, ui=custom_ui).run()
|
||||||
|
- python 3 port was a "blind fix" — functional but fragile
|
||||||
|
- verdict: TOO INCOMPLETE. would need 2-4 weeks of opcode implementation
|
||||||
|
before any real game runs. the I/O architecture is excellent reference
|
||||||
|
material but the CPU is not production-ready.
|
||||||
|
|
||||||
|
viola (python, pygame dependency):
|
||||||
|
- versions 1-8, comprehensive modern standard 1.1 compliance
|
||||||
|
- 120+ opcodes implemented — ALL documented z-spec instructions have
|
||||||
|
implementations
|
||||||
|
- clean stream architecture: 5 output streams, each individually addressable
|
||||||
|
- full quetzal 1.4 save/restore support with undo
|
||||||
|
- active maintenance (2024 copyright, author engaged)
|
||||||
|
- dependencies: pygame (for rendering/input), numpy (for image processing)
|
||||||
|
- bundled ififf submodule for blorb/quetzal/IFF handling
|
||||||
|
- blocking main loop (execloop), module-level global state
|
||||||
|
- no tests whatsoever
|
||||||
|
- clean module separation: zcode/ (VM core) vs vio/ (I/O layer)
|
||||||
|
- ~8,500 lines in zcode/ + ~2,000 in vio/
|
||||||
|
- verdict: MOST COMPLETE python z-machine available. pygame dependency is
|
||||||
|
only in vio/ — replace that module with a text-callback I/O backend and
|
||||||
|
the core VM is usable. strongest candidate for embedding.
|
||||||
|
|
||||||
|
mojozork (C, educational implementation):
|
||||||
|
- version 3 only (rejects other versions with error)
|
||||||
|
- ~45 opcodes, complete for v3 (covers ~90% of infocom catalog)
|
||||||
|
- excellent pluggable I/O via function pointer callbacks
|
||||||
|
- has runInstruction() for single-step execution — async-friendly by design
|
||||||
|
- multiple frontends: stdio, libretro, SDL3, and a TELNET MULTIPLAYER SERVER
|
||||||
|
- MultiZork uses sqlite for multiplayer state persistence
|
||||||
|
- zlib license (very permissive)
|
||||||
|
- author: ryan c. gordon (well-known open source dev)
|
||||||
|
- custom save format (not quetzal)
|
||||||
|
- C only — would need ctypes/CFFI wrapper or subprocess
|
||||||
|
- verdict: EXCELLENT REFERENCE for architecture patterns. MultiZork shows
|
||||||
|
exactly how to embed a z-machine in a multiplayer telnet server. v3-only
|
||||||
|
is limiting but covers classic infocom games. best studied, potentially
|
||||||
|
wrapped via C extension.
|
||||||
|
|
||||||
|
|
||||||
|
integration options
|
||||||
|
===================
|
||||||
|
|
||||||
|
three paths forward:
|
||||||
|
|
||||||
|
option A: embed viola (python native)
|
||||||
|
- fork or vendor viola's zcode/ module
|
||||||
|
- replace vio/ with our own I/O backend: MudZUI class
|
||||||
|
- wrap the blocking execloop with async bridge (run_in_executor)
|
||||||
|
- state serialization via viola's quetzal support
|
||||||
|
- pros: pure python, full z-machine 1-8 support, comprehensive opcodes,
|
||||||
|
native access to VM state
|
||||||
|
- cons: no tests in upstream, blocking loop requires thread pool,
|
||||||
|
global state needs refactoring for multiple concurrent games
|
||||||
|
|
||||||
|
option B: subprocess dfrozt (quick prototype)
|
||||||
|
- spawn dfrotz in a subprocess, pipe stdin/stdout
|
||||||
|
- asyncio subprocess API handles I/O
|
||||||
|
- save/restore via dfrotz's built-in quetzal support
|
||||||
|
- pros: FASTEST PATH TO WORKING. dfrozt is battle-tested, z-machine 1-8,
|
||||||
|
comprehensive game compatibility
|
||||||
|
- cons: less control over VM state, harder to implement spectators (need to
|
||||||
|
tee output), subprocess management overhead, can't introspect game state
|
||||||
|
for GMCP/rich features
|
||||||
|
|
||||||
|
option C: write our own (long-term ideal)
|
||||||
|
- study mojozork and sussman/zvm architectures
|
||||||
|
- implement enough opcodes for target games (start with v3)
|
||||||
|
- async-native from the start
|
||||||
|
- pros: perfect fit for dreambook philosophy, full control, async-native,
|
||||||
|
clean integration with MUD internals
|
||||||
|
- cons: SIGNIFICANT DETOUR. weeks/months before first game runs. opcode
|
||||||
|
implementation is tedious and error-prone. spec compliance is hard.
|
||||||
|
|
||||||
|
recommendation: START WITH B (dfrozt subprocess), PLAN FOR A (viola embedding).
|
||||||
|
|
||||||
|
rationale: dfrozt gets IF working in hours. players can play zork today. we
|
||||||
|
learn what the integration needs (spectator broadcasting, save management,
|
||||||
|
input routing) with real usage. once the architecture is proven, we can
|
||||||
|
replace the subprocess with embedded viola for better control and state
|
||||||
|
access. option C remains on the table if viola doesn't fit, but we don't
|
||||||
|
invest in a VM until we know what the MUD needs from it.
|
||||||
|
|
||||||
|
|
||||||
|
the "terminal" game object
|
||||||
|
==========================
|
||||||
|
|
||||||
|
a terminal is a room object that hosts an IF session. mechanically:
|
||||||
|
|
||||||
|
- a terminal has a z-machine story file (path or blob reference)
|
||||||
|
- when a player enters the terminal, it pushes IF mode onto their stack
|
||||||
|
- input from the player routes to the z-machine interpreter
|
||||||
|
- output from the z-machine routes to the player (and spectators)
|
||||||
|
- the terminal tracks active sessions (who's playing, current state)
|
||||||
|
|
||||||
|
example from a player's perspective:
|
||||||
|
|
||||||
|
> look
|
||||||
|
you stand in a neon-lit arcade. rows of terminals hum quietly.
|
||||||
|
a large screen on the north wall shows "ZORK - The Great Underground
|
||||||
|
Empire" in green phosphor text.
|
||||||
|
|
||||||
|
jared is standing here, absorbed in a terminal.
|
||||||
|
sarah is watching jared's game on a nearby screen.
|
||||||
|
|
||||||
|
> use terminal
|
||||||
|
you sit down at a terminal. the screen flickers to life.
|
||||||
|
|
||||||
|
[IF mode engaged. type 'quit' to exit the game.]
|
||||||
|
|
||||||
|
ZORK I: The Great Underground Empire
|
||||||
|
Copyright (c) 1981, 1982, 1983 Infocom, Inc. All rights reserved.
|
||||||
|
ZORK is a registered trademark of Infocom, Inc.
|
||||||
|
Revision 88 / Serial number 840726
|
||||||
|
|
||||||
|
West of House
|
||||||
|
You are standing in an open field west of a white house, with a boarded
|
||||||
|
front door.
|
||||||
|
There is a small mailbox here.
|
||||||
|
|
||||||
|
>
|
||||||
|
|
||||||
|
at this point the player's input goes to the z-machine. the terminal object
|
||||||
|
manages the interpreter subprocess/instance. other players in the room see
|
||||||
|
"jared is playing zork" in their ambient messages.
|
||||||
|
|
||||||
|
|
||||||
|
spectator broadcasting
|
||||||
|
======================
|
||||||
|
|
||||||
|
key feature from mojozork's MultiZork: others can watch your game.
|
||||||
|
|
||||||
|
in the MUD: if sarah is in the same room as jared, she can see his game
|
||||||
|
output in near-realtime. not keystroke-by-keystroke, but command and response.
|
||||||
|
|
||||||
|
implementation:
|
||||||
|
|
||||||
|
- terminal object has a set of active_spectators (player refs)
|
||||||
|
- when the z-machine sends output to the active player, terminal mirrors
|
||||||
|
that output to all spectators in the same room
|
||||||
|
- spectators see: "[jared's game] > n\nYou are in a maze of twisty little
|
||||||
|
passages, all alike."
|
||||||
|
- spectators can 'watch jared' to start spectating, 'stop watching' to stop
|
||||||
|
- spectators are in normal mode, not IF mode. they see their own world
|
||||||
|
events too.
|
||||||
|
|
||||||
|
this creates communal play. someone stuck on a puzzle can have friends watch
|
||||||
|
and suggest commands. speedruns become spectator events. IF becomes social.
|
||||||
|
|
||||||
|
technical: output from the z-machine goes to a broadcast function:
|
||||||
|
|
||||||
|
def send_to_player_and_spectators(player, output):
|
||||||
|
player.send(output)
|
||||||
|
terminal = player.current_terminal
|
||||||
|
for spectator in terminal.spectators:
|
||||||
|
if spectator.location == terminal.location:
|
||||||
|
spectator.send(f"[{player.name}'s game] {output}")
|
||||||
|
|
||||||
|
spectators don't share game state, just output. they can't send commands to
|
||||||
|
someone else's game.
|
||||||
|
|
||||||
|
|
||||||
|
viola embedding details
|
||||||
|
========================
|
||||||
|
|
||||||
|
if we go with option A, here's how viola integration would work:
|
||||||
|
|
||||||
|
replace vio/ with MudZUI:
|
||||||
|
|
||||||
|
class MudZUI:
|
||||||
|
"""viola I/O backend that routes to MUD player sessions"""
|
||||||
|
|
||||||
|
def __init__(self, player, terminal):
|
||||||
|
self.player = player
|
||||||
|
self.terminal = terminal
|
||||||
|
self.output_buffer = []
|
||||||
|
|
||||||
|
def write(self, text):
|
||||||
|
"""called by VM to send output"""
|
||||||
|
self.output_buffer.append(text)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
"""send buffered output to player and spectators"""
|
||||||
|
output = ''.join(self.output_buffer)
|
||||||
|
self.terminal.broadcast_output(self.player, output)
|
||||||
|
self.output_buffer.clear()
|
||||||
|
|
||||||
|
def read_line(self):
|
||||||
|
"""called by VM to get player input"""
|
||||||
|
# return input from player's IF mode input queue
|
||||||
|
return self.player.if_input_queue.get()
|
||||||
|
|
||||||
|
def read_char(self):
|
||||||
|
"""single-key input for timed events"""
|
||||||
|
# viola supports this, we'd need to too
|
||||||
|
return self.player.if_input_queue.get_char()
|
||||||
|
|
||||||
|
wrap the blocking VM loop:
|
||||||
|
|
||||||
|
async def run_if_game(player, terminal, story_path):
|
||||||
|
"""run z-machine in thread pool, bridge to async"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
# load story file
|
||||||
|
with open(story_path, 'rb') as f:
|
||||||
|
story_data = f.read()
|
||||||
|
|
||||||
|
# create VM with our I/O backend
|
||||||
|
zui = MudZUI(player, terminal)
|
||||||
|
zmachine = ZMachine(story_data, zui)
|
||||||
|
|
||||||
|
# run in executor (thread pool) since VM loop is blocking
|
||||||
|
def run_vm():
|
||||||
|
try:
|
||||||
|
zmachine.run()
|
||||||
|
except GameOver:
|
||||||
|
pass # normal exit
|
||||||
|
|
||||||
|
await loop.run_in_executor(None, run_vm)
|
||||||
|
|
||||||
|
state management:
|
||||||
|
|
||||||
|
- viola has full quetzal save/restore
|
||||||
|
- when player types 'save', VM writes quetzal IFF to bytes
|
||||||
|
- we store that blob in sqlite: game_saves table (player_id, terminal_id,
|
||||||
|
save_slot, quetzal_data, timestamp)
|
||||||
|
- when player types 'restore', we fetch the blob and pass to VM
|
||||||
|
- viola handles all the z-machine state serialization
|
||||||
|
|
||||||
|
multiple concurrent games:
|
||||||
|
|
||||||
|
- viola uses module-level global state in some places
|
||||||
|
- need to audit zcode/ and refactor globals into instance state
|
||||||
|
- each terminal object owns a ZMachine instance
|
||||||
|
- multiple players can play the same game in different terminals
|
||||||
|
- each gets their own VM instance with independent state
|
||||||
|
|
||||||
|
this is work, but manageable. viola's clean separation of zcode/ (VM) from
|
||||||
|
vio/ (I/O) makes the I/O replacement straightforward. the async bridge is a
|
||||||
|
standard pattern. the global state refactor is the biggest risk.
|
||||||
|
|
||||||
|
|
||||||
|
dfrozt subprocess details
|
||||||
|
==========================
|
||||||
|
|
||||||
|
if we go with option B for prototyping, here's the implementation:
|
||||||
|
|
||||||
|
spawn dfrozt:
|
||||||
|
|
||||||
|
async def run_if_game_subprocess(player, terminal, story_path):
|
||||||
|
"""run dfrotz as subprocess, pipe I/O"""
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
'dfrotz',
|
||||||
|
'-p', # plain output, no formatting
|
||||||
|
'-m', # disable MORE prompts
|
||||||
|
story_path,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# bridge player input to subprocess stdin
|
||||||
|
async def input_loop():
|
||||||
|
while process.returncode is None:
|
||||||
|
command = await player.if_input_queue.get()
|
||||||
|
process.stdin.write(f"{command}\n".encode())
|
||||||
|
await process.stdin.drain()
|
||||||
|
|
||||||
|
# bridge subprocess stdout to player and spectators
|
||||||
|
async def output_loop():
|
||||||
|
while process.returncode is None:
|
||||||
|
line = await process.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
text = line.decode('utf-8', errors='replace')
|
||||||
|
terminal.broadcast_output(player, text)
|
||||||
|
|
||||||
|
# run both loops concurrently
|
||||||
|
await asyncio.gather(input_loop(), output_loop())
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
save/restore:
|
||||||
|
|
||||||
|
- dfrotz handles saves via its own commands (SAVE, RESTORE)
|
||||||
|
- save files go to filesystem
|
||||||
|
- we could intercept SAVE/RESTORE commands, manage files in a
|
||||||
|
player-specific directory, copy to sqlite for persistence
|
||||||
|
- or let dfrozt write to /tmp/ and just copy the files on exit
|
||||||
|
|
||||||
|
spectator output:
|
||||||
|
|
||||||
|
- subprocess output goes to all (player + spectators) via broadcast
|
||||||
|
- tee is automatic — we control the output loop
|
||||||
|
|
||||||
|
exit/cleanup:
|
||||||
|
|
||||||
|
- player types 'quit' or EOF — we send QUIT to process, wait for exit
|
||||||
|
- process crash — we catch it, log error, tell player "game crashed"
|
||||||
|
- terminal cleanup — kill subprocess if still running
|
||||||
|
|
||||||
|
pros: simple, robust, dfrozt is battle-tested.
|
||||||
|
cons: less control. can't introspect VM state for GMCP data. can't implement
|
||||||
|
custom opcodes. subprocess overhead (but negligible for text games).
|
||||||
|
|
||||||
|
|
||||||
|
mojozork patterns to study
|
||||||
|
===========================
|
||||||
|
|
||||||
|
MultiZork is mojozork's multiplayer telnet server. it shows:
|
||||||
|
|
||||||
|
1. session management: each player gets a separate VM instance, but the game
|
||||||
|
world is shared. players see each other's actions. this is DIFFERENT from
|
||||||
|
our model (each terminal is isolated) but the architecture is instructive.
|
||||||
|
|
||||||
|
2. sqlite persistence: MultiZork stores game state in sqlite. saves are just
|
||||||
|
snapshots of the VM memory. we'd do the same with quetzal blobs.
|
||||||
|
|
||||||
|
3. command broadcasting: when one player types something, MultiZork echoes it
|
||||||
|
to others in the same location. same as our spectator model.
|
||||||
|
|
||||||
|
4. pluggable I/O: mojozork's I/O is function pointers:
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
void (*print)(const char *text);
|
||||||
|
char *(*read_line)(void);
|
||||||
|
void (*save_state)(const uint8_t *data, size_t len);
|
||||||
|
uint8_t *(*restore_state)(size_t *len);
|
||||||
|
} IOCallbacks;
|
||||||
|
|
||||||
|
clean, simple. viola's ZUI is the python equivalent.
|
||||||
|
|
||||||
|
5. tick-based execution: MultiZork runs the VM in a loop, checking for input
|
||||||
|
each tick. same as our game loop. z-machine opcodes are fast — dozens
|
||||||
|
execute per tick.
|
||||||
|
|
||||||
|
MultiZork source is excellent reference for "how to embed IF in a multiplayer
|
||||||
|
server." if we write our own VM (option C), MultiZork is the template.
|
||||||
|
|
||||||
|
|
||||||
|
save/restore and persistence
|
||||||
|
=============================
|
||||||
|
|
||||||
|
z-machine has built-in save/restore opcodes. quetzal is the standard format.
|
||||||
|
|
||||||
|
in our MUD:
|
||||||
|
|
||||||
|
- player types SAVE in the IF game
|
||||||
|
- VM serializes its state to quetzal IFF (binary format)
|
||||||
|
- we capture that blob and write to sqlite:
|
||||||
|
|
||||||
|
CREATE TABLE if_saves (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
terminal_id INTEGER NOT NULL,
|
||||||
|
slot INTEGER DEFAULT 0,
|
||||||
|
quetzal_data BLOB NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(player_id, terminal_id, slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
- player types RESTORE, we fetch the blob and pass back to VM
|
||||||
|
|
||||||
|
autosave:
|
||||||
|
|
||||||
|
- when player exits IF mode (QUIT command or connection drop), we could
|
||||||
|
autosave their state
|
||||||
|
- on reconnect/re-enter, offer to resume from last autosave
|
||||||
|
|
||||||
|
multiple save slots:
|
||||||
|
|
||||||
|
- z-machine games usually support named saves ("SAVE mysave")
|
||||||
|
- we could parse the filename and use it as the slot identifier
|
||||||
|
- or just offer slots 1-10 and map them to quetzal blobs
|
||||||
|
|
||||||
|
cross-session saves:
|
||||||
|
|
||||||
|
- player saves in session A, restores in session B (different terminal)
|
||||||
|
- works fine if both terminals host the same story file
|
||||||
|
- could enable "speedrun mode" — reset to a specific save and time from
|
||||||
|
there
|
||||||
|
|
||||||
|
undo:
|
||||||
|
|
||||||
|
- z-machine has UNDO opcode
|
||||||
|
- viola implements it via a stack of quetzal snapshots
|
||||||
|
- we could surface undo as a MUD command outside the game (safety net if
|
||||||
|
player makes a mistake)
|
||||||
|
|
||||||
|
|
||||||
|
if mode on the mode stack
|
||||||
|
==========================
|
||||||
|
|
||||||
|
when player enters a terminal, push IF mode:
|
||||||
|
|
||||||
|
player.mode_stack.push(Mode.IF)
|
||||||
|
|
||||||
|
IF mode behavior:
|
||||||
|
|
||||||
|
- all input goes to if_input_queue, not command dispatcher
|
||||||
|
- world events (chat, movement, ambient) are buffered, not displayed
|
||||||
|
- combat can't start (you're isolated)
|
||||||
|
- on exit, pop mode, show summary of buffered events
|
||||||
|
|
||||||
|
commands available in IF mode:
|
||||||
|
|
||||||
|
- anything the z-machine game accepts (depends on the game)
|
||||||
|
- special MUD commands prefixed with / or !:
|
||||||
|
- /quit — exit the game, pop IF mode
|
||||||
|
- /save [slot] — trigger VM save
|
||||||
|
- /restore [slot] — trigger VM restore
|
||||||
|
- /undo — trigger VM undo
|
||||||
|
- /help — show IF mode help
|
||||||
|
|
||||||
|
input routing:
|
||||||
|
|
||||||
|
before IF mode: player types "north" → command dispatcher → cmd_move
|
||||||
|
during IF mode: player types "north" → if_input_queue → z-machine
|
||||||
|
|
||||||
|
the mode stack handles this cleanly. IF mode is just another mode, like combat
|
||||||
|
or editor.
|
||||||
|
|
||||||
|
|
||||||
|
terminal object implementation sketch
|
||||||
|
======================================
|
||||||
|
|
||||||
|
rough structure:
|
||||||
|
|
||||||
|
class IFTerminal:
|
||||||
|
"""a room object that hosts z-machine games"""
|
||||||
|
|
||||||
|
def __init__(self, story_path, name, description):
|
||||||
|
self.story_path = story_path # path to .z3/.z5/.z8 file
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.active_sessions = {} # player -> IFSession
|
||||||
|
self.spectators = set() # players watching
|
||||||
|
|
||||||
|
async def enter(self, player):
|
||||||
|
"""player sits at terminal, start IF session"""
|
||||||
|
if player in self.active_sessions:
|
||||||
|
player.send("You're already playing this game.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# push IF mode
|
||||||
|
player.mode_stack.push(Mode.IF)
|
||||||
|
|
||||||
|
# create session (VM instance or subprocess)
|
||||||
|
session = await self.create_session(player)
|
||||||
|
self.active_sessions[player] = session
|
||||||
|
|
||||||
|
# announce to room
|
||||||
|
self.broadcast_except(player, f"{player.name} sits down at {self.name}.")
|
||||||
|
|
||||||
|
async def create_session(self, player):
|
||||||
|
"""spawn VM or subprocess for this player"""
|
||||||
|
# option A: return ZMachineSession(player, self)
|
||||||
|
# option B: return DfrotzSession(player, self)
|
||||||
|
pass
|
||||||
|
|
||||||
|
def broadcast_output(self, player, output):
|
||||||
|
"""send game output to player and spectators"""
|
||||||
|
player.send(output)
|
||||||
|
for spectator in self.spectators:
|
||||||
|
if spectator.location == self.location:
|
||||||
|
spectator.send(f"[{player.name}'s game]\n{output}")
|
||||||
|
|
||||||
|
async def exit(self, player):
|
||||||
|
"""player leaves terminal, cleanup session"""
|
||||||
|
session = self.active_sessions.pop(player, None)
|
||||||
|
if session:
|
||||||
|
await session.cleanup()
|
||||||
|
player.mode_stack.pop() # exit IF mode
|
||||||
|
self.broadcast_except(player, f"{player.name} stands up from {self.name}.")
|
||||||
|
|
||||||
|
terminals are room objects. they could be defined in world data:
|
||||||
|
|
||||||
|
- rooms:
|
||||||
|
- id: arcade
|
||||||
|
description: "a dimly lit arcade. terminals line the walls."
|
||||||
|
objects:
|
||||||
|
- type: if_terminal
|
||||||
|
name: "zork terminal"
|
||||||
|
story: "games/zork1.z3"
|
||||||
|
description: "an old CRT displaying 'ZORK I' in green text"
|
||||||
|
|
||||||
|
or created by players (builder commands):
|
||||||
|
|
||||||
|
> create terminal "hitchhiker terminal" games/hhgg.z5
|
||||||
|
You create a new IF terminal.
|
||||||
|
|
||||||
|
> describe terminal "A sleek modern terminal. The screen reads:
|
||||||
|
'The Hitchhiker's Guide to the Galaxy - Don't Panic'"
|
||||||
|
|
||||||
|
|
||||||
|
phased implementation plan
|
||||||
|
===========================
|
||||||
|
|
||||||
|
phase 1: dfrozt subprocess prototype
|
||||||
|
- define IFTerminal class
|
||||||
|
- implement IF mode on mode stack
|
||||||
|
- spawn dfrozt subprocess for a hardcoded game (zork1.z3)
|
||||||
|
- route player input to subprocess stdin
|
||||||
|
- route subprocess stdout to player
|
||||||
|
- /quit command to exit
|
||||||
|
goal: player can sit at a terminal and play zork. no saves, no spectators.
|
||||||
|
estimate: 1-2 days.
|
||||||
|
|
||||||
|
phase 2: spectator broadcasting
|
||||||
|
- terminal tracks spectators (players in same room)
|
||||||
|
- tee game output to spectators with prefix
|
||||||
|
- 'watch [player]' and 'stop watching' commands
|
||||||
|
goal: others can watch your game in realtime.
|
||||||
|
estimate: half day.
|
||||||
|
|
||||||
|
phase 3: save/restore via filesystem
|
||||||
|
- intercept SAVE/RESTORE commands
|
||||||
|
- manage save files in player-specific directory
|
||||||
|
- copy to sqlite on exit for persistence
|
||||||
|
goal: players can save progress and resume later.
|
||||||
|
estimate: 1 day.
|
||||||
|
|
||||||
|
phase 4: terminal as room object
|
||||||
|
- define terminals in world data (YAML)
|
||||||
|
- load at startup, spawn on player enter
|
||||||
|
- multiple terminals, different games
|
||||||
|
goal: arcade room with multiple games, each on its own terminal.
|
||||||
|
estimate: 1 day.
|
||||||
|
|
||||||
|
phase 5: evaluate viola embedding
|
||||||
|
- fork/vendor viola's zcode/
|
||||||
|
- implement MudZUI (I/O backend)
|
||||||
|
- wrap VM loop with run_in_executor
|
||||||
|
- test with zork1, compare to dfrozt
|
||||||
|
goal: decide if viola is better than subprocess.
|
||||||
|
estimate: 2-3 days.
|
||||||
|
|
||||||
|
phase 6: switch to viola if proven
|
||||||
|
- replace dfrozt sessions with viola sessions
|
||||||
|
- refactor terminal to use embedded VM
|
||||||
|
- quetzal saves to sqlite (direct, no filesystem)
|
||||||
|
- expose VM state for GMCP (current room, inventory, score)
|
||||||
|
goal: full control over VM, native python integration.
|
||||||
|
estimate: 2-3 days (assuming viola works).
|
||||||
|
|
||||||
|
phase 7: builder tools
|
||||||
|
- 'create terminal' command for admins/builders
|
||||||
|
- upload .z3/.z5/.z8 files to server
|
||||||
|
- terminal object editor (change description, story file)
|
||||||
|
goal: builders can add new IF games without code changes.
|
||||||
|
estimate: 2 days.
|
||||||
|
|
||||||
|
total estimate: 10-15 days for full IF integration with all features.
|
||||||
|
|
||||||
|
if we stop after phase 4, we have working IF-in-MUD with spectators and saves.
|
||||||
|
that's enough to prove the concept. phases 5-7 are refinement and tooling.
|
||||||
|
|
||||||
|
|
||||||
|
what we learn from this
|
||||||
|
========================
|
||||||
|
|
||||||
|
IF integration exercises several MUD systems:
|
||||||
|
|
||||||
|
- mode stack: IF mode is the third mode (after normal and editor). proves
|
||||||
|
the stack abstraction works for diverse isolation needs.
|
||||||
|
|
||||||
|
- session management: each terminal session is a persistent object (VM or
|
||||||
|
subprocess) tied to a player. different from stateless command dispatch.
|
||||||
|
|
||||||
|
- room-local broadcasting: spectator output is the first use of "broadcast
|
||||||
|
to players in this room." will be useful for ambient messages, weather,
|
||||||
|
room events later.
|
||||||
|
|
||||||
|
- binary blob persistence: quetzal saves are blobs in sqlite. same pattern
|
||||||
|
will apply to uploaded files, cached terrain chunks, whatever.
|
||||||
|
|
||||||
|
- game objects with behavior: terminals are room objects that do things
|
||||||
|
(spawn VMs, route I/O). pattern extends to doors, NPCs, triggers.
|
||||||
|
|
||||||
|
getting IF right means the architecture can handle editor mode, crafting
|
||||||
|
mini-games, puzzles, dialogue trees, anything with isolated state and
|
||||||
|
custom input handling.
|
||||||
|
|
||||||
|
|
||||||
|
risks and unknowns
|
||||||
|
==================
|
||||||
|
|
||||||
|
viola's lack of tests:
|
||||||
|
we'd be trusting ~10k lines of untested code. bugs in opcode implementation
|
||||||
|
would surface as "game doesn't work" with no clear cause. mitigation: test
|
||||||
|
with a suite of known games (zork, hhgg, curses, anchorhead) before
|
||||||
|
committing. if bugs emerge, consider dfrozt as the long-term solution.
|
||||||
|
|
||||||
|
async bridge overhead:
|
||||||
|
viola's blocking loop in a thread pool adds latency. run_in_executor is
|
||||||
|
designed for this but it's not zero-cost. mitigation: measure latency in
|
||||||
|
prototype. if it's perceptible (>100ms), consider writing an async-native
|
||||||
|
VM or sticking with subprocess.
|
||||||
|
|
||||||
|
global state in viola:
|
||||||
|
haven't audited zcode/ for globals yet. if it's pervasive, the refactor to
|
||||||
|
instance state could be large. mitigation: audit before committing to viola.
|
||||||
|
if globals are deeply baked in, subprocess is safer.
|
||||||
|
|
||||||
|
z-machine version support:
|
||||||
|
viola claims 1-8, mojozork does 3 only. most infocom classics are v3 or v5.
|
||||||
|
modern IF (inform 7) compiles to v8. if we only care about classics, v3 is
|
||||||
|
enough. if we want modern games, need v8. mitigation: decide target game
|
||||||
|
set before choosing VM.
|
||||||
|
|
||||||
|
spectator bandwidth:
|
||||||
|
if 10 players are watching one game, every line gets sent 11 times (player
|
||||||
|
+ 10 spectators). that's fine for text but worth monitoring. mitigation:
|
||||||
|
spectator output could be rate-limited or summarized (show every 5 lines,
|
||||||
|
not every line).
|
||||||
|
|
||||||
|
save file size:
|
||||||
|
quetzal saves are typically 20-100KB. if player saves frequently, sqlite
|
||||||
|
grows. mitigation: limit saves per player per game (10 slots?), auto-prune
|
||||||
|
old saves, or compress quetzal blobs (they're IFF, should compress well).
|
||||||
|
|
||||||
|
|
||||||
|
closing
|
||||||
|
=======
|
||||||
|
|
||||||
|
IF-in-MUD is not a detour. it's a proof that the architecture supports
|
||||||
|
isolated, stateful, player-driven content. the mode stack, room objects,
|
||||||
|
save/restore, multiplayer isolation — all tested in a concrete use case.
|
||||||
|
|
||||||
|
start with dfrozt subprocess (phase 1-4). prove the concept. if it works,
|
||||||
|
consider viola for tighter integration. if viola is risky, subprocess is
|
||||||
|
fine long-term. mojozork shows that C via subprocess is viable.
|
||||||
|
|
||||||
|
the dream: player-created IF games, built in the editor, tested in-world,
|
||||||
|
published for others. that requires a DSL and builder tools. but the
|
||||||
|
foundation is terminals + mode stack + save/restore. get that right and the
|
||||||
|
dream is achievable.
|
||||||
|
|
||||||
|
|
||||||
|
code
|
||||||
|
----
|
||||||
|
|
||||||
|
this document docs/how/if-integration.txt
|
||||||
|
dreambook DREAMBOOK.md
|
||||||
|
architecture docs/how/architecture-plan.txt
|
||||||
258
docs/how/if-journey.rst
Normal file
258
docs/how/if-journey.rst
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
if journey — from arcade terminal to moldable worlds
|
||||||
|
=====================================================
|
||||||
|
|
||||||
|
This doc tracks the IF (interactive fiction) integration effort for the MUD engine. It's a living document — updated as research progresses and decisions are made. For detailed technical analysis, see the two interpreter audits (``viola-embedding-audit.rst`` and ``zvm-embedding-audit.rst``) and the original integration notes (``if-integration.txt``).
|
||||||
|
|
||||||
|
the vision — five levels of integration
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
Five levels emerged from design discussion. They represent a spectrum of how deeply IF worlds integrate with the MUD world, from simplest to most ambitious.
|
||||||
|
|
||||||
|
Level 1 — terminal mode
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Subprocess (dfrotz), text in/out, spectators see text scrolling on a screen. Player sits at an arcade terminal, plays Zork. Others in the room see the output. The IF world is opaque — a black box.
|
||||||
|
|
||||||
|
Works today with dfrotz. Proven technology.
|
||||||
|
|
||||||
|
Level 2 — inspectable world
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Embedded interpreter. MUD can READ z-machine state. Know what room the player is in, describe it to spectators, track progress. Spectators don't just see text — they know "jared is in the Trophy Room." Read-only bridge between MUD and IF world.
|
||||||
|
|
||||||
|
Level 3 — moldable world
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Embedded interpreter. MUD can READ AND WRITE z-machine state. Inject items into IF game objects. Put a note in the Zork mailbox. The z-machine object tree (parent/child/sibling with attributes and properties) becomes accessible from MUD code. Two-way bridge. Game world is modifiable from outside.
|
||||||
|
|
||||||
|
Level 4 — shared world
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Multiple MUD players mapped into the same z-machine instance. Each has their own player object. Independent inventory and position. MojoZork's MultiZork proved this works for V3 games. The IF world is a zone in the MUD that multiple players inhabit simultaneously.
|
||||||
|
|
||||||
|
Level 5 — transcendent world
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Z-machine object tree and MUD entity model are unified. An item in Zork IS a MUD item. Pick it up in the IF world, carry it back to the MUD. The mailbox exists at coordinates in both worlds simultaneously. Full bidirectional entity bridge.
|
||||||
|
|
||||||
|
Note: Level 1 uses subprocess. Levels 2-5 require being inside the interpreter. Once you're at level 2, the jump to 3 is small (reading memory vs writing it). Level 4 is the MojoZork leap. Level 5 is the dream.
|
||||||
|
|
||||||
|
what we know — audit findings
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
Two Python z-machine interpreters were audited in detail. Don't repeat everything here — see the audit docs for details. Focus on decision-relevant facts.
|
||||||
|
|
||||||
|
viola (DFillmore/viola)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Can run games today. All V1-V5 opcodes implemented, partial V6-V8. But global state is deeply tangled (13/18 modules with mutable globals). Multiple instances in one process: not feasible without major refactor.
|
||||||
|
|
||||||
|
pygame dependency is cleanly separable (adapter pattern, unidirectional). ``sys.exit()`` in error paths (8+ locations) — needs patching for server use. Memory leaks (5+ unbounded growth patterns) — fixable with cleanup hooks.
|
||||||
|
|
||||||
|
Object tree accessors exist and are wired through all opcodes. Full quetzal save/restore working.
|
||||||
|
|
||||||
|
See: ``docs/how/viola-embedding-audit.rst``
|
||||||
|
|
||||||
|
zvm (sussman/zvm)
|
||||||
|
~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Cannot run any game. ~46/108 opcodes implemented, including ZERO input opcodes. But instance-based state (mostly clean, two minor global leaks fixable in ~20 lines). Multiple instances in one process: structurally possible.
|
||||||
|
|
||||||
|
IO abstraction is excellent — purpose-built for embedding (abstract ZUI with stubs). Proper exception hierarchy, zero ``sys.exit()`` calls. No memory leaks, clean bounded state.
|
||||||
|
|
||||||
|
Object tree parser has complete read API, mostly complete write API. BUT: many object opcodes not wired up at CPU level. Save parser works, save writer is stubbed.
|
||||||
|
|
||||||
|
See: ``docs/how/zvm-embedding-audit.rst``
|
||||||
|
|
||||||
|
The verdict from the audits: "zvm has the architecture you'd want. viola has the implementation you'd need."
|
||||||
|
|
||||||
|
MojoZork (C, Ryan C. Gordon)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
V3 only but has working multiplayer telnet server (MultiZork). Proves multiplayer z-machine works (up to 4 players, separate inventories). ``runInstruction()`` for single-step execution — the async pattern we want. Excellent reference architecture, not directly usable (C only).
|
||||||
|
|
||||||
|
the hybrid path — option D
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
This emerged from comparing the audits side by side. Use zvm's architecture (clean IO abstraction, instance-based state, exception model) as the skeleton. Port viola's working opcode implementations into it.
|
||||||
|
|
||||||
|
Why this is attractive:
|
||||||
|
|
||||||
|
- zvm's opcodes take ``self`` (ZCpu) and access state through instance attributes
|
||||||
|
- viola's opcodes use module-level globals (``zcode.game.PC``, ``zcode.memory.data``, etc)
|
||||||
|
- porting means translating global lookups to instance attribute lookups
|
||||||
|
- that's mechanical, not creative — could port 5-10 opcodes per hour once the pattern is established
|
||||||
|
- gets the clean design with the working code
|
||||||
|
|
||||||
|
Why this could be harder than it sounds:
|
||||||
|
|
||||||
|
- viola and zvm may represent z-machine internals differently
|
||||||
|
- memory layout assumptions, stack frame format, string encoding details
|
||||||
|
- porting opcodes may require porting the data structures they operate on
|
||||||
|
- need to verify each ported opcode against the z-machine spec, not just translate
|
||||||
|
|
||||||
|
Estimated effort: medium. Less than finishing zvm from scratch, less than refactoring viola's globals. But not trivial.
|
||||||
|
|
||||||
|
the object tree — key to moldable worlds
|
||||||
|
-----------------------------------------
|
||||||
|
|
||||||
|
This is what makes levels 3-5 possible. The z-machine has an object tree — every game entity is a node with parent/child/sibling pointers, attributes (boolean flags), and properties (variable-length data).
|
||||||
|
|
||||||
|
What the object tree gives us:
|
||||||
|
|
||||||
|
- Read what room the player is in (player object's parent)
|
||||||
|
- Read container contents (children of the container object)
|
||||||
|
- Inject items (create objects, parent them to containers)
|
||||||
|
- Modify game state (set/clear attributes, change properties)
|
||||||
|
- Query the dictionary (what words the parser recognizes)
|
||||||
|
|
||||||
|
Both interpreters have object tree parsers:
|
||||||
|
|
||||||
|
- viola: complete read + write, all opcodes wired, working
|
||||||
|
- zvm: complete read, mostly complete write (missing ``set_attr``/``clear_attr``), many opcodes unwired at CPU level, bug in ``insert_object``
|
||||||
|
|
||||||
|
The dictionary problem (level 3+)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Injecting an object into the mailbox works for "look in mailbox" — the game iterates children and prints short names. But "take [new item]" fails unless the word exists in the game's dictionary. The dictionary is baked into the story file.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
- Add words to dictionary at runtime (memory surgery — relocating/expanding the dictionary)
|
||||||
|
- Intercept input before the parser and handle custom items at the MUD layer
|
||||||
|
- Use existing dictionary words for injected items ("note", "scroll", "key" are common)
|
||||||
|
- Hybrid: intercept unrecognized words, check if they match MUD-injected items, handle outside z-machine
|
||||||
|
|
||||||
|
This is a level 3-5 problem. Not a blocker for levels 1-2.
|
||||||
|
|
||||||
|
games we care about
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
The games that motivated this work:
|
||||||
|
|
||||||
|
The Wizard Sniffer (Buster Hudson, 2017)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
You play a pig who sniffs out wizards. IFComp winner, XYZZY winner. Screwball comedy. The pig/wizard game that started this. Z-machine format. Would need V5 support.
|
||||||
|
|
||||||
|
Lost Pig (Admiral Jota, 2007)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Grunk the orc chases an escaped pig. IFComp winner, 4 XYZZY awards. Famous for its responsive parser and comedy writing. Z-machine format. V5.
|
||||||
|
|
||||||
|
Zork I, II, III
|
||||||
|
~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
The classics. Everyone should play them. V3.
|
||||||
|
|
||||||
|
Hitchhiker's Guide to the Galaxy
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Funny, frustrating, great spectator game. V3.
|
||||||
|
|
||||||
|
Also: Anchorhead, Photopia, Spider and Web, Shade, Colossal Cave.
|
||||||
|
|
||||||
|
V3 covers the Infocom catalog. V5 covers most modern IF including the pig games. V8 covers big modern Inform 7 games but is lower priority.
|
||||||
|
|
||||||
|
Note: no Python Glulx interpreter exists. Games that target Glulx (some modern Inform 7) are out of scope unless we subprocess to a C interpreter.
|
||||||
|
|
||||||
|
architecture fit
|
||||||
|
----------------
|
||||||
|
|
||||||
|
How this fits the existing MUD architecture. The codebase is ready:
|
||||||
|
|
||||||
|
Mode stack
|
||||||
|
~~~~~~~~~~
|
||||||
|
|
||||||
|
Push "if" mode. All input routes to IF handler, bypassing command dispatch. Same pattern as editor mode. Already proven.
|
||||||
|
|
||||||
|
Input routing
|
||||||
|
~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
``server.py`` shell loop checks ``player.mode``. Add elif for "if" mode, route to ``if_game.handle_input()``. Same as ``editor.handle_input()``.
|
||||||
|
|
||||||
|
Room-local broadcasting
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Not implemented yet. IF integration is the first use case. Terminal object maintains spectator list, broadcasts output to all. Pattern will be reused for ambient messages, weather, room events.
|
||||||
|
|
||||||
|
State storage
|
||||||
|
~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Quetzal saves as blobs in SQLite. Same pattern as other binary persistence.
|
||||||
|
|
||||||
|
Terminal game object
|
||||||
|
~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
A room object (or coordinate-anchored object) that hosts IF sessions. Players "use" it to enter IF mode. Pattern extends to other interactive objects.
|
||||||
|
|
||||||
|
open questions
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Things we haven't figured out yet. Update this as questions get answered.
|
||||||
|
|
||||||
|
1. V3 opcode footprint
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
How many of the ~62 missing zvm opcodes are actually exercised by V3 games? V3 uses a smaller subset. If we target V3 first, the hybrid might need 30 ported, not 62. Research: run a V3 game through viola with opcode tracing, collect the set.
|
||||||
|
|
||||||
|
2. zvm/viola memory layout compatibility
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Do they represent z-machine memory the same way? Both use bytearrays, but header parsing, object table offsets, string encoding — are these compatible enough that porting opcodes is translation, or is it a deeper rewrite?
|
||||||
|
|
||||||
|
3. Async model
|
||||||
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Both interpreters have blocking run loops. Options:
|
||||||
|
|
||||||
|
- ``run_in_executor`` (thread pool) — standard pattern, adds latency
|
||||||
|
- extract ``step()`` and call from async loop — zvm audit says this is ~5 lines
|
||||||
|
- run in separate thread with queue-based IO — more complex but natural
|
||||||
|
|
||||||
|
Which is best for the MUD's tick-based game loop?
|
||||||
|
|
||||||
|
4. Multiplayer z-machine
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
MojoZork does this for V3. What would it take for V5? The V5 object model is larger (65535 objects vs 255). Do V5 games assume single-player in ways that break multiplayer?
|
||||||
|
|
||||||
|
5. Game file licensing
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Infocom games are abandonware but not legally free. Modern IF games (Lost Pig, Wizard Sniffer) are freely distributable. Need to figure out what we can bundle vs what players bring.
|
||||||
|
|
||||||
|
6. Dictionary injection feasibility
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
How hard is it to add words to a z-machine dictionary at runtime? The dictionary is in static memory. Adding words means expanding it, which means relocating it if there's no space. Is this practical?
|
||||||
|
|
||||||
|
what to do next
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Concrete next steps, roughly ordered. Update as items get done.
|
||||||
|
|
||||||
|
- [ ] trace V3 opcode usage: run zork through viola with opcode logging, get the actual set of opcodes a real game uses. this tells us how much porting work the hybrid path actually requires.
|
||||||
|
|
||||||
|
- [ ] compare memory layouts: look at how viola and zvm represent z-machine memory, object tables, string tables. determine if opcode porting is mechanical translation or deeper adaptation.
|
||||||
|
|
||||||
|
- [ ] prototype the hybrid: pick 5-10 common opcodes, port them from viola to zvm's architecture. see how the pattern feels. if it's smooth, the hybrid is viable. if every opcode is a battle, reconsider.
|
||||||
|
|
||||||
|
- [ ] build level 1 prototype: regardless of interpreter choice, implement the terminal object, IF mode, and subprocess dfrotz path. this proves the MUD-side architecture (mode stack, spectators, save/restore) independently of the interpreter question.
|
||||||
|
|
||||||
|
- [ ] study MojoZork's multiplayer model: read the MultiZork source for how it handles multiple players in one z-machine. document the pattern for our eventual level 4.
|
||||||
|
|
||||||
|
- [ ] find the game files: locate freely distributable z-machine story files for the games we care about. Wizard Sniffer, Lost Pig, Zork (if legally available).
|
||||||
|
|
||||||
|
related documents
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
``docs/how/if-integration.txt`` — original research and integration plan (predates audits)
|
||||||
|
|
||||||
|
``docs/how/viola-embedding-audit.rst`` — detailed viola architecture audit
|
||||||
|
|
||||||
|
``docs/how/zvm-embedding-audit.rst`` — detailed zvm architecture audit with comparison
|
||||||
|
|
||||||
|
``docs/how/architecture-plan.txt`` — MUD engine architecture plan
|
||||||
|
|
||||||
|
``DREAMBOOK.md`` — project vision and philosophy
|
||||||
156
docs/how/viola-embedding-audit.rst
Normal file
156
docs/how/viola-embedding-audit.rst
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
=============================
|
||||||
|
viola embedding audit — Z-machine interpreter feasibility
|
||||||
|
=============================
|
||||||
|
|
||||||
|
viola is a Python Z-machine interpreter being evaluated for embedding in mudlib
|
||||||
|
to run interactive fiction games over telnet. this audit covers architecture,
|
||||||
|
isolation requirements, and modification paths.
|
||||||
|
|
||||||
|
|
||||||
|
1. global state — fork-level entangled
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
13 of 18 modules in zcode/ have mutable module-level globals.
|
||||||
|
|
||||||
|
critical three:
|
||||||
|
|
||||||
|
- game.py — PC, callstack, currentframe, undolist, interruptstack. modified by
|
||||||
|
20+ functions across multiple modules via global declarations
|
||||||
|
- memory.py — data (entire Z-machine memory as a bytearray). read/written by
|
||||||
|
nearly every module
|
||||||
|
- screen.py — currentWindow, zwindow list, color spectrum dict. all mutated at
|
||||||
|
runtime
|
||||||
|
|
||||||
|
every module does ``import zcode`` then accesses ``zcode.game.PC``,
|
||||||
|
``zcode.memory.data``, etc. hardcoded module-level lookups, not
|
||||||
|
dependency-injected. state mutation touches 1000+ function calls.
|
||||||
|
|
||||||
|
wrapping in a class would require threading a state parameter through the
|
||||||
|
entire codebase. process-level isolation (subprocess per game) is the realistic
|
||||||
|
path for concurrent games.
|
||||||
|
|
||||||
|
|
||||||
|
2. pygame boundary — clean
|
||||||
|
===========================
|
||||||
|
|
||||||
|
zero pygame imports in zcode/. architecture uses adapter pattern:
|
||||||
|
|
||||||
|
- zcode/ imports only vio.zcode as io (aliased)
|
||||||
|
- vio/zcode.py contains ALL pygame calls (1044 lines)
|
||||||
|
- dependency is unidirectional — vio.zcode never imports from zcode/
|
||||||
|
- zcode/ extends io classes via inheritance (window, font, sound channels)
|
||||||
|
|
||||||
|
can swap vio/zcode.py for a telnet IO adapter without touching VM core.
|
||||||
|
|
||||||
|
|
||||||
|
3. error paths — server-hostile
|
||||||
|
================================
|
||||||
|
|
||||||
|
8+ locations call sys.exit() directly:
|
||||||
|
|
||||||
|
- memory out-of-bounds (5 locations in memory.py)
|
||||||
|
- invalid opcode (opcodes.py)
|
||||||
|
- division by zero (numbers.py)
|
||||||
|
- corrupt story file (viola.py)
|
||||||
|
|
||||||
|
fatal path: ``zcode.error.fatal()`` -> ``sys.exit()``. not catchable exceptions.
|
||||||
|
|
||||||
|
fix: patch error.fatal() to raise a custom exception instead.
|
||||||
|
|
||||||
|
|
||||||
|
4. version coverage — V1-V5 solid, V6 partial, V7-V8 theoretical
|
||||||
|
=================================================================
|
||||||
|
|
||||||
|
- V1-V5: all opcodes implemented. most IF games are V3 or V5.
|
||||||
|
- V6: graphics framework exists but 3 opcodes marked "unfinished"
|
||||||
|
- V7-V8: supported at memory/opcode level but likely never tested
|
||||||
|
- no test suite in the repo
|
||||||
|
|
||||||
|
for a MUD, V3 and V5 cover Zork, Curses, Anchorhead, etc.
|
||||||
|
|
||||||
|
|
||||||
|
5. input model — moderate-to-hard adapter needed
|
||||||
|
=================================================
|
||||||
|
|
||||||
|
line input (READ):
|
||||||
|
easy — maps naturally to telnet line mode
|
||||||
|
|
||||||
|
single char (READ_CHAR):
|
||||||
|
hard — needs raw telnet mode or buffering
|
||||||
|
|
||||||
|
timed input:
|
||||||
|
hard — needs async server-side timer
|
||||||
|
|
||||||
|
mouse input:
|
||||||
|
impossible — stub it
|
||||||
|
|
||||||
|
arrow/function keys:
|
||||||
|
moderate — parse ANSI escape sequences
|
||||||
|
|
||||||
|
pygame is hardwired with no abstraction layer. need to create a TelnetInput
|
||||||
|
class implementing the same interface as vio.zcode.input. interface surface is
|
||||||
|
small: getinput, starttimer, stoptimer.
|
||||||
|
|
||||||
|
|
||||||
|
6. step execution mode — moderate refactor, feasible
|
||||||
|
=====================================================
|
||||||
|
|
||||||
|
main loop in routines.py:60 is clean::
|
||||||
|
|
||||||
|
while not quit and not restart:
|
||||||
|
check interrupts -> decode(PC) -> runops(oldpc)
|
||||||
|
|
||||||
|
one instruction = decode + execute. only blocking call is pygame.event.wait()
|
||||||
|
at bottom of input stack. natural yield points are input requests.
|
||||||
|
|
||||||
|
execute_one() API achievable by:
|
||||||
|
|
||||||
|
1. making IO non-blocking
|
||||||
|
2. converting z_read()'s inner while loop to a state machine
|
||||||
|
3. unwrapping recursive interrupt_call() -> execloop() pattern
|
||||||
|
|
||||||
|
|
||||||
|
7. memory/cleanup — will leak, fixable
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
unbounded growth:
|
||||||
|
|
||||||
|
- undo stack (game.py:23) — each undo saves full memory copy (MBs). never
|
||||||
|
cleared.
|
||||||
|
- command history (input.py:22) — every input appended forever
|
||||||
|
- static routine cache (routines.py:30) — never cleared
|
||||||
|
- static word cache (memory.py:108) — never cleared
|
||||||
|
- object caches (objects.py:18-26) — 4 dicts, never purged
|
||||||
|
|
||||||
|
estimate: 10-100+ MB/hour depending on undo usage. fixable by adding cleanup
|
||||||
|
calls to restart/setup path.
|
||||||
|
|
||||||
|
|
||||||
|
8. IFIFF submodule — quetzal is independent
|
||||||
|
============================================
|
||||||
|
|
||||||
|
- quetzal (saves) and blorb (multimedia) are architecturally separate
|
||||||
|
- zcode/game.py imports only quetzal — no blorb references in save/restore
|
||||||
|
- quetzal API: qdata(), save(), restore()
|
||||||
|
- pure stdlib, no external deps
|
||||||
|
- can cherry-pick quetzal.py without pulling in blorb
|
||||||
|
|
||||||
|
|
||||||
|
priority for upstream PRs
|
||||||
|
==========================
|
||||||
|
|
||||||
|
1. patch error.fatal() to raise instead of sys.exit() — small effort, unblocks
|
||||||
|
server use
|
||||||
|
2. add cleanup hooks for caches/undo on restart — small effort, fixes memory
|
||||||
|
leaks
|
||||||
|
3. create IO abstraction interface — medium effort, enables telnet adapter
|
||||||
|
4. step execution mode (execute_one()) — medium effort, enables async embedding
|
||||||
|
5. extract quetzal as standalone — small effort, clean dependency
|
||||||
|
|
||||||
|
|
||||||
|
pragmatic path
|
||||||
|
==============
|
||||||
|
|
||||||
|
the global state issue is bypassed with subprocess isolation (one Python
|
||||||
|
process per game). fine for a MUD with less than 100 concurrent players.
|
||||||
|
process-per-game sidesteps the entire global state refactor.
|
||||||
377
docs/how/zvm-embedding-audit.rst
Normal file
377
docs/how/zvm-embedding-audit.rst
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
=============================
|
||||||
|
zvm embedding audit — Z-machine interpreter feasibility
|
||||||
|
=============================
|
||||||
|
|
||||||
|
zvm is a Python Z-machine interpreter (by Ben Collins-Sussman) being evaluated
|
||||||
|
for embedding in mudlib to run interactive fiction games over telnet. this audit
|
||||||
|
covers architecture, isolation requirements, and modification paths. compared
|
||||||
|
against the viola audit for apples-to-apples decision-making.
|
||||||
|
|
||||||
|
|
||||||
|
1. global state — mostly clean, two leaks
|
||||||
|
==========================================
|
||||||
|
|
||||||
|
state is instance-based. ``ZMachine.__init__`` wires everything together::
|
||||||
|
|
||||||
|
ZMemory(story) -- memory
|
||||||
|
ZStringFactory(mem) -- string decoding
|
||||||
|
ZObjectParser(mem) -- object tree
|
||||||
|
ZStackManager(mem) -- call/data stacks
|
||||||
|
ZOpDecoder(mem, stack) -- instruction decoder
|
||||||
|
ZStreamManager(mem, ui) -- I/O streams
|
||||||
|
ZCpu(mem, opdecoder, stack, objects, string, streams, ui) -- CPU
|
||||||
|
|
||||||
|
dependency graph flows one way: ZCpu depends on everything else, everything
|
||||||
|
else depends on ZMemory. no circular dependencies. clean layering.
|
||||||
|
|
||||||
|
two global leaks:
|
||||||
|
|
||||||
|
- zlogging.py — executes at import time. opens debug.log and disasm.log in cwd,
|
||||||
|
sets root logger to DEBUG. all instances share the same loggers
|
||||||
|
- zcpu.py uses ``random.seed()`` / ``random.randint()`` — global PRNG state.
|
||||||
|
multiple instances would interfere with each other's randomness
|
||||||
|
|
||||||
|
both fixable with ~20 lines. the logging can be made instance-scoped, the PRNG
|
||||||
|
replaced with ``random.Random()`` instances.
|
||||||
|
|
||||||
|
compared to viola's 13/18 modules with mutable globals, this is dramatically
|
||||||
|
better. multiple ZMachine instances in one process is structurally possible.
|
||||||
|
|
||||||
|
|
||||||
|
2. IO boundary — excellent, purpose-built for embedding
|
||||||
|
========================================================
|
||||||
|
|
||||||
|
this is zvm's strongest feature. the README explicitly states the design goal:
|
||||||
|
"no user interface. meant to be used as the backend in other programs."
|
||||||
|
|
||||||
|
IO is fully abstracted via a four-component ``ZUI`` object (zui.py)::
|
||||||
|
|
||||||
|
ZUI(audio, screen, keyboard_input, filesystem)
|
||||||
|
|
||||||
|
each component is an abstract base class with NotImplementedError stubs:
|
||||||
|
|
||||||
|
- ZScreen (zscreen.py) — write(), split_window(), select_window(),
|
||||||
|
set_cursor_position(), erase_window(), set_text_style(), set_text_color()
|
||||||
|
- ZInputStream (zstream.py) — read_line(), read_char() with full signatures
|
||||||
|
for timed input, max length, terminating characters
|
||||||
|
- ZFilesystem (zfilesystem.py) — save_game(), restore_game(),
|
||||||
|
open_transcript_file_for_writing/reading()
|
||||||
|
- ZAudio (zaudio.py) — play_bleep(), play_sound_effect()
|
||||||
|
|
||||||
|
trivialzui.py is the reference stdio implementation showing how to subclass.
|
||||||
|
|
||||||
|
for MUD embedding: implement ZScreen.write() to push to telnet session,
|
||||||
|
ZInputStream.read_line() to receive from telnet reader, ZFilesystem to store
|
||||||
|
saves in SQLite. natural fit.
|
||||||
|
|
||||||
|
compared to viola where pygame is hardwired and you'd need to create a
|
||||||
|
TelnetInput class from scratch, zvm hands you the interface contract.
|
||||||
|
|
||||||
|
|
||||||
|
3. error paths — server-friendly
|
||||||
|
=================================
|
||||||
|
|
||||||
|
zero sys.exit() calls in the zvm/ package. only sys.exit() is in the CLI
|
||||||
|
runner run_story.py, which is appropriate.
|
||||||
|
|
||||||
|
clean exception hierarchy:
|
||||||
|
|
||||||
|
- ZMachineError (zmachine.py)
|
||||||
|
- ZCpuError -> ZCpuIllegalInstruction, ZCpuDivideByZero, ZCpuNotImplemented
|
||||||
|
- ZMemoryError -> ZMemoryIllegalWrite, ZMemoryOutOfBounds, ZMemoryBadMemoryLayout
|
||||||
|
- ZObjectError -> ZObjectIllegalObjectNumber, ZObjectIllegalAttributeNumber
|
||||||
|
- ZStackError -> ZStackNoRoutine, ZStackNoSuchVariable, ZStackPopError
|
||||||
|
- QuetzalError -> QuetzalMalformedChunk, QuetzalMismatchedFile
|
||||||
|
|
||||||
|
CPU run loop does not catch exceptions — errors propagate to the caller.
|
||||||
|
correct behavior for embedding.
|
||||||
|
|
||||||
|
one weakness: some opcodes have bare ``assert`` statements that would be
|
||||||
|
stripped with ``python -O``.
|
||||||
|
|
||||||
|
compared to viola's 8+ sys.exit() calls via error.fatal(), this is exactly
|
||||||
|
what you want for a server.
|
||||||
|
|
||||||
|
|
||||||
|
4. version coverage — V1-V5 declared, V3-V5 structural
|
||||||
|
========================================================
|
||||||
|
|
||||||
|
zmemory.py handles v1-v5 with version-switched code throughout. v6-v8 are
|
||||||
|
not supported (raises ZMemoryUnsupportedVersion).
|
||||||
|
|
||||||
|
opcode table (zcpu.py) has version annotations like ``(op_call_2s, 4)``
|
||||||
|
meaning "available from v4 onward". systematic and well-done.
|
||||||
|
|
||||||
|
v1-v3 object model uses 1-byte pointers (max 255 objects), v4-v5 uses
|
||||||
|
2-byte (max 65535). properly handled in zobjectparser.py.
|
||||||
|
|
||||||
|
test suite only uses curses.z5 — v1-v3 support exists structurally but has
|
||||||
|
untested code paths.
|
||||||
|
|
||||||
|
compared to viola's solid V1-V5 plus partial V6 and theoretical V7-V8,
|
||||||
|
zvm is narrower but the architecture is cleaner where it does exist.
|
||||||
|
|
||||||
|
|
||||||
|
5. input model — abstracted but unimplemented
|
||||||
|
===============================================
|
||||||
|
|
||||||
|
line input (read_line):
|
||||||
|
interface complete — full signature with original_text, max_length,
|
||||||
|
terminating_characters, timed_input_routine, timed_input_interval.
|
||||||
|
trivial implementation handles basic line editing, ignores timed input.
|
||||||
|
|
||||||
|
single char (read_char):
|
||||||
|
interface complete — signature includes timed_input_routine and
|
||||||
|
timed_input_interval. CPU explicitly raises ZCpuNotImplemented if
|
||||||
|
time/routine are nonzero.
|
||||||
|
|
||||||
|
timed input:
|
||||||
|
feature flag system exists (``features["has_timed_input"] = False``).
|
||||||
|
completely unimplemented at CPU level.
|
||||||
|
|
||||||
|
**critical problem**: op_sread (v1-v3) has an empty body — silently does
|
||||||
|
nothing. op_sread_v4 and op_aread (v5) both raise ZCpuNotImplemented.
|
||||||
|
the interpreter cannot accept text input. it cannot reach the first prompt
|
||||||
|
of any game.
|
||||||
|
|
||||||
|
compared to viola where input works and needs an adapter, zvm has the
|
||||||
|
better interface design but no working implementation behind it.
|
||||||
|
|
||||||
|
|
||||||
|
6. step execution mode — not available, easy refactor
|
||||||
|
======================================================
|
||||||
|
|
||||||
|
run loop in zcpu.py::
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while True:
|
||||||
|
(opcode_class, opcode_number, operands) = self._opdecoder.get_next_instruction()
|
||||||
|
implemented, func = self._get_handler(opcode_class, opcode_number)
|
||||||
|
if not implemented:
|
||||||
|
break
|
||||||
|
func(self, *operands)
|
||||||
|
|
||||||
|
tight while-True with no yielding. no step() method, no async support, no
|
||||||
|
callbacks between instructions, no instruction count limit.
|
||||||
|
|
||||||
|
the loop body is clean and self-contained though. extracting step() is a ~5
|
||||||
|
line change — pull the body into a method, call it from run().
|
||||||
|
|
||||||
|
for async MUD embedding, options are:
|
||||||
|
|
||||||
|
1. extract step() and call from an async loop with awaits at IO points
|
||||||
|
2. run each ZMachine in a thread (viable since state is instance-based)
|
||||||
|
|
||||||
|
compared to viola's similar tight loop, the refactor here is actually easier
|
||||||
|
because the body is simpler (no interrupt handling, no recursive execloop).
|
||||||
|
|
||||||
|
|
||||||
|
7. memory/cleanup — clean, no leak risk
|
||||||
|
=========================================
|
||||||
|
|
||||||
|
memory is a bytearray, fixed size, bounded by story file.
|
||||||
|
ZMachine.__init__ creates two copies (pristine + working).
|
||||||
|
|
||||||
|
ZStackManager uses a Python list as call stack. frames are popped in
|
||||||
|
finish_routine(). bounded by Z-machine stack design.
|
||||||
|
|
||||||
|
ZStringFactory, ZCharTranslator, ZLexer all pre-load from story file at init.
|
||||||
|
bounded by file content.
|
||||||
|
|
||||||
|
ZObjectParser does not cache anything — every access reads directly from
|
||||||
|
ZMemory.
|
||||||
|
|
||||||
|
one minor leak: quetzal.py QuetzalParser stores self._file and closes it
|
||||||
|
manually but does not use a context manager. exception during parsing would
|
||||||
|
leak the file handle.
|
||||||
|
|
||||||
|
compared to viola's 5+ unbounded growth patterns (undo stack, command history,
|
||||||
|
routine cache, word cache, object caches), zvm is dramatically cleaner.
|
||||||
|
|
||||||
|
|
||||||
|
8. object tree API — complete read, mostly complete write
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
zobjectparser.py public API:
|
||||||
|
|
||||||
|
read:
|
||||||
|
- get_attribute(objectnum, attrnum) — single attribute (0/1)
|
||||||
|
- get_all_attributes(objectnum) — list of all set attribute numbers
|
||||||
|
- get_parent(objectnum) — parent object number
|
||||||
|
- get_child(objectnum) — first child
|
||||||
|
- get_sibling(objectnum) — next sibling
|
||||||
|
- get_shortname(objectnum) — object's short name as string
|
||||||
|
- get_prop(objectnum, propnum) — property value
|
||||||
|
- get_prop_addr_len(objectnum, propnum) — property address and length
|
||||||
|
- get_all_properties(objectnum) — dict of all properties
|
||||||
|
- describe_object(objectnum) — debug pretty-printer
|
||||||
|
|
||||||
|
write:
|
||||||
|
- set_parent(objectnum, new_parent_num)
|
||||||
|
- set_child(objectnum, new_child_num)
|
||||||
|
- set_sibling(objectnum, new_sibling_num)
|
||||||
|
- insert_object(parent_object, new_child) — handles unlinking
|
||||||
|
- set_property(objectnum, propnum, value)
|
||||||
|
|
||||||
|
missing: set_attribute() and clear_attribute(). the parser can read attributes
|
||||||
|
but cannot set them. would need to be added.
|
||||||
|
|
||||||
|
bug in insert_object(): sibling walk loop at line 273 never advances current
|
||||||
|
or prev — would infinite-loop. needs fix.
|
||||||
|
|
||||||
|
**critical for MUD embedding**: many CPU opcodes that USE the parser are
|
||||||
|
unimplemented at the CPU level:
|
||||||
|
|
||||||
|
- op_test_attr, op_set_attr, op_clear_attr — not wired up
|
||||||
|
- op_get_sibling — not wired up (parser method exists)
|
||||||
|
- op_jin (test parent) — not wired up
|
||||||
|
- op_remove_obj, op_print_obj — not wired up
|
||||||
|
- op_get_prop_addr, op_get_next_prop, op_get_prop_len — not wired up
|
||||||
|
|
||||||
|
the parser infrastructure is there and correct. the CPU just doesn't call it.
|
||||||
|
|
||||||
|
compared to viola's zcode/objects.py which has working accessors wired through
|
||||||
|
all opcodes, zvm has a better parser design but the plumbing is incomplete.
|
||||||
|
|
||||||
|
|
||||||
|
9. save/restore — parse works, write is stubbed
|
||||||
|
=================================================
|
||||||
|
|
||||||
|
quetzal.py QuetzalParser can parse Quetzal save files:
|
||||||
|
- IFhd chunks (metadata) — working
|
||||||
|
- CMem chunks (compressed memory) — working
|
||||||
|
- UMem chunks (uncompressed memory) — has a bug (wrong attribute name)
|
||||||
|
- Stks chunks (stack frames) — working
|
||||||
|
|
||||||
|
QuetzalWriter is almost entirely stubbed. all three data methods return "0".
|
||||||
|
file writing logic exists but writes nonsense.
|
||||||
|
|
||||||
|
all save/restore CPU opcodes (op_save, op_restore, op_save_v4, op_restore_v4,
|
||||||
|
op_save_v5, op_restore_v5, op_save_undo, op_restore_undo) raise
|
||||||
|
ZCpuNotImplemented.
|
||||||
|
|
||||||
|
alternative for MUD: since ZMemory is a bytearray, could snapshot/restore
|
||||||
|
raw memory + stack state directly without Quetzal format.
|
||||||
|
|
||||||
|
compared to viola's working quetzal implementation, zvm's save system needs
|
||||||
|
significant work.
|
||||||
|
|
||||||
|
|
||||||
|
10. test suite — minimal
|
||||||
|
=========================
|
||||||
|
|
||||||
|
5 registered test modules:
|
||||||
|
|
||||||
|
- bitfield_tests.py — 6 tests, BitField bit manipulation
|
||||||
|
- zscii_tests.py — 4 tests, string encoding/decoding
|
||||||
|
- lexer_tests.py — 3 tests, dictionary parsing
|
||||||
|
- quetzal_tests.py — 2 tests, save file parsing
|
||||||
|
- glk_tests.py — 7 tests, requires compiled CheapGlk .so
|
||||||
|
|
||||||
|
not tested at all: ZCpu (no opcode tests), ZMemory, ZObjectParser,
|
||||||
|
ZStackManager, ZOpDecoder, ZStreamManager.
|
||||||
|
|
||||||
|
all tests that need a story file use stories/curses.z5 with hardcoded paths.
|
||||||
|
|
||||||
|
compared to viola which has no tests at all, zvm has some but they don't
|
||||||
|
cover the critical subsystems.
|
||||||
|
|
||||||
|
|
||||||
|
11. dependencies — zero
|
||||||
|
========================
|
||||||
|
|
||||||
|
pure stdlib. setup.py declares no dependencies. python >= 3.6.
|
||||||
|
|
||||||
|
uses: logging, random, time, itertools, re, chunk, os, sys.
|
||||||
|
ctypes only for optional Glk native integration.
|
||||||
|
|
||||||
|
both viola and zvm are pure stdlib. no advantage either way.
|
||||||
|
|
||||||
|
|
||||||
|
12. completeness — the dealbreaker
|
||||||
|
====================================
|
||||||
|
|
||||||
|
of ~108 Z-machine opcodes, zvm implements approximately 46. the remaining ~62
|
||||||
|
are stubbed with ZCpuNotImplemented or have empty bodies.
|
||||||
|
|
||||||
|
unimplemented opcodes include fundamentals:
|
||||||
|
|
||||||
|
- ALL input opcodes (op_sread, op_aread) — cannot accept player input
|
||||||
|
- ALL save/restore opcodes — cannot save or load games
|
||||||
|
- critical object opcodes (test_attr, set_attr, clear_attr, get_sibling,
|
||||||
|
remove_obj, print_obj at CPU level)
|
||||||
|
- many branch/comparison ops
|
||||||
|
- string printing variants
|
||||||
|
|
||||||
|
the interpreter cannot execute any real interactive fiction game to
|
||||||
|
completion. it cannot reach the first prompt of Zork.
|
||||||
|
|
||||||
|
|
||||||
|
verdict — comparison with viola
|
||||||
|
================================
|
||||||
|
|
||||||
|
+---------------------+--------------+----------------+
|
||||||
|
| criterion | zvm | viola |
|
||||||
|
+---------------------+--------------+----------------+
|
||||||
|
| IO abstraction | excellent | needs adapter |
|
||||||
|
| global state | mostly clean | deeply tangled |
|
||||||
|
| multi-instance | structurally | process-only |
|
||||||
|
| error handling | exceptions | sys.exit() |
|
||||||
|
| memory leaks | none | 5+ patterns |
|
||||||
|
| object tree parser | complete | complete |
|
||||||
|
| object tree opcodes | ~half wired | all wired |
|
||||||
|
| opcode coverage | ~46/108 | all V1-V5 |
|
||||||
|
| can run a game | NO | YES |
|
||||||
|
| input handling | abstracted | working |
|
||||||
|
| save/restore | parse only | working |
|
||||||
|
| dependencies | zero | zero |
|
||||||
|
| tests | minimal | none |
|
||||||
|
| maintenance | abandoned | abandoned |
|
||||||
|
+---------------------+--------------+----------------+
|
||||||
|
|
||||||
|
zvm has the architecture you'd want. viola has the implementation you'd need.
|
||||||
|
|
||||||
|
zvm is a well-designed skeleton — clean IO abstraction, instance-based state,
|
||||||
|
proper exceptions, no memory leaks. but it's roughly half-built. finishing the
|
||||||
|
62 missing opcodes is weeks of work equivalent to writing a new interpreter,
|
||||||
|
except you're also debugging someone else's partial implementation.
|
||||||
|
|
||||||
|
viola is a working interpreter with terrible architecture for embedding —
|
||||||
|
global state everywhere, sys.exit() in error paths, pygame hardwired. but it
|
||||||
|
can run Zork right now. the refactoring targets are known and bounded.
|
||||||
|
|
||||||
|
|
||||||
|
pragmatic path
|
||||||
|
===============
|
||||||
|
|
||||||
|
the "moldable world" vision (levels 3-5 from the design discussion) requires
|
||||||
|
being inside the interpreter with access to the object tree. both interpreters
|
||||||
|
have the parser infrastructure for this.
|
||||||
|
|
||||||
|
option A — fix viola's embedding problems:
|
||||||
|
1. patch error.fatal() to raise (small)
|
||||||
|
2. swap pygame IO for telnet adapter (medium)
|
||||||
|
3. add cleanup hooks for caches (small)
|
||||||
|
4. subprocess isolation handles global state (free)
|
||||||
|
total: working IF in a MUD, with known limitations on multi-instance
|
||||||
|
|
||||||
|
option B — finish zvm's implementation:
|
||||||
|
1. implement ~62 missing opcodes (large)
|
||||||
|
2. fix insert_object bug (small)
|
||||||
|
3. add set_attribute/clear_attribute to parser (small)
|
||||||
|
4. complete save writer (medium)
|
||||||
|
total: clean embeddable interpreter, but weeks of opcode work first
|
||||||
|
|
||||||
|
option C — write our own interpreter:
|
||||||
|
designed for embedding from day one. state is a first-class object. object
|
||||||
|
tree is an API. multiple games in one process. but it's the longest path and
|
||||||
|
testing against real games is the hard part.
|
||||||
|
|
||||||
|
option D — hybrid:
|
||||||
|
use zvm's architecture (ZUI interface, exception model, instance-based state)
|
||||||
|
as the skeleton. port viola's working opcode implementations into it. gets
|
||||||
|
the clean design with the working code. medium effort, high reward.
|
||||||
|
|
||||||
|
the hybrid path is probably the most interesting. zvm got the hard design
|
||||||
|
decisions right. viola got the hard implementation work done. merging the two
|
||||||
|
is less work than either finishing zvm or refactoring viola.
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from mudlib.combat.encounter import CombatState
|
from mudlib.combat.encounter import CombatState
|
||||||
from mudlib.combat.engine import get_encounter, start_encounter
|
from mudlib.combat.engine import get_encounter, start_encounter
|
||||||
|
|
@ -14,6 +15,9 @@ from mudlib.player import Player, players
|
||||||
combat_moves: dict[str, CombatMove] = {}
|
combat_moves: dict[str, CombatMove] = {}
|
||||||
combat_content_dir: Path | None = None
|
combat_content_dir: Path | None = None
|
||||||
|
|
||||||
|
# World instance will be injected by the server
|
||||||
|
world: Any = None
|
||||||
|
|
||||||
|
|
||||||
async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||||
"""Core attack logic with a resolved move.
|
"""Core attack logic with a resolved move.
|
||||||
|
|
@ -30,6 +34,10 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||||
target_name = target_args.strip()
|
target_name = target_args.strip()
|
||||||
if encounter is None and target_name:
|
if encounter is None and target_name:
|
||||||
target = players.get(target_name)
|
target = players.get(target_name)
|
||||||
|
if target is None and world is not None:
|
||||||
|
from mudlib.mobs import get_nearby_mob
|
||||||
|
|
||||||
|
target = get_nearby_mob(target_name, player.x, player.y, world)
|
||||||
|
|
||||||
# Check stamina
|
# Check stamina
|
||||||
if player.stamina < move.stamina_cost:
|
if player.stamina < move.stamina_cost:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
|
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
|
||||||
from mudlib.entity import Entity
|
from mudlib.entity import Entity, Mob
|
||||||
|
|
||||||
# Global list of active combat encounters
|
# Global list of active combat encounters
|
||||||
active_encounters: list[CombatEncounter] = []
|
active_encounters: list[CombatEncounter] = []
|
||||||
|
|
@ -101,6 +101,25 @@ async def process_combat() -> None:
|
||||||
await encounter.defender.send(result.defender_msg + "\r\n")
|
await encounter.defender.send(result.defender_msg + "\r\n")
|
||||||
|
|
||||||
if result.combat_ended:
|
if result.combat_ended:
|
||||||
|
# Determine winner/loser
|
||||||
|
if encounter.defender.pl <= 0:
|
||||||
|
loser = encounter.defender
|
||||||
|
winner = encounter.attacker
|
||||||
|
else:
|
||||||
|
loser = encounter.attacker
|
||||||
|
winner = encounter.defender
|
||||||
|
|
||||||
|
# Despawn mob losers, send victory/defeat messages
|
||||||
|
if isinstance(loser, Mob):
|
||||||
|
from mudlib.mobs import despawn_mob
|
||||||
|
|
||||||
|
despawn_mob(loser)
|
||||||
|
await winner.send(f"You have defeated the {loser.name}!\r\n")
|
||||||
|
elif isinstance(winner, Mob):
|
||||||
|
await loser.send(
|
||||||
|
f"You have been defeated by the {winner.name}!\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
# Pop combat mode from both entities if they're Players
|
# Pop combat mode from both entities if they're Players
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from typing import Any
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.effects import get_effects_at
|
from mudlib.effects import get_effects_at
|
||||||
|
from mudlib.mobs import mobs
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.render.ansi import RESET, colorize_terrain
|
from mudlib.render.ansi import RESET, colorize_terrain
|
||||||
|
|
||||||
|
|
@ -53,8 +54,30 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
||||||
other_player_positions.append((rel_x, rel_y))
|
other_player_positions.append((rel_x, rel_y))
|
||||||
|
|
||||||
|
# Build a list of (relative_x, relative_y) for alive mobs
|
||||||
|
mob_positions = []
|
||||||
|
for mob in mobs:
|
||||||
|
if not mob.alive:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dx = mob.x - player.x
|
||||||
|
dy = mob.y - player.y
|
||||||
|
if dx > world.width // 2:
|
||||||
|
dx -= world.width
|
||||||
|
elif dx < -(world.width // 2):
|
||||||
|
dx += world.width
|
||||||
|
if dy > world.height // 2:
|
||||||
|
dy -= world.height
|
||||||
|
elif dy < -(world.height // 2):
|
||||||
|
dy += world.height
|
||||||
|
rel_x = dx + center_x
|
||||||
|
rel_y = dy + center_y
|
||||||
|
|
||||||
|
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
||||||
|
mob_positions.append((rel_x, rel_y))
|
||||||
|
|
||||||
# Build the output with ANSI coloring
|
# Build the output with ANSI coloring
|
||||||
# priority: player @ > other players * > effects > terrain
|
# priority: player @ > other players * > mobs * > effects > terrain
|
||||||
half_width = VIEWPORT_WIDTH // 2
|
half_width = VIEWPORT_WIDTH // 2
|
||||||
half_height = VIEWPORT_HEIGHT // 2
|
half_height = VIEWPORT_HEIGHT // 2
|
||||||
|
|
||||||
|
|
@ -66,7 +89,7 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
if x == center_x and y == center_y:
|
if x == center_x and y == center_y:
|
||||||
line.append(colorize_terrain("@", player.color_depth))
|
line.append(colorize_terrain("@", player.color_depth))
|
||||||
# Check if this is another player's position
|
# Check if this is another player's position
|
||||||
elif (x, y) in other_player_positions:
|
elif (x, y) in other_player_positions or (x, y) in mob_positions:
|
||||||
line.append(colorize_terrain("*", player.color_depth))
|
line.append(colorize_terrain("*", player.color_depth))
|
||||||
else:
|
else:
|
||||||
# Check for active effects at this world position
|
# Check for active effects at this world position
|
||||||
|
|
|
||||||
24
src/mudlib/commands/spawn.py
Normal file
24
src/mudlib/commands/spawn.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Spawn command for creating mobs."""
|
||||||
|
|
||||||
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
from mudlib.mobs import mob_templates, spawn_mob
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_spawn(player: Player, args: str) -> None:
|
||||||
|
"""Spawn a mob at the player's current position."""
|
||||||
|
name = args.strip().lower()
|
||||||
|
if not name:
|
||||||
|
await player.send("Usage: spawn <mob_type>\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
if name not in mob_templates:
|
||||||
|
available = ", ".join(sorted(mob_templates.keys()))
|
||||||
|
await player.send(f"Unknown mob type: {name}\r\nAvailable: {available}\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
mob = spawn_mob(mob_templates[name], player.x, player.y)
|
||||||
|
await player.send(f"A {mob.name} appears!\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
register(CommandDefinition("spawn", cmd_spawn, aliases=[], mode="normal"))
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Base entity class for characters in the world."""
|
"""Base entity class for characters in the world."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -28,3 +28,5 @@ class Mob(Entity):
|
||||||
|
|
||||||
description: str = ""
|
description: str = ""
|
||||||
alive: bool = True
|
alive: bool = True
|
||||||
|
moves: list[str] = field(default_factory=list)
|
||||||
|
next_action_at: float = 0.0
|
||||||
|
|
|
||||||
114
src/mudlib/mob_ai.py
Normal file
114
src/mudlib/mob_ai.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Mob AI — game loop processor for mob combat decisions."""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
|
from mudlib.combat.encounter import CombatState
|
||||||
|
from mudlib.combat.engine import get_encounter
|
||||||
|
from mudlib.combat.moves import CombatMove
|
||||||
|
from mudlib.mobs import mobs
|
||||||
|
|
||||||
|
# Seconds between mob actions (gives player time to read and react)
|
||||||
|
MOB_ACTION_COOLDOWN = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
async def process_mobs(combat_moves: dict[str, CombatMove]) -> None:
|
||||||
|
"""Called once per game loop tick. Handles mob combat decisions."""
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
for mob in mobs[:]: # copy list in case of modification
|
||||||
|
if not mob.alive:
|
||||||
|
continue
|
||||||
|
|
||||||
|
encounter = get_encounter(mob)
|
||||||
|
if encounter is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if now < mob.next_action_at:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine if mob is attacker or defender in this encounter
|
||||||
|
mob_is_defender = encounter.defender is mob
|
||||||
|
|
||||||
|
# Defense AI: react during TELEGRAPH or WINDOW when mob is defender
|
||||||
|
if mob_is_defender and encounter.state in (
|
||||||
|
CombatState.TELEGRAPH,
|
||||||
|
CombatState.WINDOW,
|
||||||
|
):
|
||||||
|
_try_defend(mob, encounter, combat_moves, now)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Attack AI: act when encounter is IDLE
|
||||||
|
if encounter.state == CombatState.IDLE:
|
||||||
|
_try_attack(mob, encounter, combat_moves, now)
|
||||||
|
|
||||||
|
|
||||||
|
def _try_attack(mob, encounter, combat_moves, now):
|
||||||
|
"""Attempt to pick and execute an attack move."""
|
||||||
|
# Filter to affordable attack moves from mob's move list
|
||||||
|
affordable = []
|
||||||
|
for move_name in mob.moves:
|
||||||
|
move = combat_moves.get(move_name)
|
||||||
|
if (
|
||||||
|
move is not None
|
||||||
|
and move.move_type == "attack"
|
||||||
|
and mob.stamina >= move.stamina_cost
|
||||||
|
):
|
||||||
|
affordable.append(move)
|
||||||
|
|
||||||
|
if not affordable:
|
||||||
|
return
|
||||||
|
|
||||||
|
move = random.choice(affordable)
|
||||||
|
|
||||||
|
# Swap roles if mob is currently the defender
|
||||||
|
if encounter.defender is mob:
|
||||||
|
encounter.attacker, encounter.defender = (
|
||||||
|
encounter.defender,
|
||||||
|
encounter.attacker,
|
||||||
|
)
|
||||||
|
|
||||||
|
encounter.attack(move)
|
||||||
|
mob.next_action_at = now + MOB_ACTION_COOLDOWN
|
||||||
|
|
||||||
|
# Send telegraph to the player (the other participant)
|
||||||
|
# This is fire-and-forget since mob.send is a no-op
|
||||||
|
|
||||||
|
|
||||||
|
def _try_defend(mob, encounter, combat_moves, now):
|
||||||
|
"""Attempt to pick and queue a defense move."""
|
||||||
|
# Don't double-defend
|
||||||
|
if encounter.pending_defense is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Filter to affordable defense moves from mob's move list
|
||||||
|
affordable_defenses = []
|
||||||
|
for move_name in mob.moves:
|
||||||
|
move = combat_moves.get(move_name)
|
||||||
|
if (
|
||||||
|
move is not None
|
||||||
|
and move.move_type == "defense"
|
||||||
|
and mob.stamina >= move.stamina_cost
|
||||||
|
):
|
||||||
|
affordable_defenses.append(move)
|
||||||
|
|
||||||
|
if not affordable_defenses:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 40% chance to pick a correct counter, 60% random
|
||||||
|
chosen = None
|
||||||
|
current_move = encounter.current_move
|
||||||
|
if current_move and random.random() < 0.4:
|
||||||
|
# Try to find a correct counter
|
||||||
|
counters = []
|
||||||
|
for defense in affordable_defenses:
|
||||||
|
if defense.name in current_move.countered_by:
|
||||||
|
counters.append(defense)
|
||||||
|
if counters:
|
||||||
|
chosen = random.choice(counters)
|
||||||
|
|
||||||
|
if chosen is None:
|
||||||
|
chosen = random.choice(affordable_defenses)
|
||||||
|
|
||||||
|
encounter.defend(chosen)
|
||||||
|
mob.stamina -= chosen.stamina_cost
|
||||||
99
src/mudlib/mobs.py
Normal file
99
src/mudlib/mobs.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""Mob template loading, global registry, and spawn/despawn/query."""
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MobTemplate:
|
||||||
|
"""Definition loaded from TOML — used to spawn Mob instances."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
pl: float
|
||||||
|
stamina: float
|
||||||
|
max_stamina: float
|
||||||
|
moves: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level registries
|
||||||
|
mob_templates: dict[str, MobTemplate] = {}
|
||||||
|
mobs: list[Mob] = []
|
||||||
|
|
||||||
|
|
||||||
|
def load_mob_template(path: Path) -> MobTemplate:
|
||||||
|
"""Parse a mob TOML file into a MobTemplate."""
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
return MobTemplate(
|
||||||
|
name=data["name"],
|
||||||
|
description=data["description"],
|
||||||
|
pl=data["pl"],
|
||||||
|
stamina=data["stamina"],
|
||||||
|
max_stamina=data["max_stamina"],
|
||||||
|
moves=data.get("moves", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
|
||||||
|
"""Load all .toml files in a directory into a dict keyed by name."""
|
||||||
|
templates: dict[str, MobTemplate] = {}
|
||||||
|
for path in sorted(directory.glob("*.toml")):
|
||||||
|
template = load_mob_template(path)
|
||||||
|
templates[template.name] = template
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_mob(template: MobTemplate, x: int, y: int) -> Mob:
|
||||||
|
"""Create a Mob instance from a template at the given position."""
|
||||||
|
mob = Mob(
|
||||||
|
name=template.name,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
pl=template.pl,
|
||||||
|
stamina=template.stamina,
|
||||||
|
max_stamina=template.max_stamina,
|
||||||
|
description=template.description,
|
||||||
|
moves=list(template.moves),
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
return mob
|
||||||
|
|
||||||
|
|
||||||
|
def despawn_mob(mob: Mob) -> None:
|
||||||
|
"""Remove a mob from the registry and mark it dead."""
|
||||||
|
mob.alive = False
|
||||||
|
if mob in mobs:
|
||||||
|
mobs.remove(mob)
|
||||||
|
|
||||||
|
|
||||||
|
def get_nearby_mob(
|
||||||
|
name: str, x: int, y: int, world: Any, range_: int = 10
|
||||||
|
) -> Mob | None:
|
||||||
|
"""Find the closest alive mob matching name within range.
|
||||||
|
|
||||||
|
Uses wrapping-aware distance (same pattern as send_nearby_message).
|
||||||
|
"""
|
||||||
|
best: Mob | None = None
|
||||||
|
best_dist = float("inf")
|
||||||
|
|
||||||
|
for mob in mobs:
|
||||||
|
if not mob.alive or mob.name != name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dx = abs(mob.x - x)
|
||||||
|
dy = abs(mob.y - y)
|
||||||
|
dx = min(dx, world.width - dx)
|
||||||
|
dy = min(dy, world.height - dy)
|
||||||
|
|
||||||
|
if dx <= range_ and dy <= range_:
|
||||||
|
dist = dx + dy
|
||||||
|
if dist < best_dist:
|
||||||
|
best = mob
|
||||||
|
best_dist = dist
|
||||||
|
|
||||||
|
return best
|
||||||
|
|
@ -10,6 +10,7 @@ from typing import cast
|
||||||
import telnetlib3
|
import telnetlib3
|
||||||
from telnetlib3.server_shell import readline2
|
from telnetlib3.server_shell import readline2
|
||||||
|
|
||||||
|
import mudlib.combat.commands
|
||||||
import mudlib.commands
|
import mudlib.commands
|
||||||
import mudlib.commands.edit
|
import mudlib.commands.edit
|
||||||
import mudlib.commands.fly
|
import mudlib.commands.fly
|
||||||
|
|
@ -18,11 +19,14 @@ import mudlib.commands.look
|
||||||
import mudlib.commands.movement
|
import mudlib.commands.movement
|
||||||
import mudlib.commands.quit
|
import mudlib.commands.quit
|
||||||
import mudlib.commands.reload
|
import mudlib.commands.reload
|
||||||
|
import mudlib.commands.spawn
|
||||||
from mudlib.caps import parse_mtts
|
from mudlib.caps import parse_mtts
|
||||||
from mudlib.combat.commands import register_combat_commands
|
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.mob_ai import process_mobs
|
||||||
|
from mudlib.mobs import load_mob_templates, mob_templates
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.resting import process_resting
|
from mudlib.resting import process_resting
|
||||||
from mudlib.store import (
|
from mudlib.store import (
|
||||||
|
|
@ -65,6 +69,7 @@ async def game_loop() -> None:
|
||||||
t0 = asyncio.get_event_loop().time()
|
t0 = asyncio.get_event_loop().time()
|
||||||
clear_expired()
|
clear_expired()
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
await process_mobs(mudlib.combat.commands.combat_moves)
|
||||||
await process_resting()
|
await process_resting()
|
||||||
|
|
||||||
# Periodic auto-save (every 60 seconds)
|
# Periodic auto-save (every 60 seconds)
|
||||||
|
|
@ -379,6 +384,7 @@ async def run_server() -> None:
|
||||||
mudlib.commands.fly.world = _world
|
mudlib.commands.fly.world = _world
|
||||||
mudlib.commands.look.world = _world
|
mudlib.commands.look.world = _world
|
||||||
mudlib.commands.movement.world = _world
|
mudlib.commands.movement.world = _world
|
||||||
|
mudlib.combat.commands.world = _world
|
||||||
|
|
||||||
# Load content-defined commands from TOML files
|
# Load content-defined commands from TOML files
|
||||||
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
|
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
|
||||||
|
|
@ -396,6 +402,13 @@ async def run_server() -> None:
|
||||||
register_combat_commands(combat_dir)
|
register_combat_commands(combat_dir)
|
||||||
log.info("registered combat commands")
|
log.info("registered combat commands")
|
||||||
|
|
||||||
|
# Load mob templates
|
||||||
|
mobs_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "mobs"
|
||||||
|
if mobs_dir.exists():
|
||||||
|
loaded = load_mob_templates(mobs_dir)
|
||||||
|
mob_templates.update(loaded)
|
||||||
|
log.info("loaded %d mob templates from %s", len(loaded), mobs_dir)
|
||||||
|
|
||||||
# connect_maxwait: how long to wait for telnet option negotiation (CHARSET
|
# connect_maxwait: how long to wait for telnet option negotiation (CHARSET
|
||||||
# etc) before starting the shell. default is 4.0s which is painful.
|
# etc) before starting the shell. default is 4.0s which is painful.
|
||||||
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
||||||
|
|
|
||||||
317
tests/test_mob_ai.py
Normal file
317
tests/test_mob_ai.py
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
"""Tests for mob AI behavior."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import mudlib.commands.movement as movement_mod
|
||||||
|
from mudlib.combat import commands as combat_commands
|
||||||
|
from mudlib.combat.encounter import CombatState
|
||||||
|
from mudlib.combat.engine import (
|
||||||
|
active_encounters,
|
||||||
|
get_encounter,
|
||||||
|
start_encounter,
|
||||||
|
)
|
||||||
|
from mudlib.combat.moves import load_moves
|
||||||
|
from mudlib.mob_ai import process_mobs
|
||||||
|
from mudlib.mobs import (
|
||||||
|
load_mob_template,
|
||||||
|
mobs,
|
||||||
|
spawn_mob,
|
||||||
|
)
|
||||||
|
from mudlib.player import Player, players
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_state():
|
||||||
|
"""Clear mobs, encounters, and players before and after each test."""
|
||||||
|
mobs.clear()
|
||||||
|
active_encounters.clear()
|
||||||
|
players.clear()
|
||||||
|
yield
|
||||||
|
mobs.clear()
|
||||||
|
active_encounters.clear()
|
||||||
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_world():
|
||||||
|
"""Inject a mock world for movement and combat commands."""
|
||||||
|
fake_world = MagicMock()
|
||||||
|
fake_world.width = 256
|
||||||
|
fake_world.height = 256
|
||||||
|
old_movement = movement_mod.world
|
||||||
|
old_combat = combat_commands.world
|
||||||
|
movement_mod.world = fake_world
|
||||||
|
combat_commands.world = fake_world
|
||||||
|
yield fake_world
|
||||||
|
movement_mod.world = old_movement
|
||||||
|
combat_commands.world = old_combat
|
||||||
|
|
||||||
|
|
||||||
|
@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):
|
||||||
|
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
players[p.name] = p
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def moves():
|
||||||
|
"""Load combat moves from content directory."""
|
||||||
|
content_dir = Path(__file__).parent.parent / "content" / "combat"
|
||||||
|
return load_moves(content_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_moves(moves):
|
||||||
|
"""Inject loaded moves into combat commands module."""
|
||||||
|
combat_commands.combat_moves = moves
|
||||||
|
yield
|
||||||
|
combat_commands.combat_moves = {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def goblin_toml(tmp_path):
|
||||||
|
path = tmp_path / "goblin.toml"
|
||||||
|
path.write_text(
|
||||||
|
'name = "goblin"\n'
|
||||||
|
'description = "a snarling goblin with a crude club"\n'
|
||||||
|
"pl = 50.0\n"
|
||||||
|
"stamina = 40.0\n"
|
||||||
|
"max_stamina = 40.0\n"
|
||||||
|
'moves = ["punch left", "punch right", "sweep"]\n'
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dummy_toml(tmp_path):
|
||||||
|
path = tmp_path / "training_dummy.toml"
|
||||||
|
path.write_text(
|
||||||
|
'name = "training dummy"\n'
|
||||||
|
'description = "a battered wooden training dummy"\n'
|
||||||
|
"pl = 200.0\n"
|
||||||
|
"stamina = 100.0\n"
|
||||||
|
"max_stamina = 100.0\n"
|
||||||
|
"moves = []\n"
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestMobAttackAI:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_attacks_when_idle_and_cooldown_expired(
|
||||||
|
self, player, goblin_toml, moves
|
||||||
|
):
|
||||||
|
"""Mob attacks when encounter is IDLE and cooldown has expired."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
mob.next_action_at = 0.0 # cooldown expired
|
||||||
|
|
||||||
|
encounter = start_encounter(player, mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
# Mob should have attacked — encounter state should be TELEGRAPH
|
||||||
|
assert encounter.state == CombatState.TELEGRAPH
|
||||||
|
assert encounter.current_move is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_picks_from_its_own_moves(self, player, goblin_toml, moves):
|
||||||
|
"""Mob only picks moves from its moves list."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
encounter = start_encounter(player, mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
assert encounter.current_move is not None
|
||||||
|
assert encounter.current_move.name in mob.moves
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_skips_when_stamina_too_low(self, player, goblin_toml, moves):
|
||||||
|
"""Mob skips attack when stamina is too low for any move."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
mob.stamina = 0.0
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
encounter = start_encounter(player, mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
# Mob can't afford any move, encounter stays IDLE
|
||||||
|
assert encounter.state == CombatState.IDLE
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_respects_cooldown(self, player, goblin_toml, moves):
|
||||||
|
"""Mob doesn't act when cooldown hasn't expired."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
mob.next_action_at = time.monotonic() + 100.0 # far in the future
|
||||||
|
|
||||||
|
encounter = start_encounter(player, mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
# Mob should not have attacked
|
||||||
|
assert encounter.state == CombatState.IDLE
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_swaps_roles_when_defending(self, player, goblin_toml, moves):
|
||||||
|
"""Mob swaps attacker/defender roles when it attacks as defender."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
# Player is attacker, mob is defender
|
||||||
|
encounter = start_encounter(player, mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
# Mob should now be the attacker
|
||||||
|
assert encounter.attacker is mob
|
||||||
|
assert encounter.defender is player
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_doesnt_act_outside_combat(self, goblin_toml, moves):
|
||||||
|
"""Mob not in combat does nothing."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
# No encounter exists for mob
|
||||||
|
assert get_encounter(mob) is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_sets_cooldown_after_attack(self, player, goblin_toml, moves):
|
||||||
|
"""Mob sets next_action_at after attacking."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
start_encounter(player, mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
before = time.monotonic()
|
||||||
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
# next_action_at should be ~1 second in the future
|
||||||
|
assert mob.next_action_at >= before + 0.9
|
||||||
|
|
||||||
|
|
||||||
|
class TestMobDefenseAI:
|
||||||
|
@pytest.fixture
|
||||||
|
def punch_right(self, moves):
|
||||||
|
return moves["punch right"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_defends_during_telegraph(
|
||||||
|
self, player, goblin_toml, moves, punch_right
|
||||||
|
):
|
||||||
|
"""Mob attempts defense during TELEGRAPH phase."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
# Give the mob defense moves
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
mob.moves = ["punch left", "dodge left", "dodge right"]
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
encounter = start_encounter(player, mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
# Player attacks, putting encounter in TELEGRAPH
|
||||||
|
encounter.attack(punch_right)
|
||||||
|
assert encounter.state == CombatState.TELEGRAPH
|
||||||
|
|
||||||
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
# Mob should have queued a defense
|
||||||
|
assert encounter.pending_defense is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_skips_defense_when_already_defending(
|
||||||
|
self, player, goblin_toml, moves, punch_right
|
||||||
|
):
|
||||||
|
"""Mob doesn't double-defend if already has pending_defense."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
mob.moves = ["dodge left", "dodge right"]
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
encounter = start_encounter(player, mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
encounter.attack(punch_right)
|
||||||
|
|
||||||
|
# Pre-set a defense
|
||||||
|
existing_defense = moves["dodge left"]
|
||||||
|
encounter.pending_defense = existing_defense
|
||||||
|
|
||||||
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
# Should not have changed
|
||||||
|
assert encounter.pending_defense is existing_defense
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_no_defense_without_defense_moves(
|
||||||
|
self, player, goblin_toml, moves, punch_right
|
||||||
|
):
|
||||||
|
"""Mob with no defense moves in its list can't defend."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
# Only attack moves
|
||||||
|
mob.moves = ["punch left", "punch right", "sweep"]
|
||||||
|
mob.next_action_at = time.monotonic() + 100.0 # prevent attacking
|
||||||
|
|
||||||
|
encounter = start_encounter(player, mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
encounter.attack(punch_right)
|
||||||
|
|
||||||
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
# No defense queued
|
||||||
|
assert encounter.pending_defense is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dummy_never_fights_back(
|
||||||
|
self, player, dummy_toml, moves, punch_right
|
||||||
|
):
|
||||||
|
"""Training dummy with empty moves never attacks or defends."""
|
||||||
|
template = load_mob_template(dummy_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
encounter = start_encounter(player, mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
# Player attacks
|
||||||
|
encounter.attack(punch_right)
|
||||||
|
|
||||||
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
# Dummy should not have defended (empty moves list)
|
||||||
|
assert encounter.pending_defense is None
|
||||||
472
tests/test_mobs.py
Normal file
472
tests/test_mobs.py
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
"""Tests for mob templates, registry, spawn/despawn, and combat integration."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import mudlib.commands.movement as movement_mod
|
||||||
|
from mudlib.combat import commands as combat_commands
|
||||||
|
from mudlib.combat.encounter import CombatState
|
||||||
|
from mudlib.combat.engine import active_encounters, get_encounter
|
||||||
|
from mudlib.combat.moves import load_moves
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.mobs import (
|
||||||
|
despawn_mob,
|
||||||
|
get_nearby_mob,
|
||||||
|
load_mob_template,
|
||||||
|
load_mob_templates,
|
||||||
|
mobs,
|
||||||
|
spawn_mob,
|
||||||
|
)
|
||||||
|
from mudlib.player import Player, players
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_state():
|
||||||
|
"""Clear mobs, encounters, and players before and after each test."""
|
||||||
|
mobs.clear()
|
||||||
|
active_encounters.clear()
|
||||||
|
players.clear()
|
||||||
|
yield
|
||||||
|
mobs.clear()
|
||||||
|
active_encounters.clear()
|
||||||
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_world():
|
||||||
|
"""Inject a mock world for movement and combat commands."""
|
||||||
|
fake_world = MagicMock()
|
||||||
|
fake_world.width = 256
|
||||||
|
fake_world.height = 256
|
||||||
|
old_movement = movement_mod.world
|
||||||
|
old_combat = combat_commands.world
|
||||||
|
movement_mod.world = fake_world
|
||||||
|
combat_commands.world = fake_world
|
||||||
|
yield fake_world
|
||||||
|
movement_mod.world = old_movement
|
||||||
|
combat_commands.world = old_combat
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def goblin_toml(tmp_path):
|
||||||
|
"""Create a goblin TOML file."""
|
||||||
|
path = tmp_path / "goblin.toml"
|
||||||
|
path.write_text(
|
||||||
|
'name = "goblin"\n'
|
||||||
|
'description = "a snarling goblin with a crude club"\n'
|
||||||
|
"pl = 50.0\n"
|
||||||
|
"stamina = 40.0\n"
|
||||||
|
"max_stamina = 40.0\n"
|
||||||
|
'moves = ["punch left", "punch right", "sweep"]\n'
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dummy_toml(tmp_path):
|
||||||
|
"""Create a training dummy TOML file."""
|
||||||
|
path = tmp_path / "training_dummy.toml"
|
||||||
|
path.write_text(
|
||||||
|
'name = "training dummy"\n'
|
||||||
|
'description = "a battered wooden training dummy"\n'
|
||||||
|
"pl = 200.0\n"
|
||||||
|
"stamina = 100.0\n"
|
||||||
|
"max_stamina = 100.0\n"
|
||||||
|
"moves = []\n"
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadTemplate:
|
||||||
|
def test_load_single_template(self, goblin_toml):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
assert template.name == "goblin"
|
||||||
|
assert template.description == "a snarling goblin with a crude club"
|
||||||
|
assert template.pl == 50.0
|
||||||
|
assert template.stamina == 40.0
|
||||||
|
assert template.max_stamina == 40.0
|
||||||
|
assert template.moves == ["punch left", "punch right", "sweep"]
|
||||||
|
|
||||||
|
def test_load_template_no_moves(self, dummy_toml):
|
||||||
|
template = load_mob_template(dummy_toml)
|
||||||
|
assert template.name == "training dummy"
|
||||||
|
assert template.moves == []
|
||||||
|
|
||||||
|
def test_load_all_templates(self, goblin_toml, dummy_toml):
|
||||||
|
templates = load_mob_templates(goblin_toml.parent)
|
||||||
|
assert "goblin" in templates
|
||||||
|
assert "training dummy" in templates
|
||||||
|
assert len(templates) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpawnDespawn:
|
||||||
|
def test_spawn_creates_mob(self, goblin_toml):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 10, 20)
|
||||||
|
assert isinstance(mob, Mob)
|
||||||
|
assert mob.name == "goblin"
|
||||||
|
assert mob.x == 10
|
||||||
|
assert mob.y == 20
|
||||||
|
assert mob.pl == 50.0
|
||||||
|
assert mob.stamina == 40.0
|
||||||
|
assert mob.max_stamina == 40.0
|
||||||
|
assert mob.moves == ["punch left", "punch right", "sweep"]
|
||||||
|
assert mob.alive is True
|
||||||
|
assert mob in mobs
|
||||||
|
|
||||||
|
def test_spawn_adds_to_registry(self, goblin_toml):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
spawn_mob(template, 0, 0)
|
||||||
|
spawn_mob(template, 5, 5)
|
||||||
|
assert len(mobs) == 2
|
||||||
|
|
||||||
|
def test_despawn_removes_from_list(self, goblin_toml):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
despawn_mob(mob)
|
||||||
|
assert mob not in mobs
|
||||||
|
assert mob.alive is False
|
||||||
|
|
||||||
|
def test_despawn_sets_alive_false(self, goblin_toml):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
despawn_mob(mob)
|
||||||
|
assert mob.alive is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetNearbyMob:
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_world(self):
|
||||||
|
w = MagicMock()
|
||||||
|
w.width = 256
|
||||||
|
w.height = 256
|
||||||
|
return w
|
||||||
|
|
||||||
|
def test_finds_by_name_within_range(self, goblin_toml, mock_world):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 5, 5)
|
||||||
|
found = get_nearby_mob("goblin", 3, 3, mock_world)
|
||||||
|
assert found is mob
|
||||||
|
|
||||||
|
def test_returns_none_when_out_of_range(self, goblin_toml, mock_world):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
spawn_mob(template, 100, 100)
|
||||||
|
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
def test_returns_none_for_wrong_name(self, goblin_toml, mock_world):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
spawn_mob(template, 5, 5)
|
||||||
|
found = get_nearby_mob("dragon", 3, 3, mock_world)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
def test_picks_closest_when_multiple(self, goblin_toml, mock_world):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
spawn_mob(template, 8, 8)
|
||||||
|
close_mob = spawn_mob(template, 1, 1)
|
||||||
|
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
||||||
|
assert found is close_mob
|
||||||
|
|
||||||
|
def test_skips_dead_mobs(self, goblin_toml, mock_world):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 5, 5)
|
||||||
|
mob.alive = False
|
||||||
|
found = get_nearby_mob("goblin", 3, 3, mock_world)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
def test_wrapping_distance(self, goblin_toml, mock_world):
|
||||||
|
"""Mob near world edge is close to player at opposite edge."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 254, 254)
|
||||||
|
found = get_nearby_mob("goblin", 2, 2, mock_world, range_=10)
|
||||||
|
assert found is mob
|
||||||
|
|
||||||
|
|
||||||
|
# --- Phase 2: target resolution tests ---
|
||||||
|
|
||||||
|
|
||||||
|
@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):
|
||||||
|
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
players[p.name] = p
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def moves():
|
||||||
|
"""Load combat moves from content directory."""
|
||||||
|
content_dir = Path(__file__).parent.parent / "content" / "combat"
|
||||||
|
return load_moves(content_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_moves(moves):
|
||||||
|
"""Inject loaded moves into combat commands module."""
|
||||||
|
combat_commands.combat_moves = moves
|
||||||
|
yield
|
||||||
|
combat_commands.combat_moves = {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def punch_right(moves):
|
||||||
|
return moves["punch right"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTargetResolution:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_attack_mob_by_name(self, player, punch_right, goblin_toml):
|
||||||
|
"""do_attack with mob name finds and engages the mob."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
|
||||||
|
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||||
|
|
||||||
|
encounter = get_encounter(player)
|
||||||
|
assert encounter is not None
|
||||||
|
assert encounter.attacker is player
|
||||||
|
assert encounter.defender is mob
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_attack_prefers_player_over_mob(
|
||||||
|
self, player, punch_right, goblin_toml, mock_reader, mock_writer
|
||||||
|
):
|
||||||
|
"""When a player and mob share a name, player takes priority."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
spawn_mob(template, 0, 0)
|
||||||
|
|
||||||
|
# Create a player named "goblin"
|
||||||
|
goblin_player = Player(
|
||||||
|
name="goblin",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
)
|
||||||
|
players["goblin"] = goblin_player
|
||||||
|
|
||||||
|
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||||
|
|
||||||
|
encounter = get_encounter(player)
|
||||||
|
assert encounter is not None
|
||||||
|
assert encounter.defender is goblin_player
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_attack_mob_out_of_range(self, player, punch_right, goblin_toml):
|
||||||
|
"""Mob outside viewport range is not found as target."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
spawn_mob(template, 100, 100)
|
||||||
|
|
||||||
|
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||||
|
|
||||||
|
encounter = get_encounter(player)
|
||||||
|
assert encounter is None
|
||||||
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
|
assert any("need a target" in msg.lower() for msg in messages)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_encounter_mob_no_mode_push(self, player, punch_right, goblin_toml):
|
||||||
|
"""Mob doesn't get mode_stack push (it has no mode_stack)."""
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
|
||||||
|
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||||
|
|
||||||
|
# Player should be in combat mode
|
||||||
|
assert player.mode == "combat"
|
||||||
|
# Mob has no mode_stack attribute
|
||||||
|
assert not hasattr(mob, "mode_stack")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Phase 3: viewport rendering tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestViewportRendering:
|
||||||
|
@pytest.fixture
|
||||||
|
def look_world(self):
|
||||||
|
"""A mock world that returns a flat viewport of '.' tiles."""
|
||||||
|
from mudlib.commands.look import VIEWPORT_HEIGHT, VIEWPORT_WIDTH
|
||||||
|
|
||||||
|
w = MagicMock()
|
||||||
|
w.width = 256
|
||||||
|
w.height = 256
|
||||||
|
w.get_viewport = MagicMock(
|
||||||
|
return_value=[
|
||||||
|
["." for _ in range(VIEWPORT_WIDTH)] for _ in range(VIEWPORT_HEIGHT)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
w.wrap = lambda x, y: (x % 256, y % 256)
|
||||||
|
w.is_passable = MagicMock(return_value=True)
|
||||||
|
return w
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_renders_as_star(self, player, goblin_toml, look_world):
|
||||||
|
"""Mob within viewport renders as * in look output."""
|
||||||
|
import mudlib.commands.look as look_mod
|
||||||
|
|
||||||
|
old = look_mod.world
|
||||||
|
look_mod.world = look_world
|
||||||
|
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
# Place mob 2 tiles to the right of the player
|
||||||
|
spawn_mob(template, 2, 0)
|
||||||
|
|
||||||
|
await look_mod.cmd_look(player, "")
|
||||||
|
|
||||||
|
output = "".join(call[0][0] for call in player.writer.write.call_args_list)
|
||||||
|
# The center is at (10, 5), mob at relative (12, 5)
|
||||||
|
# Output should contain a * character
|
||||||
|
assert "*" in output
|
||||||
|
|
||||||
|
look_mod.world = old
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_outside_viewport_not_rendered(
|
||||||
|
self, player, goblin_toml, look_world
|
||||||
|
):
|
||||||
|
"""Mob outside viewport bounds is not rendered."""
|
||||||
|
import mudlib.commands.look as look_mod
|
||||||
|
|
||||||
|
old = look_mod.world
|
||||||
|
look_mod.world = look_world
|
||||||
|
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
# Place mob far away
|
||||||
|
spawn_mob(template, 100, 100)
|
||||||
|
|
||||||
|
await look_mod.cmd_look(player, "")
|
||||||
|
|
||||||
|
output = "".join(call[0][0] for call in player.writer.write.call_args_list)
|
||||||
|
# Should only have @ (player) and . (terrain), no *
|
||||||
|
stripped = output.replace("\033[0m", "").replace("\r\n", "")
|
||||||
|
# Remove ANSI codes for terrain colors
|
||||||
|
import re
|
||||||
|
|
||||||
|
stripped = re.sub(r"\033\[[0-9;]*m", "", stripped)
|
||||||
|
assert "*" not in stripped
|
||||||
|
|
||||||
|
look_mod.world = old
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dead_mob_not_rendered(self, player, goblin_toml, look_world):
|
||||||
|
"""Dead mob (alive=False) not rendered in viewport."""
|
||||||
|
import mudlib.commands.look as look_mod
|
||||||
|
|
||||||
|
old = look_mod.world
|
||||||
|
look_mod.world = look_world
|
||||||
|
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
mob = spawn_mob(template, 2, 0)
|
||||||
|
mob.alive = False
|
||||||
|
|
||||||
|
await look_mod.cmd_look(player, "")
|
||||||
|
|
||||||
|
output = "".join(call[0][0] for call in player.writer.write.call_args_list)
|
||||||
|
import re
|
||||||
|
|
||||||
|
stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace("\r\n", "")
|
||||||
|
assert "*" not in stripped
|
||||||
|
|
||||||
|
look_mod.world = old
|
||||||
|
|
||||||
|
|
||||||
|
# --- Phase 4: mob defeat tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestMobDefeat:
|
||||||
|
@pytest.fixture
|
||||||
|
def goblin_mob(self, goblin_toml):
|
||||||
|
template = load_mob_template(goblin_toml)
|
||||||
|
return spawn_mob(template, 0, 0)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
|
||||||
|
"""Mob with PL <= 0 gets despawned after combat resolves."""
|
||||||
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
|
encounter = start_encounter(player, goblin_mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
# Set mob PL very low so attack kills it
|
||||||
|
goblin_mob.pl = 1.0
|
||||||
|
|
||||||
|
# Attack and force resolution
|
||||||
|
encounter.attack(punch_right)
|
||||||
|
encounter.state = CombatState.RESOLVE
|
||||||
|
|
||||||
|
await process_combat()
|
||||||
|
|
||||||
|
assert goblin_mob not in mobs
|
||||||
|
assert goblin_mob.alive is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_player_gets_victory_message(self, player, goblin_mob, punch_right):
|
||||||
|
"""Player receives a victory message when mob is defeated."""
|
||||||
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
|
encounter = start_encounter(player, goblin_mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
goblin_mob.pl = 1.0
|
||||||
|
encounter.attack(punch_right)
|
||||||
|
encounter.state = CombatState.RESOLVE
|
||||||
|
|
||||||
|
await process_combat()
|
||||||
|
|
||||||
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
|
assert any("defeated" in msg.lower() for msg in messages)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_stamina_depleted_despawns(self, player, goblin_mob, punch_right):
|
||||||
|
"""Mob is despawned when attacker stamina depleted (combat end)."""
|
||||||
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
|
encounter = start_encounter(player, goblin_mob)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
# Drain player stamina so combat ends on exhaustion
|
||||||
|
player.stamina = 0.0
|
||||||
|
encounter.attack(punch_right)
|
||||||
|
encounter.state = CombatState.RESOLVE
|
||||||
|
|
||||||
|
await process_combat()
|
||||||
|
|
||||||
|
# Encounter should have ended
|
||||||
|
assert get_encounter(player) is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_player_defeat_not_despawned(self, player, goblin_mob, punch_right):
|
||||||
|
"""When player loses, player is not despawned."""
|
||||||
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
|
# Mob attacks player — mob is attacker, player is defender
|
||||||
|
encounter = start_encounter(goblin_mob, player)
|
||||||
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
|
player.pl = 1.0
|
||||||
|
encounter.attack(punch_right)
|
||||||
|
encounter.state = CombatState.RESOLVE
|
||||||
|
|
||||||
|
await process_combat()
|
||||||
|
|
||||||
|
# Player should get defeat message, not be despawned
|
||||||
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
|
assert any(
|
||||||
|
"defeated" in msg.lower() or "damage" in msg.lower() for msg in messages
|
||||||
|
)
|
||||||
|
# Player is still in players dict (not removed)
|
||||||
|
assert player.name in players
|
||||||
91
tests/test_spawn_command.py
Normal file
91
tests/test_spawn_command.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""Tests for the spawn command."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.commands.spawn import cmd_spawn
|
||||||
|
from mudlib.mobs import MobTemplate, mob_templates, mobs
|
||||||
|
from mudlib.player import Player, players
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_state():
|
||||||
|
"""Clear mobs, templates, and players."""
|
||||||
|
mobs.clear()
|
||||||
|
mob_templates.clear()
|
||||||
|
players.clear()
|
||||||
|
yield
|
||||||
|
mobs.clear()
|
||||||
|
mob_templates.clear()
|
||||||
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@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):
|
||||||
|
p = Player(name="Goku", x=10, y=20, reader=mock_reader, writer=mock_writer)
|
||||||
|
players[p.name] = p
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def goblin_template():
|
||||||
|
t = MobTemplate(
|
||||||
|
name="goblin",
|
||||||
|
description="a snarling goblin",
|
||||||
|
pl=50.0,
|
||||||
|
stamina=40.0,
|
||||||
|
max_stamina=40.0,
|
||||||
|
moves=["punch left"],
|
||||||
|
)
|
||||||
|
mob_templates["goblin"] = t
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_spawn_valid_mob(player, goblin_template):
|
||||||
|
"""Spawn creates a mob at the player's position."""
|
||||||
|
await cmd_spawn(player, "goblin")
|
||||||
|
|
||||||
|
assert len(mobs) == 1
|
||||||
|
mob = mobs[0]
|
||||||
|
assert mob.name == "goblin"
|
||||||
|
assert mob.x == player.x
|
||||||
|
assert mob.y == player.y
|
||||||
|
|
||||||
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
|
assert any("goblin" in msg.lower() and "appears" in msg.lower() for msg in messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_spawn_invalid_type(player, goblin_template):
|
||||||
|
"""Spawn with unknown type shows available mobs."""
|
||||||
|
await cmd_spawn(player, "dragon")
|
||||||
|
|
||||||
|
assert len(mobs) == 0
|
||||||
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
|
assert any("unknown" in msg.lower() for msg in messages)
|
||||||
|
assert any("goblin" in msg.lower() for msg in messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_spawn_no_args(player, goblin_template):
|
||||||
|
"""Spawn with no args shows usage."""
|
||||||
|
await cmd_spawn(player, "")
|
||||||
|
|
||||||
|
assert len(mobs) == 0
|
||||||
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
|
assert any("usage" in msg.lower() for msg in messages)
|
||||||
Loading…
Reference in a new issue