Commit graph

164 commits

Author SHA1 Message Date
8acfa5ea22
Wire thing templates and inventory into server startup
Loads thing templates from content/things/ at startup. Registers
get/drop/inventory commands via things module import. Reconstructs
player inventory from saved template names on login, with graceful
fallback for unknown templates.
2026-02-11 20:29:59 -05:00
6081c90ad1
Add inventory persistence to player saves
Inventory saved as JSON list of thing template names in an inventory
column. Migration adds column to existing databases. load_player_data
returns inventory list, save_player serializes Thing names from contents.
2026-02-11 20:29:58 -05:00
c43b3346ae
Add Thing templates, TOML loading, and spawning
ThingTemplate dataclass mirrors MobTemplate pattern. load_thing_template
and load_thing_templates parse TOML files from content/things/. spawn_thing
creates Thing instances from templates. Includes rock and fountain examples.
2026-02-11 20:01:15 -05:00
2e79255aec
Show ground items in look command
After the viewport, look lists Things at the player's position
in a "On the ground: item1, item2" line. No output when empty.
2026-02-11 20:01:10 -05:00
e96fd50de5
Add inventory command with alias "i"
Lists Thing objects in player.contents with 2-space indented format.
Shows "You aren't carrying anything." when inventory is empty.
2026-02-11 20:01:05 -05:00
7c12bf3318
Add Object.move_to(), get and drop commands
Object.move_to() handles containment transfer: removes from old location's
contents, updates location pointer and coordinates, adds to new location.
get/drop commands use move_to to transfer Things between zone and inventory.
Supports name and alias matching for item lookup.
2026-02-11 19:57:38 -05:00
9437728435
Add Thing class and Entity.can_accept() for inventory
Thing is an Object subclass with description, portable flag, and aliases.
Entity.can_accept() returns True for portable Things, enabling the
containment model where entities carry items in their contents.
2026-02-11 19:55:58 -05:00
957a411601
Clean up global state, migrate broadcast_to_spectators to Zone
Removes dependency on global players dict for spatial queries by using
Zone.contents_at() for spectator lookup. Makes _world local to run_server()
since it's only used during initialization to create the overworld Zone.
Updates test fixtures to provide zones for spatial query tests.
2026-02-11 19:42:12 -05:00
f5646589b5
Migrate look to use player.location (Zone)
- Removed world module-level variable from look.py
- look.cmd_look() now uses player.location.get_viewport() instead of world.get_viewport()
- look.cmd_look() uses zone.contents_near() to find nearby entities instead of iterating global players/mobs lists
- Wrapping calculations use zone.width/height/toroidal instead of world properties
- Added type check for player.location being a Zone instance
- Removed look.world injection from server.py
- Updated all tests to remove look.world injection
- spawn_mob() and combat commands also migrated to use Zone (player.location)
- Removed orphaned code from test_mob_ai.py and test_variant_prefix.py
2026-02-11 19:36:46 -05:00
1349c2f860
Add zone_name to persistence schema
Adds zone_name field to PlayerData and accounts table to track which
zone a player is in. Defaults to 'overworld'. Includes migration logic
to handle existing databases without the column. Server now resolves
zone from zone_name when loading player data.
2026-02-11 19:33:23 -05:00
875ded5762
Migrate fly to use player.location (Zone)
Removed module-level world variable and replaced all world.wrap() calls
with player.location.wrap(). Added Zone assertion for type safety,
matching the pattern in movement.py. Updated tests to remove fly.world
injection since it's no longer needed.
2026-02-11 19:33:15 -05:00
404a1cdf0c
Migrate movement to use player.location (Zone)
Movement commands now access the zone through player.location instead of
a module-level world variable. send_nearby_message uses
zone.contents_near() to find nearby entities, eliminating the need for
the global players dict and manual distance calculations.

Tests updated to create zones and add entities via location assignment.
2026-02-11 19:28:27 -05:00
66c6e1ebd4
Create overworld Zone at startup, set player.location 2026-02-11 19:19:15 -05:00
6f58ae0501
Add contents_near() spatial query to Zone 2026-02-11 19:17:17 -05:00
b4fca95830
Add Zone class with terrain, spatial queries, and viewport
Zone(Object) is a spatial area with a terrain grid. Supports
toroidal wrapping and bounded clamping, passability checks,
viewport extraction, and contents_at(x, y) spatial queries.
Zones are top-level containers (location=None) that accept
everything via can_accept().
2026-02-11 19:08:30 -05:00
51dc583818
Make Entity inherit from Object
Entity gains location from Object and narrows x/y back to int
(entities always have spatial coordinates). No behavioral change —
all existing tests pass unchanged.
2026-02-11 18:40:35 -05:00
d9e9d1b785
Add Object base class with containment primitives
Object provides name, location, x/y, contents reverse-lookup, and
can_accept() — the foundation for the containment tree that zones,
things, and inventory will build on.
2026-02-11 18:40:31 -05:00
74538756d5
Handle object 0 (nothing) gracefully in object parser
Per the Z-machine spec, object 0 means "nothing" and operations on it
should be safe no-ops: get_child/get_sibling/get_parent return 0,
test_attr returns false, set/clear_attr are no-ops, etc. Previously
these threw ZObjectIllegalObjectNumber, crashing on games like Curses
that pass object 0 to get_child during room transitions.
2026-02-10 18:32:36 -05:00
5a98adb6ee
Add instruction tracing to step_fast and improve error messages
step_fast() never recorded trace entries, so crash dumps always showed
an empty trace. Now records PC + opcode info in the same deque as
step(). Also includes exception type in player-facing error messages
when the exception string is empty.
2026-02-10 18:29:27 -05:00
c8d9bdfae9
Map empty Enter to ZSCII 13 in read_char
MudInputStream.read_char() returned 0 for empty input, which no game
recognizes as a valid keypress. Now returns 13 (Enter/Return) so
"press any key" prompts like Curses' intro work from a MUD client.
2026-02-10 18:26:41 -05:00
fd977b91a2
Guard bare > stripping with has_prompt check 2026-02-10 17:53:11 -05:00
b81bc3edc8
Add consistent > prompt for IF mode in server loop 2026-02-10 17:50:35 -05:00
ac1d16095e
Strip trailing > prompt from embedded z-machine output 2026-02-10 17:50:06 -05:00
909ee0932b
Replace deprecated chunk module with inline IFF parser
The chunk module was deprecated in 3.11 and removed in 3.13.
Our usage was minimal (read name, size, data, skip padding),
so a small _read_iff_chunk() helper replaces it with no deps.
2026-02-10 17:20:01 -05:00
3140a4d617
Add return_addr to ZStackBottom for uniform frame access
ZStackBottom already had stack and local_vars for uniform treatment,
but was missing return_addr. Adding it removes 5 type: ignore
suppressions and fixes all ty possibly-missing-attribute warnings.
2026-02-10 17:16:25 -05:00
6d29ec00fb
Implement stub opcodes for game compatibility
set_colour, piracy, erase_line, get_cursor, not_v5, print_table
2026-02-10 17:10:29 -05:00
bc1a2e5489
Add undo command support 2026-02-10 16:49:46 -05:00
1b3a3646d6
Pre-consume store byte in op_aread and op_read_char before blocking reads
Without this, MUD-level saves during read_line/read_char capture PC pointing
at the store byte, which gets misinterpreted as an opcode on restore.
2026-02-10 15:45:52 -05:00
bb2f1989cb
Optimize z-machine hot loop: fast step, dispatch table, inline bit ops
Add step_fast() that skips trace/logging overhead (saves ~22% at 1M+
avoided log calls). Pre-resolve opcode dispatch table at init to
eliminate per-instruction version checks and isinstance calls. Replace
BitField allocations with direct bit masks in opcode decoder.

Cold start: 4720ms -> 786ms. Steady state: ~500ms -> ~460ms.
2026-02-10 15:05:34 -05:00
802c72819c
Fix op_read_char to accept optional timing arguments
Lost Pig calls read_char with only the required first operand.
The Z-machine spec says time and input_routine are optional.
2026-02-10 14:48:57 -05:00
5f12a4f841
Suppress upper window writes in MudScreen to fix Lost Pig output
V5+ games write room names to the upper window (status line) via
select_window(1). Since select_window was a no-op, status line text
leaked into the main output buffer, causing ">Outside" on prompt lines.

Track the active window and only buffer writes to window 0 (lower).
2026-02-10 14:36:42 -05:00
e55294af78
Implement V5+ save/restore opcodes and handle in-game saves on restore
- op_save_v5: generates Quetzal save, stores 1 on success / 0 on failure
- op_restore_v5: loads Quetzal save, stores 2 ("restored") via store byte
- _try_restore: detect V5+ in-game saves (0xBE 0x00 before PC) and process
  the store byte with result 2, matching the V3 branch-on-restore pattern
2026-02-10 14:18:42 -05:00
602da45ac2
Fix IF bugs: case-insensitive story lookup, double prompt, phantom restore command
- _find_story() now compares path.stem.lower() so "lostpig" matches "LostPig.z8"
- Server no longer writes its own prompt in IF mode (game handles prompting)
- Suppress phantom game output on restore (saved PC past sread causes garbage)
- Route .z5/.z8 files to EmbeddedIFSession now that V5+ is supported
2026-02-10 14:16:19 -05:00
8a5ef7b1f6
Implement V5+ opcodes: aread, save_undo, shifts, scan_table, and more
Implements the opcode set needed for Lost Pig (V8):
- op_aread: V5+ input with text at byte 2, char count at byte 1,
  stores terminating character
- op_save_undo/op_restore_undo: stub returning -1/0 (undo not yet
  available, game continues without it)
- op_log_shift/op_art_shift: logical and arithmetic bit shifts
- op_scan_table: table search with configurable entry size and
  word/byte comparison
- op_tokenize: re-tokenize text buffer against dictionary
- op_copy_table: memory copy/zero with forward/backward support
- op_set_font: returns 1 for font 1, 0 for others
- op_print_unicode/op_check_unicode: basic Unicode output support

Lost Pig now runs to completion: 101K steps, 61 unique opcodes,
3 full input cycles with room descriptions rendering correctly.
2026-02-10 13:51:28 -05:00
d71f221277
Implement V5+ call variants and fix double-byte operand decoding
New opcodes: op_call_vn, op_call_vn2, op_call_vs2, op_catch,
op_check_arg_count. All call variants delegate to existing _call().
ZRoutine now tracks arg_count for check_arg_count.

Fixed zopdecoder double-byte operand parsing for call_vs2/call_vn2:
the old code called _parse_operands_byte() twice, but this method
reads both type byte AND operands together. The second call would
read operand data as a type byte. Refactored into _read_type_byte()
+ _parse_operand_list() so both type bytes are read before any
operand data.

Also fixed the double-byte detection: was checking opcode[0:7] (7
bits = 0x7A for call_vn2) instead of opcode_num (5 bits = 0x1A).
The check never matched, so double-byte opcodes were always
mis-parsed.
2026-02-10 13:48:08 -05:00
38e60ae40c
Fix insert_object to remove from old parent before inserting
The old code inserted the object into the new parent first, then
tried to remove it from the old parent. This corrupted the sibling
chain because the object's sibling pointer was already modified.

The Z-spec says "if O already has a parent, it is first removed."
Now delegates to remove_object() before inserting.
2026-02-10 13:47:58 -05:00
e61dcc3ac4
Implement extended opcode decoder for V5+
The 0xBE prefix byte triggers extended opcode parsing. Reads the
opcode number from the next byte, then parses operand types using
the same format as VAR opcodes. Required for all V5+ games.
2026-02-10 13:37:27 -05:00
11d939a70f
Relax version gates to accept V8 story files
V8 uses the same format as V5 (object model, opcodes, stack) with
two differences: packed address scaling (×8 instead of ×4) and max
file size (512KB instead of 256KB).

zmemory: add V8 size validation and packed_address case
zobjectparser: accept version 8 alongside 4-5 in all checks
zstackmanager: allow V8 stack initialization
V6-7 remain unsupported (different packed address format with offsets).
2026-02-10 13:37:22 -05:00
e0573f4229
Fix two zmachine bugs found during code audit
quetzal.py: typo in _parse_umem() — cmem.dynamic_start should be
cmem._dynamic_start. Would crash on uncompressed memory restore.

zcpu.py: op_erase_window had if/if/else instead of if/elif/else.
When window_number was -1, the second if fell through to else,
calling erase_window(-1) after the correct reset path.
2026-02-10 13:30:44 -05:00
c52e59c5d4
Process V3 save branch on restore to advance PC past branch data
The Quetzal spec stores the PC pointing at the save instruction's
branch data. On restore, this branch must be processed as "save
succeeded" to advance the PC to the actual next instruction. Without
this, the branch bytes were decoded as an opcode, corrupting execution.

Detect the save opcode (0xB5) immediately before the restored PC to
distinguish in-game saves from out-of-band saves (which don't need
branch processing). Also improve error diagnostics: pop_stack now
raises ZStackPopError with frame context, and the instruction trace
dumps on all exceptions.
2026-02-10 13:15:16 -05:00
8526e48247
Fix Quetzal Stks field mapping: return_pc to caller, varnum to frame
return_pc for each frame belongs on the caller's program_counter (the
resume address when this routine exits). varnum is the store variable
that receives the return value, stored as return_addr on the frame
itself. Also handle flags bit 4 (discard result → return_addr=None).
2026-02-10 12:39:40 -05:00
776cfba021
Pad restored local vars to 15 slots
The Quetzal save format only stores the number of local vars the
routine declared, but the runtime indexes local_vars[0..14] for any
variable access. Restored routines had short lists, causing IndexError
on the first command after restore.
2026-02-10 12:00:15 -05:00
1ee89e5e3c
Log full traceback on interpreter crash
The error handler only captured str(e), losing the stack trace.
Now logs the full traceback so crashes are actually debuggable.
2026-02-10 12:00:10 -05:00
65a080608a
Fix stack manager references after Quetzal restore
QuetzalParser._parse_stks() creates a new ZStackManager and sets it on
zmachine._stackmanager, but ZCpu._stackmanager and ZOpDecoder._stack
still pointed to the old empty stack. After restore, all stack ops
(local var reads, routine returns, stack pops) used the wrong stack,
causing the interpreter to crash on the first command.
2026-02-10 11:51:50 -05:00
15e1d807aa
Move z-machine restore before interpreter thread start
Replaces the async _do_restore() (called after thread launch) with a
synchronous _try_restore() called before the thread starts. This
eliminates the race condition where restore mutates z-machine state
while the interpreter thread is running.

The restore prefix message is now part of start()'s return value
instead of being sent separately in play.py.
2026-02-10 11:51:45 -05:00
7c1d1efcdb
Wire embedded z-machine interpreter into MUD mode stack
EmbeddedIFSession runs the hybrid interpreter in a daemon thread,
bridged to the async MUD loop via threading.Event synchronization.
.z3 files use the embedded path; other formats fall back to dfrotz.

- MUD ZUI components: MudScreen (buffered output), MudInputStream
  (thread-safe input), MudFilesystem (quetzal saves), NullAudio
- save/restore via QuetzalWriter/QuetzalParser and :: escape commands
- state inspection: get_location_name(), get_room_objects()
- error reporting for interpreter crashes
- fix quetzal parser bit slice bug: _parse_stks used [0:3] (3 bits,
  max 7 locals) instead of [0:4] (4 bits, max 15) — Zork uses 15
2026-02-10 11:18:16 -05:00
b0fb9b5e2c
Wire op_restore to QuetzalParser and filesystem
Implement V3 restore opcode:
- Add QuetzalParser.load_from_bytes() to parse save data from memory
- Wire op_restore to call filesystem.restore_game() and parse result
- Validate IFhd matches current story (release/serial/checksum)
- Restore dynamic memory, call stack, and program counter
- Branch true on success, false on failure/cancellation

Fix IFF chunk padding bug:
- Add padding byte to odd-length chunks in QuetzalWriter
- Ensures proper chunk alignment for parser compatibility

Add comprehensive tests:
- Branch false when filesystem returns None
- Branch false without zmachine reference
- Branch true on successful restore
- Verify memory state matches saved values
- Handle malformed save data gracefully
2026-02-10 10:13:45 -05:00
a5053e10f2
Wire op_save to QuetzalWriter and filesystem
Implement full save functionality for V3 z-machine:
- Fixed QuetzalWriter._generate_anno_chunk() to return bytes
- Added QuetzalWriter.generate_save_data() to produce IFF container
- Updated QuetzalWriter.write() to use new method and binary mode
- Added zmachine reference to ZCpu for QuetzalWriter access
- Added _program_counter property to ZCpu for Quetzal access
- Implemented op_save to call QuetzalWriter and filesystem
- Updated tests for op_save (success, failure, IFF validation)
- Added filesystem mock to MockUI for testing
- Added _call_stack to MockStackManager for QuetzalWriter

All tests pass. Save now generates valid IFF/FORM/IFZS data with
IFhd, CMem, Stks, and ANNO chunks.
2026-02-10 10:13:45 -05:00
2b8c177977
Fix off-by-one in QuetzalParser return_pc parsing 2026-02-10 10:13:33 -05:00
0c6eadb0da
Implement QuetzalWriter._generate_ifhd_chunk()
The IFhd chunk contains 13 bytes of metadata identifying the story
and current execution state:
- Release number (2 bytes) from header
- Serial number (6 bytes) from header
- Checksum (2 bytes) from header
- Program counter (3 bytes) from CPU state

This allows save files to be validated against the story file.
2026-02-10 09:47:24 -05:00