The server never proactively offered GMCP or MSDP to clients, so
telnetlib3 logged "cannot send MSDP without negotiation" every second.
Now the server sends WILL GMCP and WILL MSDP on connection, and
send_msdp_vitals checks negotiation state before attempting to send.
Implements Phase 7 foundation:
- gmcp.py module with package builders for Char.Vitals, Char.Status,
Room.Info, Room.Map, and MSDP vitals
- Player helper methods send_gmcp() and send_msdp() for convenience
- Full test coverage for all GMCP/MSDP functions and edge cases
Zones can now define spawn rules in TOML:
- [[spawns]] sections specify mob type, max count, and respawn timer
- SpawnRule dataclass stores configuration
- load_zone() parses spawn rules from TOML
- Added example spawn rules to treehouse zone (squirrel, crow)
This is configuration infrastructure only - actual spawning logic
will be handled by the game loop in a future phase.
Implements the new player funnel with two tutorial zones:
- Flower: 7x7 sealed zone with translucent petals, spawn at center
- Treehouse: 20x15 platform zone with rope ladder and branch exits
Both zones are bounded (non-toroidal) and include portals for progression.
Zone TOML files can now define portals using [[portals]] sections.
Each portal specifies coordinates (x, y), a target (zone_name:x,y),
and a label. Optional aliases are supported. Portals are
automatically created and placed in the zone when it loads.
Thing templates can now define verbs in TOML using [verbs] section with
module:function references. Verbs are resolved at spawn time and bound
to the spawned object instance using functools.partial. Works for both
Thing and Container instances.
Implements unlock_handler that checks for a key in player inventory
and unlocks containers. Tests cover error cases (non-container,
not locked, no key), success case, key aliasing, and state preservation.
Implements a TDD-built 'use' command that lets players invoke
object verbs with optional targets:
- use X - calls X's use verb
- use X on Y - calls X's use verb with Y as args
- Proper error messages for missing objects/verbs
- Tests cover all edge cases including inventory/ground search
Also fixes type checking issue in verb dispatch where get_verb
could return None.
Implements a global examine/ex command that shows detailed descriptions
of objects. Searches inventory first, then ground at player position.
Works with Things, Containers, and Mobs.
Verbs let any Object have interactive handlers players can trigger.
Uses @verb decorator to mark methods that auto-register on instantiation.
- Object._verbs dict stores verb name to async handler mapping
- Object.register_verb(), get_verb(), has_verb() API
- @verb decorator marks methods with _verb_name attribute
- __post_init__ scans for decorated methods and registers them
- find_object() helper searches inventory then ground by name/alias
- Bound methods stored in _verbs (self already bound)
- Works on Object and all subclasses (Thing, Entity, etc)
- 18 tests covering registration, lookup, decoration, inheritance
Look command now displays portals separately from ground items.
Portals at the player's position are shown after ground items with
the format "Portals: name1, name2". This separates portals from
regular items since they serve a different purpose in gameplay.
Implements portal-based zone transitions with the enter command.
Players can enter portals at their position to move to target zones
with specified coordinates. Includes departure/arrival messaging to
nearby players and automatic look output in the destination zone.
Portals are matched by partial name or exact alias match.
Extended ThingTemplate with optional container fields (capacity, closed, locked).
When a template includes capacity, spawn_thing now creates a Container instead
of a regular Thing.
Added two example container templates:
- chest.toml: non-portable, capacity 5, starts closed
- sack.toml: portable, capacity 3, starts open
Containers now display their state when viewed:
- Closed containers show "(closed)"
- Open empty containers show "(open, empty)"
- Open containers with items show "(open, containing: item1, item2)"
This applies to both ground items in the look command and inventory items.
Added _format_thing_name helper to both look.py and things.py to handle
the display formatting consistently.
Implements load_zone() and load_zones() functions to parse zone
definitions from TOML files. Wires zone loading into server startup
to register all zones from content/zones/ directory. Updates player
zone lookup to use the registry instead of hardcoded overworld check.
Includes tavern.toml as first hand-built interior zone (8x6 bounded).
Implements a module-level zone registry for looking up zones by name.
Includes register_zone() and get_zone() functions with comprehensive
tests covering single/multiple zones, unknown lookups, and overwrites.
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.
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.
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.
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.
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.
- 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
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.
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.
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.
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().
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.
Implements Phase 4 of the z-machine compatibility plan.
Creates automated regression tests that smoke-test all supported games
(V3, V5, V8) by loading each story, executing basic commands, and verifying
the interpreter doesn't crash.
Key features:
- Parametrized test covering 7 games (zork1, curses, photopia, Tangle,
shade, LostPig, anchor)
- QuietScreen class that disables [MORE] prompts for unattended testing
- AutoInputStream that auto-feeds commands then exits cleanly
- Tests verify: no crashes, unimplemented opcodes, and minimum instruction count
- All tests pass in ~2 seconds
Tests skip gracefully if story files aren't present, making this safe to
run in CI or on systems without all game files.
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).
- _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
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).
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).
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.
Unit tests for MUD UI components (screen, input stream, filesystem)
and integration tests with real zork1.z3 (session lifecycle, escape
commands, save/restore round-trip, state inspection).
Verifies complete save/restore pipeline by generating save data with
QuetzalWriter and restoring it with QuetzalParser. Tests cover:
- Basic round-trip with memory, stack, and PC restoration
- Multiple nested call frames
- Preservation of unchanged memory bytes (run-length encoding)
- Empty stack (no routine frames)
Each test confirms that state modified after save is correctly
restored to original values from the save data.
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
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.
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.
Adds comprehensive test coverage for three newly implemented Z-machine opcodes:
- op_test: Tests bitmap flag checking with all flags set, some missing, zero
flags, and identical values
- op_verify: Tests checksum verification with matching and mismatched checksums
- op_get_child: Tests getting first child of object with and without children
Also extends MockMemory with generate_checksum() and MockObjectParser with
get_child() to support the new tests.
Spec fixes: implement op_test (bitwise AND branch), add missing branch
to op_get_child, handle call-to-address-0 as no-op in op_call/_call.
I/O fixes: correct keyboard API (keyboard_input.read_line), non-TTY
fallbacks in trivialzui, stdout flush for immediate output. Graceful
handling of unmapped ZSCII characters. Add instruction trace buffer
for debugging.
op_verify now performs actual checksum validation against the header
instead of raising NotImplemented. ZLexer is injected into ZCpu and
sread tokenizes input into the parse buffer per the V3 spec.
Implements op_print_addr, op_print_num, op_ret, and op_show_status following
TDD approach with tests first. Each opcode now properly decodes/prints text,
handles signed numbers, returns from routines, or acts as a no-op as appropriate.
Implemented 10 opcodes for object tree manipulation: get_sibling,
test_attr, set_attr, clear_attr, jin, remove_obj, print_obj,
get_prop_addr, get_next_prop, and get_prop_len.
Added 6 supporting methods to ZObjectParser: set_attribute,
clear_attribute, remove_object, get_property_data_address,
get_next_property, and get_property_length.
Fixed bug in insert_object where sibling chain walk never advanced
the prev pointer, potentially causing infinite loops.
Added 16 unit tests with MockObjectParser to verify CPU opcode
behavior. All tests passing.
When starting an IF game, check for existing save file and restore
if present. Shows 'restoring saved game...' message and broadcasts
restored game state to spectators.
Also cleaned up redundant tests that didn't properly mock the
auto-save functionality now present in ::quit and stop().
Helper function sends messages to all players at the same x,y coordinates
as the source player, skipping the source player themselves. Used for IF
spectator broadcasting.
Tests verify:
- Spectators at same location see IF output with player name header
- Spectators at different locations see nothing
- Game start intro broadcasts to spectators
- broadcast_to_spectators skips the playing player
- Multiple spectators all receive messages
Tests currently fail as broadcast_to_spectators not yet implemented.
TDD implementation of IFSession that manages a dfrotz subprocess.
IFResponse dataclass follows the editor pattern with output/done fields.
IFSession handles spawning dfrotz, routing input, and detecting the prompt.
Escape commands (::quit, ::help) are handled without sending to dfrotz.
Phase 6: spawn command creates mobs at player position from loaded
templates. Server loads mob templates from content/mobs/ at startup,
injects world into combat/commands module, and runs process_mobs()
each game loop tick after process_combat().
Phase 5: process_mobs() runs each tick, handling mob attack and defense
decisions. Mobs pick random attacks from their move list when IDLE,
swap roles if needed, and attempt defense during TELEGRAPH/WINDOW with
a 40% chance of correct counter. 1-second cooldown between actions.
Training dummies with empty moves never fight back.
Phase 4: when combat ends, determine winner/loser. If the loser is a
Mob, despawn it and send a victory message to the winner. If the loser
is a Player fighting a Mob, send a defeat message instead.
Phase 3: look command now collects alive mob positions using the same
wrapping-aware relative position calc as players, and renders them as *
with the same priority as other players (after @ but before effects).
Phase 2: do_attack now searches the mobs registry after players dict
when resolving a target name. Players always take priority over mobs
with the same name. World instance injected into combat/commands module
for wrapping-aware mob proximity checks.
Phase 1 of fightable mobs: MobTemplate dataclass loaded from TOML,
global mobs list, spawn_mob/despawn_mob/get_nearby_mob with
wrapping-aware distance. Mob entity gets moves and next_action_at fields.
The variant handler now supports prefix matching for directional variants.
This allows 'pa hi' to match 'parry high', 'pa lo' to match 'parry low', etc.
Implementation:
- First tries exact match on variant key
- Falls back to prefix matching if no exact match
- Returns unique match if exactly one variant starts with the prefix
- Shows disambiguation message if multiple variants match
- Shows error with valid options if no variants match
Tests cover exact match, prefix match, ambiguous prefix, no match,
single-char prefix, case-insensitivity, and preservation of target args.
Defense moves now asyncio.sleep for timing_window_ms instead of using
a cooldown field. Input queues naturally since the per-player loop is
sequential. Outside combat shows "parry the air!" flavor text.
- Set variant defense registration to mode="*" (both attacks and defenses)
- Strengthen telegraph switch test to verify new move's telegraph text
- Remove unused punch parameter from four idle timeout tests
- Use single time.monotonic() call in attack() method
Encounters track last_action_at (updated on attack and defend). If 30
seconds pass with no actions, combat fizzles out with a message to both
players and combat mode is popped. start_encounter initializes the
timestamp so fresh encounters don't immediately timeout.
Defenses now work outside combat mode with stamina cost, recovery lock
(based on timing_window_ms), and broadcast to nearby players. Lock
prevents spamming defenses — you commit to the move. Stamina deduction
moved from encounter.defend() to do_defend command layer. Defense
commands registered with mode="*" instead of "combat".
Attacker can change their move mid-telegraph or mid-window without
resetting the timer. Old move's stamina is refunded, new move charged.
Defender gets a fresh telegraph on switch. Feedback says "switch to"
instead of "use" when swapping attacks.
resolve() returns ResolveResult dataclass with attacker_msg, defender_msg,
damage, countered, and combat_ended fields. process_combat is now async
and sends messages to both participants on resolve. Counter, hit, and
slam messages give each player their own perspective on what happened.
The DREAMBOOK always described "punch right/left [target]" as one command
with a direction argument, but the implementation had separate TOML files
and multi-word command names that the dispatcher couldn't reach (it only
matches the first word). Aliases like "pr" also couldn't pass targets
because the shared handler tried to re-derive the move from args.
Changes:
- Merge punch_left/right, dodge_left/right, parry_high/low into single
TOML files with [variants] sections
- Add command/variant fields to CombatMove for tracking move families
- load_move() now returns list[CombatMove], expanding variants
- Handlers bound to moves via closures at registration time:
variant handler for base commands (punch → parses direction from args),
direct handler for aliases and simple moves (pr → move already known)
- Core logic in do_attack/do_defend takes a resolved move
- Combat doc rewritten as rst with architecture details
- Simplify mud.tin aliases (pr/pl/etc are built-in MUD commands now)
Integrates the Editor class into the MUD server's shell loop, allowing
players to enter and use the text editor from the game.
Changes:
- Add editor field to Player dataclass
- Modify shell input loop to check player mode and route to editor
- Add edit command to enter editor mode from normal mode
- Use inp (not command.strip()) for editor to preserve indentation
- Show line-numbered prompt in editor mode
- Pop mode and clear editor when done=True
- Add comprehensive integration tests
- Fix test isolation issue in test_movement_updates_position
Parse MTTS from telnetlib3 writer during connection and store capabilities
on Player.caps field. Add convenience property Player.color_depth that
delegates to caps.color_depth for easy access by rendering code.
Changes:
- Add caps field to Player with default 16-color ANSI capabilities
- Parse MTTS in server shell after Player creation using parse_mtts()
- Add Player.color_depth property for quick capability checks
- Add tests verifying Player caps integration and color_depth property
Extended ansi.py with fg_256, bg_256, fg_rgb, and bg_rgb functions
for generating 256-color and truecolor escape sequences. All functions
include value clamping to valid ranges (0-255).
Parses MTTS bitfield values from telnetlib3 ttype3 into a ClientCaps dataclass.
Includes color_depth property that returns the best available color mode
(truecolor, 256, or 16) based on client capabilities.
Player state is now saved when using the quit command or when the connection
is lost unexpectedly. Ensures progress is preserved even without auto-save.
Adds login/registration prompts on connection, database initialization on
startup, and periodic auto-save every 5 minutes in the game loop. Player
state is now tied to authenticated accounts.
Implements account management with password hashing (pbkdf2_hmac with SHA256)
and constant-time comparison. Includes player state serialization for position
and inventory persistence across sessions.
Scan content/commands/ for .toml files at startup and register them
as commands alongside Python-defined ones. Two flavors: handler-based
(points to a Python callable via module:function) and message-based
(auto-generates a handler from inline text). Includes example MOTD
command, type validation, error logging, and full test coverage.
Each cloud in the trail gets a slightly longer TTL than the one
before it (0.15s stagger). The origin cloud dissolves first, then
each subsequent tile follows. Two consecutive flights produce a
trail where the oldest clouds are already gone.
fly with no args toggles flying on/off. Movement commands (fly east,
etc) only work while airborne. "You aren't flying." if you try to
move without toggling on first. Player.flying field tracks the state.
fly <direction> moves the player 5 tiles, ignoring terrain. Leaves
a trail of bright white ~ clouds that fade after 2 seconds. Effects
system supports arbitrary timed visual overlays on the viewport.
TinTin aliases: fn/fs/fe/fw/fne/fnw/fse/fsw.
Tileable Perlin noise: each octave wraps its integer grid coordinates
with modulo at the octave's frequency, so gradients at opposite edges
match and the noise field is continuous across the boundary.
Coarse elevation grid interpolation wraps instead of padding boundary
cells. Rivers can flow across world edges. All coordinate access
(get_tile, is_passable, get_viewport) wraps via modulo. Movement,
spawn search, nearby-player detection, and viewport relative positions
all handle the toroidal topology.
1000x1000 tile world generated deterministically from a seed using
layered Perlin noise. Terrain derived from elevation: mountains,
forests, grasslands, sand, water, with rivers traced downhill from
peaks. ANSI-colored viewport centered on player.
Command system with registry/dispatch, 8-direction movement (n/s/e/w
+ diagonals), look/l, quit/q. Players see arrival/departure messages.
Set connect_maxwait=0.5 on telnetlib3 to avoid the 4s CHARSET
negotiation timeout — MUD clients reject CHARSET immediately via MTTS.