Compare commits

..

No commits in common. "f450952e04265e37db2d5185527e6a91668fd70e" and "4991c87104931f94313d1dc3f7be62f66b779a00" have entirely different histories.

118 changed files with 2977 additions and 4003 deletions

View file

@ -1 +0,0 @@
@.claude/CLAUDE.md

View file

@ -2,8 +2,7 @@ name = "dodge"
description = "a quick sidestep to evade incoming attacks" description = "a quick sidestep to evade incoming attacks"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
active_ms = 800 timing_window_ms = 800
recovery_ms = 2700
[variants.left] [variants.left]

View file

@ -3,7 +3,6 @@ description = "crouch down to avoid high attacks, leaving you vulnerable to low
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
telegraph = "" telegraph = ""
active_ms = 600 timing_window_ms = 700
recovery_ms = 2900
damage_pct = 0.0 damage_pct = 0.0
countered_by = [] countered_by = []

View file

@ -3,7 +3,6 @@ description = "leap upward to evade low attacks, exposing you to high strikes"
move_type = "defense" move_type = "defense"
stamina_cost = 4.0 stamina_cost = 4.0
telegraph = "" telegraph = ""
active_ms = 600 timing_window_ms = 700
recovery_ms = 2900
damage_pct = 0.0 damage_pct = 0.0
countered_by = [] countered_by = []

View file

@ -2,8 +2,7 @@ name = "parry"
description = "deflect an attack with precise timing, redirecting force rather than absorbing it" description = "deflect an attack with precise timing, redirecting force rather than absorbing it"
move_type = "defense" move_type = "defense"
stamina_cost = 4.0 stamina_cost = 4.0
active_ms = 400 timing_window_ms = 1200
recovery_ms = 3100
[variants.high] [variants.high]

View file

@ -2,7 +2,7 @@ name = "punch"
description = "a close-range strike with the fist, quick but predictable" description = "a close-range strike with the fist, quick but predictable"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 3000 timing_window_ms = 1800
damage_pct = 0.15 damage_pct = 0.15
[variants.left] [variants.left]

View file

@ -6,7 +6,7 @@ telegraph = "{attacker} shifts {his} weight back..."
announce = "{attacker} launch{es} a roundhouse kick at {defender}!" announce = "{attacker} launch{es} a roundhouse kick at {defender}!"
resolve_hit = "{attacker}'s roundhouse slams into {defender}!" resolve_hit = "{attacker}'s roundhouse slams into {defender}!"
resolve_miss = "{defender} counter{s} {attacker}'s roundhouse!" resolve_miss = "{defender} counter{s} {attacker}'s roundhouse!"
hit_time_ms = 3000 timing_window_ms = 2000
damage_pct = 0.25 damage_pct = 0.25
countered_by = ["duck", "parry high", "parry low"] countered_by = ["duck", "parry high", "parry low"]

View file

@ -6,7 +6,7 @@ telegraph = "{attacker} drops low..."
announce = "{attacker} sweep{s} at {defender}'s legs!" announce = "{attacker} sweep{s} at {defender}'s legs!"
resolve_hit = "{attacker}'s sweep catches {defender}'s legs!" resolve_hit = "{attacker}'s sweep catches {defender}'s legs!"
resolve_miss = "{defender} jump{s} over {attacker}'s sweep!" resolve_miss = "{defender} jump{s} over {attacker}'s sweep!"
hit_time_ms = 3000 timing_window_ms = 1800
damage_pct = 0.18 damage_pct = 0.18
countered_by = ["jump", "parry low"] countered_by = ["jump", "parry low"]

View file

@ -1,42 +0,0 @@
name = "admin-commands"
title = "admin command reference"
admin = true
body = """
quick reference for admin @ commands.
world building
@zones list all zones
@goto <zone> teleport to zone spawn point
@dig <name> <w> <h> create new zone
@paint toggle terrain paint mode
@save save current zone to file
@place <thing> place a thing at your position
spawning
spawn <mob> spawn a mob at your tile
content management
reload <name> hot-reload TOML definition
edit <move> edit combat move in-game
help topics
@help list all help topics
@help create create new help topic
@help edit <topic> edit existing topic
@help remove <topic> remove topic
player management
@promote <player> grant admin status
@demote <player> revoke admin status
paint mode (after @paint)
p toggle painting on/off
brush <char> set brush character
movement paint or survey while moving
other useful commands
power up/down/<num> manage power level
snapneck <target> instant kill unconscious target
see also: help building, help spawning, help editing, help content
"""

View file

@ -1,38 +0,0 @@
name = "building"
title = "building zones"
admin = true
body = """
zones are spatial containers with terrain grids. this is the full workflow
for creating and editing zones.
creating zones
@dig <name> <w> <h> create a blank zone and teleport there
example: @dig castle 20 15
editing terrain
@paint toggle paint mode on/off
p toggle painting (while in paint mode)
brush <char> set the brush character
movement paint or survey as you move
paint mode lets you edit terrain tile-by-tile. after entering paint mode:
- move around to survey
- set a brush character (. for grass, # for wall, etc)
- press 'p' to start painting
- move to paint tiles
- press 'p' again to stop painting
placing objects
@place <thing> place a thing at your position
available things: bookshelf, chair, chest, fountain, lamp, nail,
painting, plank, rock, rug, sack, table
persisting changes
@save save current zone to content/zones/<name>.toml
navigating
@zones list all zones
@goto <zone> teleport to a zone's spawn point
zones are saved as TOML files in content/zones/ and persist across restarts.
"""

View file

@ -1,52 +0,0 @@
name = "combat"
title = "combat system"
body = """
starting a fight
use any attack move on a target to start combat. you need to be on the same
tile and altitude (both flying or both grounded). example: punch left goblin
stamina
all moves cost stamina. attacks and defenses both drain your stamina pool.
when you run out, you can't use moves until it regenerates. stamina refills
slowly over time.
combat timing
attacks have three phases:
telegraph (enemy sees your wind-up message)
timing window (defender has time to counter)
resolve (damage is dealt or countered)
telegraph time is built into the hit_time. for example, a punch has a
3000ms hit time - the telegraph goes out immediately, then after 3000ms
the attack resolves.
defenses and counters
each attack can be countered by specific defenses. timing matters - your
defense must be active when the attack resolves. defenses have:
active window (how long the defense can counter attacks)
recovery time (cooldown before you can defend again)
correct defense (counter) = 0 damage. wrong defense = normal damage.
no defense at all = 1.5x damage.
attack switching
during the telegraph phase (PENDING state), you can switch to a different
attack. your old stamina cost is refunded and the new attack starts fresh.
use this to adapt if your opponent reads your move.
idle timeout
if no damage lands for 30 seconds, combat fizzles out automatically.
this prevents stalled encounters from blocking the world.
available moves
attacks: punch (left/right), roundhouse, sweep
defenses: dodge (left/right), parry (high/low), duck, jump
use 'skills' to see all moves, 'help <move>' for details on timing and
counters. some moves are locked until you meet requirements.
altitude and flying
flying makes you immune to ground-based attacks and vice versa. use this
tactically - 'fly' to take off, 'fly' again to land. attacks auto-miss
when attacker and defender are at different altitudes.
"""

View file

@ -1,34 +0,0 @@
name = "containers"
title = "inventory and containers"
body = """
pick up items, carry them in your inventory, and store them
in containers like chests and bags.
basic commands
get <item> pick up an item from the ground
take <item> same as get
drop <item> drop an item at your feet
inventory list what you're carrying
i short for inventory
containers
containers are items that hold other items.
they can be open or closed, and some can be locked.
container commands
open <container> open a container
close <container> close a container
put <item> in <container> store an item in a container
get <item> from <container> take an item from a container
get all from <container> take everything from a container
where to find containers
containers can be on the ground or in your inventory.
put commands work with either location.
closed containers block access to their contents.
targeting
you can use partial names - 'get pla' matches 'plank'.
ordinals work too - 'get 2nd sword' if there are multiple.
container state shows in inventory - (open, empty) or (closed).
"""

View file

@ -1,43 +0,0 @@
name = "content"
title = "content system"
admin = true
body = """
the mud loads content from TOML files in the content/ directory.
content can be edited and hot-reloaded without restarting the server.
directory structure
content/
commands/ content commands (motd, etc)
combat/ combat move definitions
help/ help topics
mobs/ mob templates
things/ thing templates
zones/ zone definitions
recipes/ crafting recipes
dialogue/ npc dialogue trees
hot-reloading
reload <name> reload a TOML definition
example: reload punch
example: reload motd
reload works for:
- combat moves (content/combat/)
- content commands (content/commands/)
creating new content
- write a .toml file in the appropriate directory
- restart server to load it initially
- use reload to test changes after editing
editing content
edit <move> edit in-game (see 'help editing')
or edit files directly with your text editor
content format
all content files use TOML format. combat moves follow a specific
schema defined in docs/how/combat.rst. content commands use a simpler
format with name, handler/message, and optional fields.
see also: help editing, help admin-commands
"""

View file

@ -1,25 +0,0 @@
name = "crafting"
title = "crafting items from recipes"
body = """
combine ingredients to create new items using recipes.
commands
craft <recipe> craft an item if you have the ingredients
recipes list all available recipes
recipes <name> view details for a specific recipe
how it works
recipes define what ingredients are needed and what you get.
you need all ingredients in your inventory to craft.
ingredients are consumed when you craft.
example
wooden table requires 3 planks and 2 nails.
carrying those items? type 'craft wooden table'.
the ingredients disappear and you get a table.
finding recipes
use 'recipes' to see what you can make.
use 'recipes <name>' to see what ingredients you need.
prefix matching works - 'craft wood' matches 'wooden table'.
"""

View file

@ -1,32 +0,0 @@
name = "editing"
title = "in-game toml editor"
admin = true
body = """
edit command opens the in-game text editor for TOML content files.
usage
edit blank editor
edit <move> edit a combat move TOML
example: edit punch
the editor supports:
- syntax highlighting for TOML
- search and replace
- undo/redo
- color depth detection (256 color or 16 color)
editor commands (in editor mode)
:h show editor help
:w save and continue editing
:wq save and exit
:q quit without saving
editing combat moves
when you edit a combat move, the editor loads the TOML file from
content/combat/<move>.toml. saves write directly to the file.
after editing, use 'reload <move>' to hot-reload the changes without
restarting the server.
see also: help content, help admin-commands
"""

View file

@ -1,28 +0,0 @@
name = "getting-started"
title = "getting started"
body = """
welcome to the mud. here's how to play.
looking around
look see the world around you
look <thing> examine something in detail
moving
north / n move north (also: south, east, west)
northwest / nw diagonal movement (also: ne, sw, se)
finding commands
commands list all available commands
skills list combat moves
help <command> get help on a specific command
basics
say <message> talk to nearby players
tell <name> <msg> private message a player
inventory see what you're carrying
get <thing> pick something up
drop <thing> put something down
the world persists. your position and inventory save when you log out.
type 'quit' to disconnect safely.
"""

View file

@ -1,30 +0,0 @@
name = "interactive-fiction"
title = "interactive fiction"
body = """
you can play classic text adventure games from inside the mud.
other players in the room will see your gameplay on a virtual terminal.
starting a game
play list available games
play <game> start a game (e.g. "play zork")
available games
zork1 the great underground empire
curses a time-travel puzzle adventure
photopia a story about light and memory
shade a one-room mystery
anchor a nautical puzzle game
tangle a nature-themed exploration
lostpig find the pig
escape commands
::quit exit the game (auto-saves first)
::save force save to disk
::help show escape commands
your progress saves automatically. when you return to a game,
it restores from where you left off.
game commands use normal IF syntax (open mailbox, go north, etc).
anything not starting with :: goes to the game interpreter.
"""

View file

@ -1,26 +0,0 @@
name = "mobs"
title = "creatures and NPCs"
body = """
mobs are creatures that move around zones. some are friendly NPCs,
others are hostile and will fight you.
types
hostile mobs attack on sight or when provoked
friendly NPCs have schedules, can be talked to
training dummies stationary targets for practicing combat
interacting with NPCs
some mobs have an npc_name and follow daily schedules.
the librarian, for example, works during the day and rests at night.
approach them when they're active to start conversations.
combat
hostile mobs will engage you in combat when you're nearby.
see 'help combat' for how to fight.
defeating mobs may drop loot - check corpses after battle.
behavior
mobs can wander, patrol routes, flee when threatened, or stay put.
friendly NPCs transition between states based on their schedule.
some mobs stay within a home region and won't chase you far.
"""

View file

@ -1,40 +0,0 @@
name = "movement"
title = "movement and navigation"
body = """
basic directions
move with cardinal and diagonal directions:
n, s, e, w (or north, south, east, west)
ne, nw, se, sw (northeast, northwest, southeast, southwest)
terrain
different tiles have different properties. some are passable, some aren't.
impassable terrain (like water ~ or mountains ^) blocks movement unless
you have special abilities.
zones
the world is divided into zones - spatial areas with their own terrain grids.
most zones are toroidal, meaning if you walk off the east edge you wrap to
the west edge (same for north/south). this makes zones feel continuous.
portals
some tiles have portals that transport you to other zones. to use a portal,
type 'enter <portal>' when standing on the portal tile. portals are one-way
unless there's a return portal at the destination.
flying
'fly' toggles flight mode. while flying:
- you can move over impassable terrain like water and mountains
- 'fly <direction>' moves you 5 tiles in that direction
- you leave cloud trails that fade over time
- you're immune to ground-based attacks (altitude matters in combat)
- 'fly' again to land
home command
'home' teleports you to your personal zone (a private space just for you).
'home return' takes you back to where you were before going home.
your return point is saved when you leave, so you can bounce back and forth.
looking around
'look' shows your current viewport and what's nearby.
the viewport centers on you and shows terrain, other players, mobs, and items.
"""

View file

@ -1,44 +0,0 @@
name = "skills"
title = "combat skills"
body = """
what are skills?
skills are your combat moves - both attacks and defenses. each skill has
different timing, stamina cost, and tactical use. you unlock more skills
by defeating enemies and completing challenges.
seeing your skills
'skills' lists all available combat moves (attacks and defenses).
'help <skill>' shows detailed info: stamina cost, timing, counters.
attack skills
punch left/right quick jab, 5 stamina, 3000ms hit time, 15% damage
roundhouse heavy kick, 8 stamina, 3000ms hit time, 25% damage
sweep low kick, 6 stamina, 3000ms hit time, 18% damage
attacks always need a target. start combat with: punch left goblin
defense skills
dodge left/right sidestep, 3 stamina, 800ms window, 2700ms recovery
parry high/low deflection, 4 stamina, 400ms window, 3100ms recovery
duck crouch, 3 stamina, 600ms window, 2900ms recovery
jump leap, 4 stamina, 600ms window, 2900ms recovery
defenses work both in and out of combat. you can practice them anytime.
stamina costs
every skill drains stamina. attacks and defenses both cost stamina when used.
stamina regenerates slowly over time. if you run out, you can't use skills
until it refills. manage your stamina carefully in long fights.
locked skills
some skills require you to meet conditions before you can use them:
roundhouse: defeat 5 enemies
sweep: defeat 3 goblins
locked skills show [LOCKED] in their help text with unlock requirements.
learning more
'help combat' explains how timing, counters, and combat flow works.
'help <move>' shows specific details for any attack or defense.
experiment with different combinations to find what works for you.
"""

View file

@ -1,27 +0,0 @@
name = "spawning"
title = "spawning mobs and things"
admin = true
body = """
spawn command creates mobs at your current position.
usage
spawn <mob_type> spawn a mob at your tile
available mobs
goblin hostile creature
librarian friendly npc with dialogue
training_dummy practice target
spawned mobs appear on your current tile and persist until killed or
despawned. mobs with loot tables drop corpses when killed.
placing things
@place <thing> place a thing (see 'help building')
available things
bookshelf, chair, chest, fountain, lamp, nail, painting, plank,
rock, rug, sack, table
things are static objects. some are containers (chest, sack), others
are furniture or decorations.
"""

View file

@ -1,31 +0,0 @@
name = "world"
title = "zones, terrain, and navigation"
body = """
the world is made of zones - spatial areas with terrain grids.
zones can be toroidal (wrapping at edges) or bounded.
terrain types
each zone has a grid of terrain tiles with different properties.
some tiles are impassable (mountains ^, water ~).
the overworld is procedurally generated and wraps seamlessly.
zones
zones are separate areas - the overworld, dungeons, interiors.
each zone has a spawn point where you appear when entering.
zones can contain mobs, items, portals, and players.
navigation
move with cardinal directions: north, south, east, west.
use 'look' to see terrain and objects around you.
toroidal zones wrap - walk far enough and you loop back.
portals
portals connect zones (doorways, stairs, gates).
walk onto a portal tile and type 'enter <portal>'.
you'll teleport to the target zone at specific coordinates.
player homes
type 'home' to teleport to your personal zone.
this is a private space you can furnish and customize.
type 'home return' to go back to where you were.
"""

View file

@ -241,7 +241,7 @@ name = "punch"
description = "a close-range strike with the fist, quick but predictable" description = "a close-range strike with the fist, quick but predictable"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 1800 timing_window_ms = 1800
damage_pct = 0.15 damage_pct = 0.15
[variants.left] [variants.left]
@ -266,8 +266,7 @@ name = "dodge"
description = "a quick sidestep to evade incoming attacks" description = "a quick sidestep to evade incoming attacks"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
active_ms = 800 timing_window_ms = 800
recovery_ms = 2700
[variants.left] [variants.left]
@ -280,9 +279,7 @@ recovery_ms = 2700
- `description` - shown in help/skills - `description` - shown in help/skills
- `move_type` - "attack" or "defense" - `move_type` - "attack" or "defense"
- `stamina_cost` - stamina consumed per use - `stamina_cost` - stamina consumed per use
- `hit_time_ms` - (attacks) time in ms from initiation to impact - `timing_window_ms` - how long the window is open (attacks: time to defend, defenses: commitment time)
- `active_ms` - (defenses) how long defense blocks once activated, in ms
- `recovery_ms` - (defenses) lockout after active window ends, in ms
- `damage_pct` - fraction of attacker's PL dealt as damage (attacks only) - `damage_pct` - fraction of attacker's PL dealt as damage (attacks only)
- `[variants.X]` - each variant becomes a separate command: "punch left", "punch right" - `[variants.X]` - each variant becomes a separate command: "punch left", "punch right"
- POV templates: `{attacker}`, `{defender}`, `{s}` (third person s), `{es}`, `{his}`, `{him}`, `{y|ies}` (irregular conjugation) - POV templates: `{attacker}`, `{defender}`, `{s}` (third person s), `{es}`, `{his}`, `{him}`, `{y|ies}` (irregular conjugation)

View file

@ -50,21 +50,29 @@ the state machine
:: ::
IDLE → PENDING → RESOLVE → IDLE IDLE → TELEGRAPH → WINDOW → RESOLVE → IDLE
IDLE: IDLE:
no active move. attacker can initiate attack. defender can do nothing no active move. attacker can initiate attack. defender can do nothing
combat-specific. combat-specific.
PENDING: TELEGRAPH:
attacker has declared a move. telegraph message sent to defender. attacker has declared a move. defender sees the telegraph message.
the move is now in flight — defender can queue a defense any time "goku winds up a right hook!"
during this phase. duration is the attack's hit_time_ms. defender can queue a defense during this phase.
duration: brief (implementation decides, not move-defined).
WINDOW:
the timing window opens. defender can still queue defense.
if defender queued correct counter during TELEGRAPH or WINDOW, they succeed.
duration: move.timing_window_ms (defined in TOML).
RESOLVE: RESOLVE:
hit_time_ms elapsed. check if defender's active defense counters the timing window closes. check if defense counters attack.
attack. calculate damage, apply stamina costs, check for knockouts calculate damage based on attacker PL, damage_pct, and defense success.
or exhaustion. return to IDLE. apply stamina costs.
check for knockouts (PL = 0) or exhaustion (stamina = 0).
return to IDLE.
entity stats entity stats
@ -197,7 +205,7 @@ moves live in content/combat/. two formats: simple and variant.
move_type = "attack" move_type = "attack"
stamina_cost = 8.0 stamina_cost = 8.0
telegraph = "{attacker} spins into a roundhouse kick!" telegraph = "{attacker} spins into a roundhouse kick!"
hit_time_ms = 600 timing_window_ms = 600
damage_pct = 0.25 damage_pct = 0.25
countered_by = ["duck", "parry high", "parry low"] countered_by = ["duck", "parry high", "parry low"]
@ -207,7 +215,7 @@ moves live in content/combat/. two formats: simple and variant.
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 800 timing_window_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.left] [variants.left]
@ -220,7 +228,7 @@ moves live in content/combat/. two formats: simple and variant.
telegraph = "{attacker} winds up a right hook!" telegraph = "{attacker} winds up a right hook!"
countered_by = ["dodge left", "parry high"] countered_by = ["dodge left", "parry high"]
shared properties (stamina_cost, hit_time_ms, damage_pct) are defined at shared properties (stamina_cost, timing_window_ms, damage_pct) are defined at
the top level. variants inherit these and can override them. variant-specific the top level. variants inherit these and can override them. variant-specific
properties (telegraph, countered_by, aliases) live under [variants.<key>]. properties (telegraph, countered_by, aliases) live under [variants.<key>].
@ -228,28 +236,13 @@ each variant produces a CombatMove with a qualified name like "punch left".
the ``command`` field tracks the base command ("punch") and ``variant`` tracks the ``command`` field tracks the base command ("punch") and ``variant`` tracks
the key ("left"). simple moves have command=name and variant="". the key ("left"). simple moves have command=name and variant="".
**defense move** (directional)::
# content/combat/dodge.toml
name = "dodge"
move_type = "defense"
stamina_cost = 3.0
active_ms = 800
recovery_ms = 2700
[variants.left]
[variants.right]
TOML field reference: TOML field reference:
- name: the command name (simple) or base command (variant) - name: the command name (simple) or base command (variant)
- move_type: "attack" or "defense" - move_type: "attack" or "defense"
- stamina_cost: stamina consumed when using this move - stamina_cost: stamina consumed when using this move
- hit_time_ms: (attacks) time in ms from initiation to impact - timing_window_ms: how long defender has to respond
- active_ms: (defenses) how long defense blocks once activated, in ms - telegraph: shown to defender during TELEGRAPH phase. {attacker} replaced
- recovery_ms: (defenses) lockout after active window ends, in ms
- telegraph: shown to defender during PENDING phase. {attacker} replaced
- damage_pct: fraction of attacker's PL dealt as damage - damage_pct: fraction of attacker's PL dealt as damage
- countered_by: list of qualified move names that counter this move - countered_by: list of qualified move names that counter this move
- aliases: short command aliases registered as standalone commands - aliases: short command aliases registered as standalone commands

View file

@ -1,151 +0,0 @@
=============================
content loading pipeline
=============================
the content pipeline converts static TOML definitions into runtime Python objects
at startup. all loading happens once before accepting player connections — no hot-reload.
startup sequence
================
from ``server.py run_server()``, in order::
1. init_db() → database
2. init_game_time() → game clock
3. World() + terrain → procedural terrain
4. Zone (overworld) → overworld from terrain
5. load_zones() → content zones (hub, tavern, etc) from content/zones/
6. load_commands() → TOML commands from content/commands/
7. load_help_topics() → help topics from content/help/
8. register_combat_commands() → combat moves from content/combat/
9. load_mob_templates() → mob definitions from content/mobs/
10. load_thing_templates() → thing/container definitions from content/things/
11. load_recipes() → crafting recipes from content/recipes/
12. load_all_dialogues() → NPC dialogue trees from content/dialogue/
common loader pattern
=====================
every content type follows the same pattern::
1. load_X(path) → parse single TOML file into dataclass
2. load_Xs(directory) → iterate .toml files, call load_X each, return dict
3. At startup: global_registry.update(load_Xs(dir))
the loader functions are synchronous. they read files, parse TOML, validate data,
and populate module-level registries. errors during load are logged but don't
crash the server (graceful degradation).
content directory structure
===========================
all content lives under ``content/``::
content/commands/ → command definitions (name, aliases, handler or message)
content/combat/ → combat moves (attacks, defenses, variants)
content/things/ → thing/container templates
content/mobs/ → mob templates with loot and schedules
content/zones/ → zone definitions (terrain grids, portals, spawns, ambient)
content/recipes/ → crafting recipes
content/dialogue/ → NPC dialogue trees
content/help/ → help topic files
content/stories/ → z-machine game files (not TOML, loaded separately)
each subdirectory contains multiple ``.toml`` files. the loader iterates them
all and merges results into a single registry.
global registries
=================
module-level dicts, populated at startup, read-only during play::
commands/__init__.py: _registry dict
combat/commands.py: combat_moves dict
things.py: thing_templates dict
mobs.py: mob_templates dict, mobs list
zones.py: zone_registry dict
crafting.py: recipes dict
commands/help.py: _help_topics dict
commands/talk.py: dialogue_trees dict
these dicts are never mutated after startup. new content requires a server restart.
handler resolution
==================
commands and thing verbs can reference Python functions via ``"module:function"``
strings. at load time, the loader uses ``importlib`` to resolve the string into
a callable.
commands can also have inline message text instead of a handler. in that case,
the loader wraps the message in a simple async handler that sends the text.
if handler resolution fails, the loader logs a warning and continues. the command
is registered but won't work (graceful degradation).
template vs instance
====================
templates are immutable definitions stored as dataclasses. spawning creates mutable
runtime objects (``Thing``, ``Mob``, etc).
templates stay in the registry forever. instances are created and destroyed during
play. when a mob dies, the template remains — you can spawn another.
this separation keeps content definitions clean and allows multiple instances of
the same template (ten wolves from one ``wolf`` template).
validation
==========
different content types validate at different levels:
dialogue trees
--------------
validate all node references at load time. if a choice points to a nonexistent node,
``load_dialogue()`` raises ``ValueError`` and the server won't start.
combat moves
------------
validate ``countered_by`` references. if a move references a nonexistent counter,
the loader logs a warning but continues. the move is still usable, just without
that counter.
zone portals
------------
parse ``"zone_name:x,y"`` target format at load time. if the format is invalid,
the portal won't work. if the zone doesn't exist yet (load order), it's fine —
the portal stores the string and runtime lookup happens when someone uses it.
code
====
startup sequence::
src/mudlib/server.py (run_server function)
loaders::
src/mudlib/commands/__init__.py (load_command, load_commands)
src/mudlib/combat/commands.py (load_combat_move, register_combat_commands)
src/mudlib/things.py (load_thing_templates)
src/mudlib/mobs.py (load_mob_template, load_mob_templates)
src/mudlib/zones.py (load_zone, load_zones)
src/mudlib/crafting.py (load_recipes)
src/mudlib/commands/help.py (load_help_topic, load_help_topics)
src/mudlib/commands/talk.py (load_dialogue, load_all_dialogues)
content directories::
content/commands/
content/combat/
content/things/
content/mobs/
content/zones/
content/recipes/
content/dialogue/
content/help/
content/stories/

View file

@ -1,86 +0,0 @@
====================
crafting and recipes
====================
the crafting system lets players combine materials to create new items. recipes
are defined in TOML files, loaded at startup, and executed via the ``craft``
command.
recipe structure
================
recipes live in ``content/recipes/`` as TOML files. each defines:
- ``name`` - unique identifier (lowercase, underscores)
- ``description`` - what you're making (shown in recipe listings)
- ``ingredients`` - list of thing template names (duplicates = need multiples)
- ``result`` - thing template name to spawn
example::
name = "wooden_table"
description = "Craft a sturdy table from planks and nails"
ingredients = ["plank", "plank", "plank", "nail", "nail"]
result = "table"
all ingredient and result names must match existing thing templates in the
``thing_templates`` registry.
loading
=======
``load_recipe(path)`` parses a single TOML file into a Recipe dataclass.
``load_recipes(dir)`` walks the directory and loads all ``.toml`` files.
the global ``recipes`` dict (keyed by name) is updated at server startup in
``server.py``. recipes are immutable after load.
crafting
========
``cmd_craft(player, args)`` executes the crafting flow:
1. parse recipe name from args (case-insensitive, prefix matching)
2. find matching recipe (error if ambiguous or not found)
3. count required ingredients via ``Counter()``
4. count available items in player inventory
5. check all ingredients present (list missing if not)
6. verify result template exists in ``thing_templates``
7. consume ingredients (remove from world)
8. spawn result via ``spawn_thing``, add to inventory
9. send success message
ingredient counting uses python's ``Counter`` to handle duplicates. "2x plank"
means the ingredients list contains ``"plank"`` twice.
browsing recipes
================
``cmd_recipes()`` shows available recipes:
- no args: lists all recipes with descriptions
- with args: shows detailed recipe for a specific name (prefix matching)
detail view shows ingredients with counts ("2x plank, 2x nail") and the result
item. ambiguous prefixes are detected and reported.
materials
=========
existing thing templates usable as materials:
- ``plank`` - wooden plank
- ``nail`` - iron nail
- ``table`` - wooden table
- ``chair`` - wooden chair
new materials require adding thing templates to ``things.py`` and
creating recipes that reference them.
code
====
- ``src/mudlib/crafting.py`` - Recipe dataclass, loading, registry
- ``src/mudlib/commands/crafting.py`` - craft and recipes commands
- ``content/recipes/`` - TOML recipe definitions
- ``src/mudlib/things.py`` - thing template registry

View file

@ -1,86 +0,0 @@
==============
visual effects
==============
the overlay system for transient visual effects like cloud trails.
overview
========
effects are temporary overlays displayed on top of terrain in the viewport.
each effect has a position, character, color, and expiration time. they're
rendered last-wins on overlap, cleaned up passively in the game loop.
data model
==========
effect dataclass
----------------
defined in ``effects.py``::
@dataclass
class Effect:
x: int
y: int
char: str
color: str # ANSI code
expires_at: float # monotonic time
global state
------------
single global list::
active_effects: list[Effect] = []
no per-zone partitioning. simple.
core functions
==============
``add_effect(x, y, char, color, ttl)``
creates effect with expiry at ``time.monotonic() + ttl``, appends to list
``get_effects_at(x, y)``
returns active effects at position, auto-filters expired ones
``clear_expired()``
in-place list mutation to drop expired effects. called once per tick
(10/sec) in ``server.py`` game loop
rendering
=========
in ``look.py``, for each viewport tile:
1. check ``get_effects_at(x, y)``
2. if effects exist, take ``effects[-1]`` (most recent)
3. display its ``char`` and ``color``, overlaying terrain
last-added effect wins on overlap.
timing
======
uses ``time.monotonic()`` for consistent async timing. ttl in seconds.
current usage: fly command creates cloud trail ("~" bright white) with
staggered ttls (1.5s base + 0.4s per step) so clouds fade origin to destination.
design decisions
================
- global state, not per-zone (simplicity)
- passive cleanup via tick-based ``clear_expired()``
- last-wins overlay (no z-index, no blending)
- just char + ANSI color (minimal rendering)
- monotonic time (no wall clock drift)
code
====
- ``src/mudlib/effects.py`` - dataclass, functions, global state
- ``src/mudlib/look.py`` - viewport rendering with effect overlay
- ``src/mudlib/server.py`` - game loop calls ``clear_expired()``
- ``content/commands/fly.toml`` - cloud trail effect usage

View file

@ -1,104 +0,0 @@
=======================
loot and corpse system
=======================
the death system has three phases: knockout, finisher, and decomposition.
corpses are containers that rot over time. loot is probabilistic.
loot tables
===========
loot is defined per-mob in toml with ``LootEntry`` records::
[[loot]]
name = "crude club"
chance = 0.8
description = "a crude wooden club"
[[loot]]
name = "gold coin"
chance = 0.5
min_count = 1
max_count = 3
fields:
- ``name`` - item name
- ``chance`` - probability (0.0-1.0) to drop
- ``min_count`` / ``max_count`` - quantity range (defaults to 1)
- ``description`` - item description string
``roll_loot()`` processes the table entry by entry. for each entry, it rolls
random against chance. if successful, picks a count in the min/max range and
creates that many ``Thing`` objects. returns a flat list of items.
death flow
==========
knockout (automatic)
--------------------
when stamina or pl drops to/below zero, the entity's ``posture`` becomes
``"unconscious"``. the mob stays registered in ``mobs`` dict. no corpse is
created yet. items stay in the mob's inventory.
finisher (explicit)
-------------------
requires a command (e.g. ``snapneck``) targeting an unconscious entity. sets
``pl`` to -100 (dead). loads mob template to get loot table. calls
``create_corpse()`` with the mob, zone, and loot table.
no corpse until finisher — knockout alone doesn't drop loot.
corpse creation
===============
``create_corpse(mob, zone, ttl=300, loot_table=None)``
1. creates ``Corpse`` object (extends ``Container``) at mob's coords
2. names it ``"{mob.name}'s corpse"``
3. transfers all items from mob inventory to corpse
4. rolls loot table, adds generated items to corpse
5. calls ``despawn_mob()`` to remove mob from world
6. registers corpse in global ``active_corpses`` list
7. returns the corpse
corpse properties:
- ``closed=False`` - always open
- ``portable=False`` - can't be picked up
- ``decompose_at`` - timestamp (now + ttl seconds)
corpses work with existing item commands (``get``, ``put``, ``look in``) because
they're containers.
decomposition
=============
``process_decomposing()`` runs every game loop tick. checks each corpse in
``active_corpses`` against current time. when ``decompose_at`` passes:
1. broadcasts "X decomposes." to entities on same tile
2. clears all items (items rot with corpse — no loot recovery)
3. removes corpse from world
4. removes from ``active_corpses`` registry
ttl-based cleanup ensures corpses don't clutter the world. default 300s (5min).
code
====
implementation:
- ``src/mudlib/loot.py`` - loot table dataclass and roll logic
- ``src/mudlib/corpse.py`` - Corpse container, creation, decomposition
- ``src/mudlib/commands/snapneck.py`` - finisher command
- ``content/mobs/*.toml`` - per-mob loot tables
related systems:
- ``src/mudlib/combat.py`` - knockout logic (posture="unconscious")
- ``src/mudlib/entity.py`` - Entity.posture, inventory
- ``src/mudlib/item.py`` - Thing, Container base classes
- ``src/mudlib/server.py`` - calls process_decomposing() each tick

View file

@ -1,193 +0,0 @@
================
npc/mob system
================
the npc/mob system handles non-player characters: enemies, allies, townsfolk. mobs can wander, patrol, converse, flee, and fight. they follow schedules and have behavior states that transition based on game events.
entity model
============
all characters (players and mobs) inherit from ``Entity`` in ``entity.py``. this gives them:
- ``pl`` (power level) - combat effectiveness
- ``stamina`` and ``max_stamina`` - resource for moves
- ``posture`` - computed @property with priority order: "unconscious", "fighting", "flying", "sleeping", "resting", "standing"
- ``x``, ``y``, ``zone`` - location in the world
the ``Mob`` subclass adds:
- ``description`` - what you see when you look at them
- ``alive`` - whether they're dead
- ``moves`` - list of combat move names they can use
- ``next_action_at`` - throttle for AI decisions
- ``home_x_min``, ``home_x_max``, ``home_y_min``, ``home_y_max`` - wander bounds
- ``behavior_state`` - current state: "idle", "patrol", "converse", "flee", "working"
- ``behavior_data`` - dict with state-specific data (waypoints, threat coords, etc)
- ``npc_name`` - key into dialogue tree registry
- ``schedule`` - ``NpcSchedule`` instance with hourly behavior changes
mob templates
=============
templates live in ``content/mobs/`` as TOML files. ``MobTemplate`` in ``mobs.py`` defines the schema::
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"]
[[loot]]
name = "crude club"
chance = 0.8
description = "a crude wooden club"
``spawn_mob(template, x, y, zone, home_region)`` creates a ``Mob`` from a template and registers it. ``despawn_mob(mob)`` marks it dead and removes it from the registry. ``get_nearby_mob(name, x, y, zone, radius)`` finds mobs by name within range (toroidal-aware).
behavior states
===============
the behavior system in ``npc_behavior.py`` is a state machine. ``process_behavior(mob)`` dispatches to handlers based on ``mob.behavior_state``:
- **idle** - default state, wanders toward home region center
- **patrol** - moves toward waypoints in ``behavior_data["waypoints"]``
- **converse** - locked in place, talking to a player
- **flee** - runs away from threat at ``behavior_data["flee_from"]``
- **working** - schedule-driven state (librarian at desk, blacksmith at forge)
transitions happen when:
- player starts conversation → "converse"
- schedule hour changes → state from active schedule entry
- conversation ends → restores ``previous_state`` from conversation data
- flee timeout expires → return to previous state
helper functions calculate movement direction:
- ``get_patrol_direction(mob)`` - toward next waypoint (toroidal)
- ``get_flee_direction(mob)`` - away from threat (toroidal)
schedules
=========
scheduled NPCs have ``NpcSchedule`` in ``npc_schedule.py``. a schedule is a list of ``ScheduleEntry``::
[[schedule]]
hour = 7
state = "working"
location = [10, 20, "town"]
[[schedule]]
hour = 21
state = "idle"
``get_active_entry(hour)`` returns the most recent entry at or before the given hour. ``process_schedules(game_time)`` is called from the game loop when the hour changes. it:
- finds all mobs with schedules
- skips dead mobs and mobs in conversation
- checks if the active entry changed
- teleports mob to new location if specified
- transitions to new state
- clears behavior_data if state changed
mob ai
======
combat ai
---------
``process_mobs()`` in ``mob_ai.py`` runs every tick (10/sec). for mobs in combat:
- **defender with incoming attack** - 40% chance of correct counter, 60% random affordable defense
- **attacker outside defend window** - random affordable attack
the ai respects throttles (``next_action_at``) and stamina costs. it uses ``get_affordable_moves()`` to filter by stamina.
movement ai
-----------
``process_mob_movement()`` runs every tick with a 3-second cooldown per mob. behavior-based movement:
- **patrol** - toward next waypoint, cycles through list
- **flee** - away from threat coordinates
- **idle** - toward home region center
- **working** - no movement
movement checks passability via ``world.is_passable(x, y, zone)``. broadcasts departure/arrival to nearby players. uses toroidal distance for direction calculation.
dialogue and conversation
=========================
``DialogueTree`` in ``dialogue.py`` has ``DialogueNode`` entries::
[dialogue.librarian.greeting]
text = "welcome to the library"
[[dialogue.librarian.greeting.choices]]
text = "what books do you have?"
next_node = "books"
``DialogueChoice`` has optional ``condition`` (python expression) and ``action`` (function name).
``ConversationState`` in ``conversation.py`` tracks:
- ``tree`` - the dialogue tree
- ``current_node`` - id of current node
- ``npc`` - the mob being talked to
- ``previous_state`` - mob's state before conversation
``start_conversation(player, npc)`` transitions the mob to "converse" state. ``end_conversation(player)`` restores the mob's previous state. active conversations are stored in a dict keyed by player name.
commands
========
talk and reply
--------------
``cmd_talk`` in ``commands/talk.py``:
- finds nearby NPC by name
- starts conversation
- shows greeting text and choices
``cmd_reply``:
- takes choice number as argument
- advances to next node
- ends conversation if no next node
spawn
-----
``cmd_spawn`` in ``commands/spawn.py``:
- takes template name
- creates mob at player location
- sets home region to 5-tile radius
game loop integration
=====================
the ``game_loop()`` function in ``server.py`` calls:
- ``process_mobs()`` - every tick (10/sec)
- ``process_mob_movement()`` - every tick (10/sec)
- ``process_schedules(game_time)`` - when hour changes
this keeps mob AI responsive while preventing spam. the throttles (``next_action_at`` for combat, 3s cooldown for movement) ensure mobs don't act too frequently.
code
====
- ``src/mudlib/entity.py`` - Entity and Mob classes
- ``src/mudlib/mobs.py`` - MobTemplate, spawn/despawn registry
- ``src/mudlib/npc_behavior.py`` - behavior state machine
- ``src/mudlib/npc_schedule.py`` - NpcSchedule and ScheduleEntry
- ``src/mudlib/mob_ai.py`` - combat and movement AI
- ``src/mudlib/dialogue.py`` - DialogueTree, DialogueNode, DialogueChoice
- ``src/mudlib/conversation.py`` - ConversationState, start/end conversation
- ``src/mudlib/commands/talk.py`` - talk and reply commands
- ``src/mudlib/commands/spawn.py`` - spawn command
- ``content/mobs/`` - mob template TOML files
- ``content/dialogue/`` - dialogue tree TOML files

View file

@ -1,96 +0,0 @@
=======================
target resolution
=======================
how we find what the player is talking about.
the problem
===========
when a player types "attack goblin" or "get sword", we need to figure out
which goblin, which sword. could be multiple matches. could be an alias.
could be "2.goblin" to mean the second one.
the system uses graceful degradation: try exact matches, fall back to prefix
matches, then aliases, then ordinal selection.
parsing the raw input
=====================
``parse_target(raw)`` extracts ordinal prefixes::
"goblin" → (1, "goblin")
"2.goblin" → (2, "goblin")
"3.rat" → (3, "rat")
only ordinals >= 1 are valid. "0.goblin" or "-1.goblin" are treated as literal
names (weird, but no crash).
matching priority
=================
``resolve_target(name, candidates, key=None)`` uses a priority-based matching
flow:
**four match priorities** (checked in order, stops at first non-empty result):
1. exact name match (case-insensitive)
2. name prefix match ("gob" matches "goblin")
3. exact alias match
4. alias prefix match ("gobb" matches alias "gobby")
collects ALL matches at each priority level before moving to the next. this
prevents a prefix match from shadowing a later exact alias match.
**ordinal selection**: if an ordinal was parsed (e.g., "2.goblin"), picks the
Nth match from whichever priority level produced results.
**fallback**: returns None if no matches found at any level.
custom key function lets you adapt to non-standard objects (e.g., dicts with
a "name" field).
finding entities
================
``find_entity_on_tile(name, player, z_filter=True)`` searches players and mobs
on the same tile.
filters:
- excludes the player themselves
- skips dead mobs
- z-axis filtering: only matches entities on same z-level (both grounded or
both flying). flying creatures can't attack grounded ones by default.
sorts results to prefer Players over Mobs.
finding things
==============
``find_thing_on_tile(name, zone, x, y)`` searches ground items at coordinates.
only Thing instances.
``find_in_inventory(name, player)`` searches player inventory. only Thing
instances.
ordinal disambiguation
======================
if there are three goblins, you can target the second with "2.goblin"::
attack 2.goblin
the ordinal is parsed, then resolve_target collects matches and picks the Nth
one. ordinal selection happens after all four priority levels have been tried,
operating on whichever level produced matches.
case sensitivity
================
all matching is case-insensitive. "Goblin", "goblin", "GOBLIN" all work.
code
====
- ``src/mudlib/commands/targeting.py`` - parse and resolve functions
- ``tests/test_targeting.py`` - test coverage for all priority levels

View file

@ -1,134 +0,0 @@
=====================================
things and verbs: how objects interact
=====================================
the thing/verb system lets world objects respond to player actions. instead of hardcoding behavior in commands, objects carry their own verb handlers. two ways to register verbs: decorator-based (python classes) or toml-based (content files). both converge to the same dispatch mechanism.
object base class
=================
``Object`` (object.py) is the foundation for all world entities. it has:
- ``name`` - what the object is called
- ``location`` - another Object or None (the entire containment system)
- ``x``, ``y`` - coordinates in the world
- ``_contents`` - list of objects inside this one
- ``_verbs`` - dict mapping verb names to handlers
``__post_init__`` scans the instance for methods decorated with ``@verb`` and auto-registers them in ``_verbs``.
thing and container
===================
``Thing`` (thing.py) extends Object for portable items::
name: str
description: str
portable: bool = True
aliases: list[str] = []
readable_text: str | None = None
tags: set[str] = field(default_factory=set)
``Container`` (container.py) extends Thing with containment rules::
capacity: int
closed: bool = False
locked: bool = False
``can_accept()`` checks object type, open state, and capacity before allowing insertions.
verb registration: decorator path
==================================
the ``@verb`` decorator (verbs.py) marks methods as verb handlers. it sets ``_verb_name`` on the function. ``Object.__post_init__`` discovers these and calls ``register_verb()``::
class Fountain(Thing):
@verb("drink")
async def drink(self, player, args):
await player.send("You drink from the fountain.\r\n")
when you instantiate the fountain, the drink handler automatically registers in ``_verbs["drink"]``.
verb registration: toml path
=============================
``ThingTemplate`` (things.py) defines objects in toml::
name = "chest"
description = "a sturdy wooden chest with iron bindings"
portable = false
capacity = 5
closed = true
locked = false
aliases = ["box"]
[verbs]
unlock = "verb_handlers:unlock_handler"
``spawn_thing()`` (things.py) creates a Thing or Container from the template. if ``capacity`` is set, you get a Container. if ``verbs`` dict is present, it resolves "module:function" strings via importlib and binds them with functools.partial.
``verb_handlers.py`` contains standalone functions referenced by toml. example::
async def unlock_handler(obj, player, args):
if not isinstance(obj, Container):
await player.send("That's not lockable.\r\n")
return
if not obj.locked:
await player.send("It's already unlocked.\r\n")
return
# check for key in inventory...
obj.locked = False
await player.send(f"You unlock the {obj.name}.\r\n")
two paths, one dispatch
=======================
decorator-based and toml-based verbs both populate ``Object._verbs``. the command dispatcher doesn't care which path was used.
finding objects
===============
``find_object()`` (verbs.py) searches for targets:
1. player inventory first
2. ground at player position second
3. matches by name or aliases (case-insensitive)
returns the object or None.
command dispatch fallback
=========================
the command dispatcher (commands/__init__.py) tries registered commands first. if no match, it tries verb dispatch:
1. parse input as "verb target" (e.g., "drink fountain")
2. call ``find_object()`` for target
3. check ``obj.has_verb(verb)``
4. call ``obj.call_verb(verb, player, args)``
this lets content authors add new interactions without modifying command code.
use command
===========
``use`` (commands/use.py) provides explicit verb access::
use fountain # calls fountain's "use" verb
use key on chest # calls chest's "use" verb with args="key on chest"
parses input, finds object, checks for "use" verb, calls handler.
code
====
relevant files::
src/mudlib/object.py # Object base class, verb registration
src/mudlib/thing.py # Thing class
src/mudlib/container.py # Container class
src/mudlib/verbs.py # @verb decorator, find_object()
src/mudlib/things.py # ThingTemplate, spawn_thing()
src/mudlib/verb_handlers.py # standalone verb functions for toml
src/mudlib/commands/__init__.py # command dispatcher with verb fallback
src/mudlib/commands/use.py # explicit verb command
content/things/ # toml thing definitions

View file

@ -1,121 +0,0 @@
=============================
time and weather system
=============================
the game simulates passing time, seasons, weather, and their effect on visibility. all together these create atmospheric descriptions in the look command and influence how far you can see.
game time
=========
time flows faster in the game world than in reality. by default, 1 real minute = 1 game hour, so a full game day passes in 24 real minutes.
the ``GameTime`` class converts real timestamps to game time using an epoch (when the game started) and a ratio. methods::
get_game_hour() -> int # 0-23
get_game_time() -> tuple # (hour, minute)
get_game_day() -> int # days since epoch, 0-based
``init_game_time()`` is called at server startup to establish the global game clock. all other systems query this clock to determine current conditions.
time of day
===========
game hours map to periods: dawn (5-6), day (7-17), dusk (18-19), night (20-4).
``get_sky_description(hour)`` picks variant descriptions for each period. the selection is deterministic using ``hour % len(variants)`` so the same hour always shows the same description within that day. examples::
dawn: "pale light seeps across the horizon"
day: "the sun hangs high overhead"
dusk: "the day's light begins to soften"
night: "stars wheel slowly overhead"
there are 3-4 variants per period to add variety across different hours.
seasons
=======
the game year is 28 days long with 4 seasons of 7 days each: spring, summer, autumn, winter. seasons cycle infinitely.
``get_season(game_day)`` returns the current season name. ``get_season_description(season, terrain)`` returns descriptive text, but only for grass and forest terrain. other terrain types return empty strings. examples::
spring + grass: "fresh green grass springs up everywhere"
winter + forest: "bare branches reach toward the sky"
summer + forest: "a thick green canopy spreads overhead"
this is layered into the atmosphere line when looking at the world.
weather
=======
weather is a global simulation that evolves each game hour.
conditions
----------
``WeatherCondition`` enum: clear, cloudy, rain, storm, snow, fog.
``WeatherState`` tracks the current condition plus intensity (0.0-1.0). intensity affects descriptions::
rain < 0.3: "a light drizzle falls"
rain 0.3-0.6: "rain patters steadily"
rain >= 0.6: "rain hammers down relentlessly"
similar tiers exist for snow, fog, and storm.
climate profiles
----------------
``advance_weather()`` is probabilistic with three climate profiles:
- **temperate**: balanced transitions between all conditions
- **arid**: 90% chance clear stays clear, rare rain, no snow
- **arctic**: heavy emphasis on snow and fog
season filtering prevents snow in spring/summer.
global state
------------
``init_weather()`` sets up the initial condition. ``get_current_weather()`` returns the current state. ``tick_weather()`` is called each game hour to advance the simulation.
visibility
==========
weather and time of day affect how far you can see.
``get_visibility(hour, weather, base_width=21, base_height=11)`` calculates effective viewport dimensions by applying stacking penalties::
night: -6 width, -2 height
dawn/dusk: -2 width
thick fog (>=0.7): -8 width, -4 height
fog (0.4-0.7): -4 width, -2 height
storm: -4 width, -2 height
minimum viewport is 7x5. effects stack: a stormy night with thick fog applies all three penalties.
integration
===========
the look command pulls current hour, day, weather, and season. it calculates effective viewport via ``get_visibility()`` and builds the atmosphere line with ``render_atmosphere()``.
example outputs::
"the sun hangs high overhead. [day, summer]"
"the day's light begins to soften. rain patters steadily. [day, autumn]"
"stars wheel slowly overhead. snow drifts down softly. [night, winter]"
the atmosphere line appears at the top of the world view, followed by the terrain map clipped to effective visibility.
code
====
relevant files::
src/mudlib/gametime.py
src/mudlib/timeofday.py
src/mudlib/seasons.py
src/mudlib/weather.py
src/mudlib/visibility.py
src/mudlib/commands/look.py

View file

@ -28,14 +28,6 @@ mostly standalone subsystems.
- ``docs/how/persistence.txt`` — SQLite storage, what's persisted vs runtime - ``docs/how/persistence.txt`` — SQLite storage, what's persisted vs runtime
- ``docs/how/prompt-system.txt`` — modal prompts, color markup, per-player customization - ``docs/how/prompt-system.txt`` — modal prompts, color markup, per-player customization
- ``docs/how/protocols.rst`` — GMCP/MSDP negotiation, client detection, guard pattern - ``docs/how/protocols.rst`` — GMCP/MSDP negotiation, client detection, guard pattern
- ``docs/how/things-and-verbs.rst`` — verb registry, @verb decorator, TOML verb handlers, dispatch fallback
- ``docs/how/content-loading.rst`` — TOML content pipeline, startup sequence, global registries
- ``docs/how/targeting.rst`` — ordinal parsing, priority matching, z-axis filtering
- ``docs/how/npc-mobs.rst`` — mob templates, behavior states, AI, schedules, dialogue, spawning
- ``docs/how/time-and-weather.rst`` — game time, seasons, weather simulation, visibility
- ``docs/how/effects.rst`` — temporary visual overlays on the map (cloud trails, etc)
- ``docs/how/loot-and-corpses.rst`` — loot tables, corpse creation, decomposition
- ``docs/how/crafting.rst`` — recipe definitions, ingredient matching, item spawning
combat combat
------ ------

View file

@ -1,164 +0,0 @@
Project Health Audit
====================
Feb 2026. Full codebase audit after combat-timing-fields landed.
The architecture is solid. The problem is drift: things got built ahead of
being cared about, so docs/tests/schemas are out of sync with actual code.
Nothing is broken, but the project needs a cleanup pass before pushing
forward.
Combat: Doc/Schema Mismatch
----------------------------
The most concrete issue. Three things are wrong:
1. **State machine docs are stale.** combat.rst describes
IDLE -> TELEGRAPH -> WINDOW -> RESOLVE (4 states). Code has
IDLE -> PENDING -> RESOLVE (3 states). TELEGRAPH and WINDOW got
collapsed into PENDING. The docs need to match reality.
2. **Field names drifted.** Old schema used ``timing_window_ms`` for
everything. New schema split it: ``hit_time_ms`` for attacks (time to
impact), ``active_ms``/``recovery_ms`` for defenses (active window /
lockout). builder-manual.md and combat.rst still reference the old name.
3. **No schema validation.** ``moves.py`` checks 3 required fields (name,
move_type, stamina_cost). You can define an attack with hit_time_ms=0
and it silently does nothing. Attacks should require hit_time_ms > 0,
defenses should require active_ms > 0.
Other combat notes:
- Stamina deduction is asymmetric: attacks deduct inside
``encounter.attack()``, defenses deduct in the command handler before
calling ``encounter.defend()``. Works but confusing. Consider
standardizing.
- Defense telegraphs are all empty strings. Intentional (reactive moves
don't broadcast intent) but undocumented.
- Future features (stuns, combos, lethal, multi-combatant) are NOT
half-implemented. No stubs or dead code. Clean.
Files to touch::
docs/how/combat.rst - fix state machine, fix field names
docs/builder-manual.md - fix TOML schema examples
src/mudlib/combat/moves.py - add per-move-type validation
Tests: Mixed Quality
--------------------
~1,448 test functions across 100+ files. The good tests are genuinely good
(combat encounter, quetzal roundtrip, content loading). But there's bloat.
**Trivial constructor tests (~50+).** Things like::
def test_thing_creation_minimal():
t = Thing(name="rock")
assert t.name == "rock"
These verify Python dataclasses work. Delete them.
**Over-mocking.** Many tests mock the writer then assert "was something
written?" but not what. Color bugs, format bugs slip through. Loose
assertions like ``assert "\033[" in result`` check some ANSI was emitted
but not the right color.
**Duplicated fixtures.** ``mock_writer`` is defined in 15+ test files
instead of sharing from conftest. Same for ``mock_reader`` and zone helpers.
**File sprawl.** 4 container test files, 3 portal files, 3 help files,
4 zone files. These should consolidate into 1-2 per feature area.
**Stub files (1 test each).** Seven files with a single test: test_corpse,
test_npc_behavior, test_npc_integration, test_import, test_mobs,
test_game_compatibility, test_two_way_portals. Either flesh out or delete.
**Missing test categories:**
- Error paths (bad input, wrong types, missing data)
- Edge cases (boundary values, empty inputs, max values)
- Concurrency (async code with no race condition tests)
Plan::
1. Consolidate mock_writer/mock_reader into conftest
2. Delete trivial constructor/property tests
3. Merge fragmented test files (containers, portals, help, zones)
4. Decide on stub files: flesh out or remove
5. Strengthen loose assertions where practical
Documentation: Strong Foundation, Gaps
---------------------------------------
**Current and good:** DREAMBOOK, architecture-plan, object-model,
builder-manual, protocols, persistence, terrain-generation, IF docs,
all lessons.
**Stale:**
- combat.rst (wrong state machine, wrong field names)
- builder-manual.md (old timing_window_ms references)
- roadmap.rst (phases 1-2 done but not marked)
- IF research docs (viola/zvm/mojozork audits predate implementation)
**Missing docs for existing systems:**
- NPC/mob system (mobs.py, npc_behavior.py, npc_schedule.py, dialogue.py,
conversation.py, mob_ai.py - 6+ files, no implementation doc)
- Thing/verb system (thing.py, things.py, verbs.py, verb_handlers.py)
- Time/season/weather (gametime.py, seasons.py, weather.py, timeofday.py,
visibility.py)
- Effects system (effects.py)
- Targeting (targeting.py)
- Loot/corpse (loot.py, corpse.py)
- Crafting implementation (crafting.py - builder-manual has usage but no
internals doc)
- Content loading pipeline (content.py)
- Embedded z-machine (50+ files in zmachine/, no implementation guide)
Plan: write docs as we touch each system, not all at once. Priority order
matches what we're likely to work on next (combat first, then mobs/things).
Architecture: Solid
-------------------
No action needed on architecture itself. Notes for awareness:
- Module-level globals (players, mobs, active_encounters, command registry)
are appropriate for a game server. Tests clear them properly.
- server.py is 676 lines and dense (login, shell loop, content loading) but
responsibilities are clear. Could split later if it grows.
- Player dataclass has 29 fields. Organized but growing. Watch it.
- Mode stack is string-based ("normal", "combat", "editor"). Works fine,
enum would be safer eventually.
- No dead code in core engine. Z-machine has TODOs (expected).
- No circular dependency issues. Lazy imports in commands/ handle cycles.
Execution Order
---------------
Phase 1: Combat docs and schema (small, unblocks combat work)
- Update combat.rst state machine and field names
- Update builder-manual.md TOML examples
- Add schema validation in moves.py
- Tests for validation
Phase 2: Test cleanup (can be gradual)
- Consolidate fixtures into conftest
- Delete trivial tests
- Merge fragmented test files
- Strengthen assertions
Phase 3: Doc gaps (as-needed, per system)
- Write how/ docs when touching each system
- Mark completed roadmap phases
- Consider archiving pre-implementation IF research docs

View file

@ -1,13 +0,0 @@
#!/usr/bin/env bash
# parse dbzfe combat log for combat-relevant lines
# grabs lines starting with (after timestamp): * or You
log="${1:-docs/research/dbzfe.log}"
if [[ ! -f "$log" ]]; then
echo "usage: docs/research/dbzfe [logfile]"
echo "default: docs/research/dbzfe.log"
exit 1
fi
grep -E '^[0-9:.]+[[:space:]]+((\*|You ))' "$log"

File diff suppressed because it is too large Load diff

View file

@ -2,17 +2,10 @@
#split 0 1 #split 0 1
#session dbzfe dbzfe.com 4000 #session dbzfe dbzfe.com 4000
#NOP log with timestamps, plain text (no ANSI colors) #NOP log with ANSI colors preserved (view later with: less -R dbzfe.log)
#config {log mode} {plain} #config {log mode} {raw}
#NOP %f = microseconds in some strftime implementations, may show literal %f if unsupported
#log timestamp {%H:%M:%S.%f }
#log append dbzfe.log #log append dbzfe.log
#NOP for color logging later: switch to raw mode and drop timestamp
#NOP #config {log mode} {raw}
#NOP #log timestamp {}
#NOP #log append dbzfe-color.log
#NOP fly aliases: f<direction> = fly 5 in that direction #NOP fly aliases: f<direction> = fly 5 in that direction
#alias {fn} {fly north} #alias {fn} {fly north}
#alias {fs} {fly south} #alias {fs} {fly south}
@ -25,11 +18,11 @@
#NOP combat aliases (pr/pl/dr/dl/f/v are built into the MUD) #NOP combat aliases (pr/pl/dr/dl/f/v are built into the MUD)
#NOP these are extras for single-key convenience #NOP these are extras for single-key convenience
#alias {pr} {punch right %0} #alias {pr} {punch right}
#alias {pl} {punch left %0} #alias {pl} {punch left}
#alias {o} {sweep %0} #alias {o} {sweep}
#alias {r} {roundhouse %0} #alias {r} {roundhouse}
#alias {f} {parry high %0} #alias {f} {parry high}
#alias {v} {parry low %0} #alias {v} {parry low}
#alias {dl} {dodge left %0} #alias {dl} {dodge left}
#alias {dr} {dodge right %0} #alias {dr} {dodge right}

16
mud.tin
View file

@ -37,11 +37,11 @@
#alias {fsw} {fly southwest} #alias {fsw} {fly southwest}
#NOP combat shortcuts #NOP combat shortcuts
#alias {o} {sweep %0} #alias {o} {sweep}
#alias {pl} {punch left %0} #alias {pl} {punch left}
#alias {pr} {punch right %0} #alias {pr} {punch right}
#alias {r} {roundhouse %0} #alias {r} {roundhouse}
#alias {f} {parry high %0} #alias {f} {parry high}
#alias {v} {parry low %0} #alias {v} {parry low}
#alias {dr} {dodge right %0} #alias {dr} {dodge right}
#alias {dl} {dodge left %0} #alias {dl} {dodge left}

View file

@ -1,5 +1,6 @@
"""Combat command handlers.""" """Combat command handlers."""
import asyncio
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
@ -101,7 +102,10 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
await defender.send(f"{telegraph}\r\n") await defender.send(f"{telegraph}\r\n")
# Detect switch before attack() modifies state # Detect switch before attack() modifies state
switching = encounter.state == CombatState.PENDING switching = encounter.state in (
CombatState.TELEGRAPH,
CombatState.WINDOW,
)
# Execute the attack (deducts stamina) # Execute the attack (deducts stamina)
encounter.attack(move) encounter.attack(move)
@ -123,8 +127,8 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
async def do_defend(player: Player, _args: str, move: CombatMove) -> None: async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
"""Core defense logic with a resolved move. """Core defense logic with a resolved move.
Works both in and outside combat. The encounter tracks active/recovery Works both in and outside combat. Applies a recovery lock
windows internally. (based on timing_window_ms) so defenses have commitment.
Args: Args:
player: The defending player player: The defending player
@ -152,7 +156,7 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
# Check stamina cues after defense cost # Check stamina cues after defense cost
await check_stamina_cues(player) await check_stamina_cues(player)
# If in combat, queue/activate the defense on the encounter # If in combat, queue the defense on the encounter
encounter = get_encounter(player) encounter = get_encounter(player)
if encounter is not None: if encounter is not None:
encounter.defend(move) encounter.defend(move)
@ -167,6 +171,9 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
f"{player.name} {move.command}s!\r\n", f"{player.name} {move.command}s!\r\n",
) )
# Commitment: block for the timing window (inputs queue naturally)
await asyncio.sleep(move.timing_window_ms / 1000.0)
if encounter is not None: if encounter is not None:
await player.send(f"You {move.name}!\r\n") await player.send(f"You {move.name}!\r\n")
else: else:

View file

@ -12,11 +12,15 @@ class CombatState(Enum):
"""States of the combat state machine.""" """States of the combat state machine."""
IDLE = "idle" IDLE = "idle"
PENDING = "pending" TELEGRAPH = "telegraph"
WINDOW = "window"
RESOLVE = "resolve" RESOLVE = "resolve"
# Seconds since last landed damage before combat fizzles out # Telegraph phase duration in seconds (3 game ticks at 100ms/tick)
TELEGRAPH_DURATION = 0.3
# Seconds of no action before combat fizzles out
IDLE_TIMEOUT = 30.0 IDLE_TIMEOUT = 30.0
@ -40,60 +44,46 @@ class CombatEncounter:
current_move: CombatMove | None = None current_move: CombatMove | None = None
move_started_at: float = 0.0 move_started_at: float = 0.0
pending_defense: CombatMove | None = None pending_defense: CombatMove | None = None
defense_activated_at: float | None = None
defense_recovery_until: float | None = None
queued_defense: CombatMove | None = None
# Monotonic timestamp of most recent landed damage in this encounter.
last_action_at: float = 0.0 last_action_at: float = 0.0
def attack(self, move: CombatMove) -> None: def attack(self, move: CombatMove) -> None:
"""Initiate or switch an attack move. """Initiate or switch an attack move.
If called during PENDING, switches to the new move and restarts If called during TELEGRAPH or WINDOW, switches to the new move
the timer. Refunds old move's stamina cost. without resetting the timer. Refunds old move's stamina cost.
Args: Args:
move: The attack move to execute move: The attack move to execute
""" """
now = time.monotonic() now = time.monotonic()
if self.state == CombatState.PENDING and self.current_move: if self.state in (CombatState.TELEGRAPH, CombatState.WINDOW):
# Switching — refund old cost # Switching — refund old cost, keep timer
self.attacker.stamina = min( if self.current_move:
self.attacker.stamina + self.current_move.stamina_cost, self.attacker.stamina = min(
self.attacker.max_stamina, self.attacker.stamina + self.current_move.stamina_cost,
) self.attacker.max_stamina,
)
else:
# First attack — start timer
self.move_started_at = now
# Always restart timer
self.move_started_at = now
self.current_move = move self.current_move = move
self.attacker.stamina -= move.stamina_cost self.attacker.stamina -= move.stamina_cost
self.last_action_at = now
if self.state == CombatState.IDLE: if self.state == CombatState.IDLE:
self.state = CombatState.PENDING self.state = CombatState.TELEGRAPH
def defend(self, move: CombatMove) -> None: def defend(self, move: CombatMove) -> None:
"""Queue or activate a defense move. """Queue a defense move on the encounter.
If in recovery, queues the defense. Otherwise activates immediately. Stamina cost and lock are handled by the command layer (do_defend).
Stamina cost is handled by the command layer (do_defend).
Args: Args:
move: The defense move to attempt move: The defense move to attempt
""" """
now = time.monotonic() self.pending_defense = move
self.last_action_at = time.monotonic()
# Check if in recovery
if (
self.defense_recovery_until is not None
and now < self.defense_recovery_until
):
# Queue for later activation
self.queued_defense = move
else:
# Activate immediately
self.pending_defense = move
self.defense_activated_at = now
self.queued_defense = None
def tick(self, now: float) -> None: def tick(self, now: float) -> None:
"""Advance the state machine based on current time. """Advance the state machine based on current time.
@ -101,45 +91,23 @@ class CombatEncounter:
Args: Args:
now: Current time from monotonic clock now: Current time from monotonic clock
""" """
# Check if queued defense should activate if self.state == CombatState.TELEGRAPH:
if ( # Check if telegraph phase is over
self.queued_defense is not None elapsed = now - self.move_started_at
and self.defense_recovery_until is not None if elapsed >= TELEGRAPH_DURATION:
and now >= self.defense_recovery_until self.state = CombatState.WINDOW
):
# Activate queued defense
self.pending_defense = self.queued_defense
self.defense_activated_at = now
self.queued_defense = None
self.defense_recovery_until = None
# Check PENDING -> RESOLVE transition elif self.state == CombatState.WINDOW:
if self.state == CombatState.PENDING: # Check if timing window has expired
if self.current_move is None: if self.current_move is None:
return return
elapsed = now - self.move_started_at elapsed = now - self.move_started_at
hit_time_seconds = self.current_move.hit_time_ms / 1000.0 window_seconds = self.current_move.timing_window_ms / 1000.0
total_time = TELEGRAPH_DURATION + window_seconds
if elapsed >= hit_time_seconds: if elapsed >= total_time:
self.state = CombatState.RESOLVE self.state = CombatState.RESOLVE
# Don't expire defense here - resolve() will handle it after checking
# Only expire defense if NOT in RESOLVE state
# (resolve() will clear defense after checking for counters)
if (
self.state != CombatState.RESOLVE
and self.defense_activated_at is not None
and self.pending_defense is not None
):
active_duration = now - self.defense_activated_at
active_seconds = self.pending_defense.active_ms / 1000.0
if active_duration >= active_seconds:
# Defense window expired, enter recovery
recovery_seconds = self.pending_defense.recovery_ms / 1000.0
self.defense_recovery_until = now + recovery_seconds
self.defense_activated_at = None
self.pending_defense = None
def resolve(self) -> ResolveResult: def resolve(self) -> ResolveResult:
"""Resolve the combat exchange and return result. """Resolve the combat exchange and return result.
@ -173,16 +141,8 @@ class CombatEncounter:
) )
# Check if defense counters attack # Check if defense counters attack
# Defense must be active (in active window) to succeed
defense_is_active = False
if self.defense_activated_at is not None and self.pending_defense is not None:
active_duration = time.monotonic() - self.defense_activated_at
active_window = self.pending_defense.active_ms / 1000.0
defense_is_active = active_duration < active_window
defense_succeeds = ( defense_succeeds = (
defense_is_active self.pending_defense
and self.pending_defense is not None
and self.pending_defense.name in self.current_move.countered_by and self.pending_defense.name in self.current_move.countered_by
) )
if defense_succeeds: if defense_succeeds:
@ -197,7 +157,7 @@ class CombatEncounter:
elif self.pending_defense: elif self.pending_defense:
# Wrong defense - normal damage # Wrong defense - normal damage
damage = self.attacker.pl * self.current_move.damage_pct damage = self.attacker.pl * self.current_move.damage_pct
self.defender.pl = max(0.0, self.defender.pl - damage) self.defender.pl -= damage
template = ( template = (
self.current_move.resolve_hit self.current_move.resolve_hit
if self.current_move.resolve_hit if self.current_move.resolve_hit
@ -207,7 +167,7 @@ class CombatEncounter:
else: else:
# No defense - increased damage # No defense - increased damage
damage = self.attacker.pl * self.current_move.damage_pct * 1.5 damage = self.attacker.pl * self.current_move.damage_pct * 1.5
self.defender.pl = max(0.0, self.defender.pl - damage) self.defender.pl -= damage
template = ( template = (
self.current_move.resolve_hit self.current_move.resolve_hit
if self.current_move.resolve_hit if self.current_move.resolve_hit
@ -215,16 +175,13 @@ class CombatEncounter:
) )
countered = False countered = False
if damage > 0: # Check for combat end conditions
self.last_action_at = time.monotonic() combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0
combat_ended = False
# Reset to IDLE and clear defense state # Reset to IDLE
# Note: defense_recovery_until persists across attacks
self.state = CombatState.IDLE self.state = CombatState.IDLE
self.current_move = None self.current_move = None
self.pending_defense = None self.pending_defense = None
self.defense_activated_at = None
return ResolveResult( return ResolveResult(
resolve_template=template, resolve_template=template,

View file

@ -4,7 +4,7 @@ import time
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
from mudlib.combat.stamina import check_stamina_cues from mudlib.combat.stamina import check_stamina_cues
from mudlib.entity import Entity from mudlib.entity import Entity, Mob
from mudlib.gmcp import send_char_status, send_char_vitals from mudlib.gmcp import send_char_status, send_char_vitals
from mudlib.render.colors import colorize from mudlib.render.colors import colorize
from mudlib.render.pov import render_pov from mudlib.render.pov import render_pov
@ -89,7 +89,7 @@ async def process_combat() -> None:
now = time.monotonic() now = time.monotonic()
for encounter in active_encounters[:]: # Copy list to allow modification for encounter in active_encounters[:]: # Copy list to allow modification
# Check for no-damage timeout. # Check for idle timeout
if now - encounter.last_action_at > IDLE_TIMEOUT: if now - encounter.last_action_at > IDLE_TIMEOUT:
await encounter.attacker.send("Combat has fizzled out.\r\n") await encounter.attacker.send("Combat has fizzled out.\r\n")
await encounter.defender.send("Combat has fizzled out.\r\n") await encounter.defender.send("Combat has fizzled out.\r\n")
@ -108,10 +108,10 @@ async def process_combat() -> None:
# Tick the state machine # Tick the state machine
encounter.tick(now) encounter.tick(now)
# Send announce message on PENDING → RESOLVE transition # Send announce message on TELEGRAPH → WINDOW transition
if ( if (
previous_state == CombatState.PENDING previous_state == CombatState.TELEGRAPH
and encounter.state == CombatState.RESOLVE and encounter.state == CombatState.WINDOW
and encounter.current_move and encounter.current_move
and encounter.current_move.announce and encounter.current_move.announce
): ):
@ -156,3 +156,67 @@ async def process_combat() -> None:
# Check stamina cues after damage # Check stamina cues after damage
await check_stamina_cues(encounter.attacker) await check_stamina_cues(encounter.attacker)
await check_stamina_cues(encounter.defender) await check_stamina_cues(encounter.defender)
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
# Track kill/death stats
if isinstance(winner, Player):
winner.kills += 1
if isinstance(loser, Mob):
winner.mob_kills[loser.name] = (
winner.mob_kills.get(loser.name, 0) + 1
)
# Check for new unlocks
from mudlib.combat.commands import combat_moves
from mudlib.combat.unlock import check_unlocks
newly_unlocked = check_unlocks(winner, combat_moves)
for move_name in newly_unlocked:
await winner.send(f"You have learned {move_name}!\r\n")
if isinstance(loser, Player):
loser.deaths += 1
# Despawn mob losers, send victory/defeat messages
if isinstance(loser, Mob):
from mudlib.corpse import create_corpse
from mudlib.mobs import mob_templates
from mudlib.zone import Zone
zone = loser.location
if isinstance(zone, Zone):
# Look up loot table from mob template
template = mob_templates.get(loser.name)
loot_table = template.loot if template else None
create_corpse(loser, zone, loot_table=loot_table)
else:
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
attacker = encounter.attacker
if isinstance(attacker, Player) and attacker.mode == "combat":
attacker.mode_stack.pop()
send_char_status(attacker)
defender = encounter.defender
if isinstance(defender, Player) and defender.mode == "combat":
defender.mode_stack.pop()
send_char_status(defender)
# Remove encounter from active list
end_encounter(encounter)

View file

@ -28,9 +28,7 @@ class CombatMove:
name: str name: str
move_type: str # "attack" or "defense" move_type: str # "attack" or "defense"
stamina_cost: float stamina_cost: float
hit_time_ms: int = 0 # for attacks: ms from initiation to impact timing_window_ms: int
active_ms: int = 0 # for defenses: how long defense blocks once activated
recovery_ms: int = 0 # for defenses: lockout after active window ends
aliases: list[str] = field(default_factory=list) aliases: list[str] = field(default_factory=list)
telegraph: str = "" telegraph: str = ""
damage_pct: float = 0.0 damage_pct: float = 0.0
@ -71,22 +69,12 @@ def load_move(path: Path) -> list[CombatMove]:
data = tomllib.load(f) data = tomllib.load(f)
# Required fields # Required fields
required_fields = ["name", "move_type", "stamina_cost"] required_fields = ["name", "move_type", "stamina_cost", "timing_window_ms"]
for field_name in required_fields: for field_name in required_fields:
if field_name not in data: if field_name not in data:
msg = f"missing required field: {field_name}" msg = f"missing required field: {field_name}"
raise ValueError(msg) raise ValueError(msg)
# Move-type-specific timing validation
move_type = data["move_type"]
name = data["name"]
if move_type == "attack" and data.get("hit_time_ms", 0) <= 0:
msg = f"attack move '{name}' requires hit_time_ms > 0"
raise ValueError(msg)
if move_type == "defense" and data.get("active_ms", 0) <= 0:
msg = f"defense move '{name}' requires active_ms > 0"
raise ValueError(msg)
base_name = data["name"] base_name = data["name"]
variants = data.get("variants") variants = data.get("variants")
@ -109,12 +97,8 @@ def load_move(path: Path) -> list[CombatMove]:
name=qualified_name, name=qualified_name,
move_type=data["move_type"], move_type=data["move_type"],
stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]), stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]),
hit_time_ms=variant_data.get( timing_window_ms=variant_data.get(
"hit_time_ms", data.get("hit_time_ms", 0) "timing_window_ms", data["timing_window_ms"]
),
active_ms=variant_data.get("active_ms", data.get("active_ms", 0)),
recovery_ms=variant_data.get(
"recovery_ms", data.get("recovery_ms", 0)
), ),
aliases=variant_data.get("aliases", []), aliases=variant_data.get("aliases", []),
telegraph=variant_data.get("telegraph", data.get("telegraph", "")), telegraph=variant_data.get("telegraph", data.get("telegraph", "")),
@ -155,9 +139,7 @@ def load_move(path: Path) -> list[CombatMove]:
name=base_name, name=base_name,
move_type=data["move_type"], move_type=data["move_type"],
stamina_cost=data["stamina_cost"], stamina_cost=data["stamina_cost"],
hit_time_ms=data.get("hit_time_ms", 0), timing_window_ms=data["timing_window_ms"],
active_ms=data.get("active_ms", 0),
recovery_ms=data.get("recovery_ms", 0),
aliases=data.get("aliases", []), aliases=data.get("aliases", []),
telegraph=data.get("telegraph", ""), telegraph=data.get("telegraph", ""),
damage_pct=data.get("damage_pct", 0.0), damage_pct=data.get("damage_pct", 0.0),

View file

@ -28,7 +28,7 @@ async def cmd_alias(player: Player, args: str) -> None:
# Check if this is a single-word lookup or a definition # Check if this is a single-word lookup or a definition
parts = args.split(None, 1) parts = args.split(None, 1)
alias_name = parts[0].lower() alias_name = parts[0]
if len(parts) == 1: if len(parts) == 1:
# Show single alias # Show single alias
@ -39,7 +39,7 @@ async def cmd_alias(player: Player, args: str) -> None:
return return
# Create alias # Create alias
expansion = parts[1].strip() expansion = parts[1]
# Cannot alias over built-in commands # Cannot alias over built-in commands
if alias_name in _registry: if alias_name in _registry:
@ -56,7 +56,7 @@ async def cmd_unalias(player: Player, args: str) -> None:
Usage: Usage:
unalias <name> unalias <name>
""" """
alias_name = args.strip().lower() alias_name = args.strip()
if not alias_name: if not alias_name:
await player.send("Usage: unalias <name>\r\n") await player.send("Usage: unalias <name>\r\n")

View file

@ -3,55 +3,50 @@
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.entity import Entity from mudlib.entity import Entity
from mudlib.player import Player from mudlib.player import Player
from mudlib.targeting import find_entity_on_tile, find_in_inventory, find_thing_on_tile
from mudlib.thing import Thing from mudlib.thing import Thing
from mudlib.zone import Zone from mudlib.zone import Zone
async def examine_target( def _find_object_in_inventory(name: str, player: Player) -> Thing | Entity | None:
player: Player, """Find an object in player inventory by name or alias."""
target_name: str, name_lower = name.lower()
*, for obj in player.contents:
prefer_inventory: bool = True, # Only examine Things and Entities
) -> None: if not isinstance(obj, (Thing, Entity)):
"""Resolve and describe a target for examine/look style commands.""" continue
zone = player.location if isinstance(player.location, Zone) else None
# look <thing> should prioritize entities/ground; direct examine keeps # Match by name
# historical inventory-first behavior. if obj.name.lower() == name_lower:
ordered_finders = [] return obj
if prefer_inventory:
ordered_finders.append(lambda: find_in_inventory(target_name, player))
ordered_finders.append(lambda: find_entity_on_tile(target_name, player))
if zone is not None:
ordered_finders.append(
lambda: find_thing_on_tile(target_name, zone, player.x, player.y)
)
if not prefer_inventory:
ordered_finders.append(lambda: find_in_inventory(target_name, player))
found: Thing | Entity | None = None # Match by alias (Things have aliases)
for finder in ordered_finders: if isinstance(obj, Thing) and name_lower in (a.lower() for a in obj.aliases):
found = finder() return obj
if found is not None:
break
if found is None: return None
await player.send("You don't see that here.\r\n")
return
if isinstance(found, Entity):
if getattr(found, "description", ""):
await player.send(f"{found.description}\r\n")
else:
await player.send(f"{found.name} is {found.posture}.\r\n")
return
desc = getattr(found, "description", "") def _find_object_at_position(name: str, player: Player) -> Thing | Entity | None:
if desc: """Find an object on the ground at player position by name or alias."""
await player.send(f"{desc}\r\n") zone = player.location
else: if zone is None or not isinstance(zone, Zone):
await player.send("You see nothing special.\r\n") return None
name_lower = name.lower()
for obj in zone.contents_at(player.x, player.y):
# Only examine Things and Entities
if not isinstance(obj, (Thing, Entity)):
continue
# Match by name
if obj.name.lower() == name_lower:
return obj
# Match by alias (Things have aliases)
if isinstance(obj, Thing) and name_lower in (a.lower() for a in obj.aliases):
return obj
return None
async def cmd_examine(player: Player, args: str) -> None: async def cmd_examine(player: Player, args: str) -> None:
@ -60,7 +55,26 @@ async def cmd_examine(player: Player, args: str) -> None:
await player.send("Examine what?\r\n") await player.send("Examine what?\r\n")
return return
await examine_target(player, args.strip(), prefer_inventory=True) target_name = args.strip()
# Search inventory first
found = _find_object_in_inventory(target_name, player)
# Then search ground
if not found:
found = _find_object_at_position(target_name, player)
# Not found anywhere
if not found:
await player.send("You don't see that here.\r\n")
return
# Show description (both Thing and Entity have description)
desc = getattr(found, "description", "")
if desc:
await player.send(f"{desc}\r\n")
else:
await player.send("You see nothing special.\r\n")
register(CommandDefinition("examine", cmd_examine, aliases=["ex"], mode="*")) register(CommandDefinition("examine", cmd_examine, aliases=["ex"], mode="*"))

View file

@ -123,11 +123,7 @@ async def _show_single_command(
# Combat move specific details # Combat move specific details
if move is not None: if move is not None:
lines.append(f" stamina: {move.stamina_cost}") lines.append(f" stamina: {move.stamina_cost}")
if move.move_type == "attack": lines.append(f" timing window: {move.timing_window_ms}ms")
lines.append(f" hit time: {move.hit_time_ms}ms")
else: # defense
lines.append(f" active window: {move.active_ms}ms")
lines.append(f" recovery: {move.recovery_ms}ms")
if move.damage_pct > 0: if move.damage_pct > 0:
damage_pct = int(move.damage_pct * 100) damage_pct = int(move.damage_pct * 100)
lines.append(f" damage: {damage_pct}%") lines.append(f" damage: {damage_pct}%")
@ -192,11 +188,7 @@ async def _show_variant_overview(
lines.append(f" aliases: {aliases_str}") lines.append(f" aliases: {aliases_str}")
lines.append(f" stamina: {move.stamina_cost}") lines.append(f" stamina: {move.stamina_cost}")
if move.move_type == "attack": lines.append(f" timing window: {move.timing_window_ms}ms")
lines.append(f" hit time: {move.hit_time_ms}ms")
else: # defense
lines.append(f" active window: {move.active_ms}ms")
lines.append(f" recovery: {move.recovery_ms}ms")
if move.damage_pct > 0: if move.damage_pct > 0:
damage_pct = int(move.damage_pct * 100) damage_pct = int(move.damage_pct * 100)

View file

@ -37,11 +37,52 @@ async def cmd_look(player: Player, args: str) -> None:
player: The player executing the command player: The player executing the command
args: Command arguments (if provided, use targeting to resolve) args: Command arguments (if provided, use targeting to resolve)
""" """
# If args provided, route directly to examine behavior. # If args provided, use targeting to resolve
if args.strip(): if args.strip():
from mudlib.commands.examine import examine_target from mudlib.targeting import (
find_entity_on_tile,
find_in_inventory,
find_thing_on_tile,
)
await examine_target(player, args.strip(), prefer_inventory=False) target_name = args.strip()
# First try to find an entity on the tile
entity = find_entity_on_tile(target_name, player)
if entity:
# Show entity info (name and posture)
if hasattr(entity, "description") and entity.description:
await player.send(f"{entity.description}\r\n")
else:
await player.send(f"{entity.name} is {entity.posture}.\r\n")
return
# Then try to find a thing on the ground
zone = player.location
if zone is not None and isinstance(zone, Zone):
thing = find_thing_on_tile(target_name, zone, player.x, player.y)
if thing:
# Show thing description
desc = getattr(thing, "description", "")
if desc:
await player.send(f"{desc}\r\n")
else:
await player.send("You see nothing special.\r\n")
return
# Finally try inventory
thing = find_in_inventory(target_name, player)
if thing:
# Show thing description
desc = getattr(thing, "description", "")
if desc:
await player.send(f"{desc}\r\n")
else:
await player.send("You see nothing special.\r\n")
return
# Nothing found
await player.send("You don't see that here.\r\n")
return return
zone = player.location zone = player.location
@ -179,7 +220,7 @@ async def cmd_look(player: Player, args: str) -> None:
output.append(render_nearby(nearby_entities, player)) output.append(render_nearby(nearby_entities, player))
# Exits line # Exits line
output.append(render_exits(zone, player.x, player.y, player)) output.append(render_exits(zone, player.x, player.y))
# Send to player # Send to player
player.writer.write("\r\n".join(output) + "\r\n") player.writer.write("\r\n".join(output) + "\r\n")

View file

@ -2,7 +2,7 @@
from mudlib.combat.engine import end_encounter, get_encounter from mudlib.combat.engine import end_encounter, get_encounter
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.player import Player from mudlib.player import Player, players
DEATH_PL = -100.0 DEATH_PL = -100.0
@ -14,32 +14,37 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
player: The player executing the command player: The player executing the command
args: Target name args: Target name
""" """
# Get encounter
encounter = get_encounter(player)
if encounter is None:
await player.send("You're not in combat.\r\n")
return
# Parse target # Parse target
target_name = args.strip() target_name = args.strip()
if not target_name: if not target_name:
await player.send("Snap whose neck?\r\n") await player.send("Snap whose neck?\r\n")
return return
# Must be used during an active encounter. # Find target
encounter = get_encounter(player) target = players.get(target_name)
if encounter is None: if target is None and player.location is not None:
await player.send("You're not in combat.\r\n") from mudlib.mobs import get_nearby_mob
return from mudlib.zone import Zone
# Find target on this tile. if isinstance(player.location, Zone):
from mudlib.targeting import find_entity_on_tile target = get_nearby_mob(target_name, player.x, player.y, player.location)
target = find_entity_on_tile(target_name, player)
if target is None: if target is None:
await player.send(f"You don't see {target_name} here.\r\n") await player.send(f"You don't see {target_name} here.\r\n")
return return
if target is player: # Verify target is in the encounter
await player.send("You can't do that to yourself.\r\n") if encounter.attacker is not player and encounter.defender is not player:
await player.send("You're not in combat with that target.\r\n")
return return
# Snap neck can only target your current opponent. if encounter.attacker is not target and encounter.defender is not target:
if target not in (encounter.attacker, encounter.defender):
await player.send("You're not in combat with that target.\r\n") await player.send("You're not in combat with that target.\r\n")
return return
@ -59,38 +64,16 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
from mudlib.entity import Mob from mudlib.entity import Mob
from mudlib.gmcp import send_char_vitals from mudlib.gmcp import send_char_vitals
if isinstance(target, Player): if not isinstance(target, Mob):
send_char_vitals(target) send_char_vitals(target)
# Award kill/death stats on explicit finishers only. # Handle mob despawn
player.kills += 1
if isinstance(target, Player):
target.deaths += 1
elif isinstance(target, Mob):
player.mob_kills[target.name] = player.mob_kills.get(target.name, 0) + 1
# Check for newly unlocked moves after a finisher kill.
from mudlib.combat.commands import combat_moves
from mudlib.combat.unlock import check_unlocks
newly_unlocked = check_unlocks(player, combat_moves)
for move_name in newly_unlocked:
await player.send(f"You have learned {move_name}!\r\n")
# Handle mob corpse/death
if isinstance(target, Mob): if isinstance(target, Mob):
from mudlib.corpse import create_corpse from mudlib.mobs import despawn_mob
from mudlib.mobs import despawn_mob, mob_templates
from mudlib.zone import Zone
zone = target.location despawn_mob(target)
if isinstance(zone, Zone):
template = mob_templates.get(target.name)
loot_table = template.loot if template else None
create_corpse(target, zone, loot_table=loot_table)
else:
despawn_mob(target)
# Pop combat mode from both entities. # Pop combat mode from both entities if they're Players
from mudlib.gmcp import send_char_status from mudlib.gmcp import send_char_status
if isinstance(player, Player) and player.mode == "combat": if isinstance(player, Player) and player.mode == "combat":

View file

@ -46,8 +46,11 @@ async def process_mobs(combat_moves: dict[str, CombatMove]) -> None:
# Determine if mob is attacker or defender in this encounter # Determine if mob is attacker or defender in this encounter
mob_is_defender = encounter.defender is mob mob_is_defender = encounter.defender is mob
# Defense AI: react during PENDING when mob is defender # Defense AI: react during TELEGRAPH or WINDOW when mob is defender
if mob_is_defender and encounter.state == CombatState.PENDING: if mob_is_defender and encounter.state in (
CombatState.TELEGRAPH,
CombatState.WINDOW,
):
_try_defend(mob, encounter, combat_moves, now) _try_defend(mob, encounter, combat_moves, now)
continue continue

View file

@ -80,7 +80,7 @@ def render_nearby(entities: list, viewer) -> str:
return f"Nearby: ({count}) {names}" return f"Nearby: ({count}) {names}"
def render_exits(zone, x: int, y: int, viewer=None) -> str: def render_exits(zone, x: int, y: int) -> str:
"""Render available exits from current position. """Render available exits from current position.
Args: Args:
@ -94,6 +94,7 @@ def render_exits(zone, x: int, y: int, viewer=None) -> str:
exits = [] exits = []
# Check cardinal directions # Check cardinal directions
# NOTE: up/down exits deferred until z-axis movement is implemented
if zone.is_passable(x, y - 1): # north (y decreases going up) if zone.is_passable(x, y - 1): # north (y decreases going up)
exits.append("north") exits.append("north")
if zone.is_passable(x, y + 1): # south if zone.is_passable(x, y + 1): # south
@ -102,11 +103,6 @@ def render_exits(zone, x: int, y: int, viewer=None) -> str:
exits.append("east") exits.append("east")
if zone.is_passable(x - 1, y): # west if zone.is_passable(x - 1, y): # west
exits.append("west") exits.append("west")
# Vertical exit is based on current altitude state.
if viewer is not None and getattr(viewer, "flying", False):
exits.append("down")
elif viewer is not None:
exits.append("up")
if exits: if exits:
return f"Exits: {' '.join(exits)}" return f"Exits: {' '.join(exits)}"
@ -115,7 +111,6 @@ def render_exits(zone, x: int, y: int, viewer=None) -> str:
_POSTURE_MESSAGES = { _POSTURE_MESSAGES = {
"standing": "is standing here.", "standing": "is standing here.",
"sleeping": "is sleeping here.",
"resting": "is resting here.", "resting": "is resting here.",
"flying": "is flying above.", "flying": "is flying above.",
"fighting": "is fighting here.", "fighting": "is fighting here.",

View file

@ -502,8 +502,7 @@ def load_aliases(name: str, db_path: str | Path | None = None) -> dict[str, str]
(name,), (name,),
) )
# Normalize keys to lowercase so dispatch can match consistently. result = {alias: expansion for alias, expansion in cursor.fetchall()}
result = {alias.lower(): expansion for alias, expansion in cursor.fetchall()}
conn.close() conn.close()
return result return result

View file

@ -38,6 +38,19 @@ def zone():
return z return z
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
def make_player(name, zone, mock_writer, mock_reader, is_admin=False): def make_player(name, zone, mock_writer, mock_reader, is_admin=False):
p = Player( p = Player(
name=name, name=name,

View file

@ -25,11 +25,11 @@ def test_save_and_load_aliases_roundtrip():
db_path = Path(tmpdir) / "test.db" db_path = Path(tmpdir) / "test.db"
init_db(db_path) init_db(db_path)
aliases = {"PR": "punch right", "pl": "punch left", "l": "look"} aliases = {"pr": "punch right", "pl": "punch left", "l": "look"}
save_aliases("goku", aliases, db_path) save_aliases("goku", aliases, db_path)
loaded = load_aliases("goku", db_path) loaded = load_aliases("goku", db_path)
assert loaded == {"pr": "punch right", "pl": "punch left", "l": "look"} assert loaded == aliases
def test_load_aliases_empty(): def test_load_aliases_empty():
@ -76,16 +76,6 @@ async def test_alias_create(player):
player.writer.write.assert_called_with("Alias set: pr -> punch right\r\n") player.writer.write.assert_called_with("Alias set: pr -> punch right\r\n")
@pytest.mark.asyncio
async def test_alias_normalizes_name_to_lowercase(player):
"""Alias names are normalized so dispatch lookup is consistent."""
from mudlib.commands.alias import cmd_alias
await cmd_alias(player, "PR punch right")
assert "pr" in player.aliases
assert "PR" not in player.aliases
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_alias_list_with_aliases(player): async def test_alias_list_with_aliases(player):
"""alias with no args lists all aliases.""" """alias with no args lists all aliases."""
@ -123,16 +113,6 @@ async def test_unalias_removes_alias(player):
player.writer.write.assert_called_with("Alias removed: pr\r\n") player.writer.write.assert_called_with("Alias removed: pr\r\n")
@pytest.mark.asyncio
async def test_unalias_is_case_insensitive(player):
"""unalias should remove aliases regardless of case."""
from mudlib.commands.alias import cmd_unalias
player.aliases = {"pr": "punch right"}
await cmd_unalias(player, "PR")
assert "pr" not in player.aliases
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unalias_no_such_alias(player): async def test_unalias_no_such_alias(player):
"""unalias on non-existent alias shows error.""" """unalias on non-existent alias shows error."""
@ -155,18 +135,6 @@ async def test_alias_cannot_override_builtin(player):
assert "look" not in player.aliases assert "look" not in player.aliases
@pytest.mark.asyncio
async def test_alias_builtin_collision_is_case_insensitive(player):
"""Built-in collision checks should apply regardless of alias casing."""
import mudlib.commands.look # noqa: F401 - needed to register look command
from mudlib.commands.alias import cmd_alias
await cmd_alias(player, "LOOK punch right")
player.writer.write.assert_called_with(
"Cannot alias over built-in command: look\r\n"
)
# Dispatch integration tests # Dispatch integration tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_alias_expands_in_dispatch(player): async def test_alias_expands_in_dispatch(player):
@ -183,21 +151,6 @@ async def test_alias_expands_in_dispatch(player):
assert called_with == ["hello"] assert called_with == ["hello"]
@pytest.mark.asyncio
async def test_alias_expands_in_dispatch_case_insensitive_key(player):
"""Stored aliases with uppercase keys still load/use as lowercase."""
called_with = []
async def test_handler(p, args):
called_with.append(args)
register(CommandDefinition("testcmd", test_handler))
player.aliases["pr"] = "testcmd"
await dispatch(player, "PR hello")
assert called_with == ["hello"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_alias_with_extra_args(player): async def test_alias_with_extra_args(player):
"""Alias expansion preserves additional arguments.""" """Alias expansion preserves additional arguments."""

View file

@ -1,5 +1,7 @@
"""Tests for builder commands.""" """Tests for builder commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib.player import Player, players from mudlib.player import Player, players
@ -24,6 +26,19 @@ def zone():
return z return z
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def player(zone, mock_writer, mock_reader): def player(zone, mock_writer, mock_reader):
p = Player( p = Player(

View file

@ -2,6 +2,7 @@
import time import time
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -37,6 +38,19 @@ def test_zone():
return zone return zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer, test_zone):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
@ -331,14 +345,14 @@ async def test_switch_attack_sends_new_telegraph(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_defense_does_not_block(player, dodge_left): async def test_defense_blocks_for_timing_window(player, dodge_left):
"""Test defense no longer blocks (encounter tracks active/recovery internally).""" """Test defense sleeps for timing_window_ms (commitment via blocking)."""
before = time.monotonic() before = time.monotonic()
await combat_commands.do_defend(player, "", dodge_left) await combat_commands.do_defend(player, "", dodge_left)
elapsed = time.monotonic() - before elapsed = time.monotonic() - before
# Should return immediately, not block for active_ms expected = dodge_left.timing_window_ms / 1000.0
assert elapsed < 0.1 # Allow for some overhead assert elapsed >= expected - 0.05
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -25,7 +25,7 @@ def punch():
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
hit_time_ms=800, timing_window_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left", "parry high"], countered_by=["dodge left", "parry high"],
) )
@ -37,8 +37,7 @@ def dodge():
name="dodge left", name="dodge left",
move_type="defense", move_type="defense",
stamina_cost=3.0, stamina_cost=3.0,
active_ms=800, timing_window_ms=800,
recovery_ms=2700,
) )
@ -48,8 +47,7 @@ def wrong_dodge():
name="dodge right", name="dodge right",
move_type="defense", move_type="defense",
stamina_cost=3.0, stamina_cost=3.0,
active_ms=800, timing_window_ms=800,
recovery_ms=2700,
) )
@ -59,7 +57,7 @@ def sweep():
name="sweep", name="sweep",
move_type="attack", move_type="attack",
stamina_cost=8.0, stamina_cost=8.0,
hit_time_ms=600, timing_window_ms=600,
damage_pct=0.20, damage_pct=0.20,
countered_by=["jump"], countered_by=["jump"],
) )
@ -74,12 +72,12 @@ def test_combat_encounter_initial_state(attacker, defender):
assert encounter.move_started_at == 0.0 assert encounter.move_started_at == 0.0
def test_attack_transitions_to_pending(attacker, defender, punch): def test_attack_transitions_to_telegraph(attacker, defender, punch):
"""Test attacking transitions to PENDING state.""" """Test attacking transitions to TELEGRAPH state."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
assert encounter.state == CombatState.PENDING assert encounter.state == CombatState.TELEGRAPH
assert encounter.current_move is punch assert encounter.current_move is punch
assert encounter.move_started_at > 0.0 assert encounter.move_started_at > 0.0
@ -94,13 +92,12 @@ def test_attack_applies_stamina_cost(attacker, defender, punch):
def test_defend_records_pending_defense(attacker, defender, punch, dodge): def test_defend_records_pending_defense(attacker, defender, punch, dodge):
"""Test defend records the defense move and activates it.""" """Test defend records the defense move."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
encounter.defend(dodge) encounter.defend(dodge)
assert encounter.pending_defense is dodge assert encounter.pending_defense is dodge
assert encounter.defense_activated_at is not None
def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge): def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
@ -114,12 +111,29 @@ def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
assert defender.stamina == initial_stamina assert defender.stamina == initial_stamina
def test_tick_pending_to_resolve(attacker, defender, punch): def test_tick_telegraph_to_window(attacker, defender, punch):
"""Test tick advances from PENDING to RESOLVE after hit time.""" """Test tick advances from TELEGRAPH to WINDOW after brief delay."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
# Wait for hit_time_ms (800ms) # Wait for telegraph phase (300ms)
time.sleep(0.31)
now = time.monotonic()
encounter.tick(now)
assert encounter.state == CombatState.WINDOW
def test_tick_window_to_resolve(attacker, defender, punch):
"""Test tick advances from WINDOW to RESOLVE after timing window."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
# Skip to WINDOW state
time.sleep(0.31)
encounter.tick(time.monotonic())
# Wait for timing window to expire (800ms)
time.sleep(0.85) time.sleep(0.85)
now = time.monotonic() now = time.monotonic()
encounter.tick(now) encounter.tick(now)
@ -198,11 +212,16 @@ def test_full_state_machine_cycle(attacker, defender, punch):
"""Test complete state machine cycle from IDLE to IDLE.""" """Test complete state machine cycle from IDLE to IDLE."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
# IDLE → PENDING # IDLE → TELEGRAPH
encounter.attack(punch) encounter.attack(punch)
assert encounter.state == CombatState.PENDING assert encounter.state == CombatState.TELEGRAPH
# PENDING → RESOLVE (after hit_time_ms) # TELEGRAPH → WINDOW
time.sleep(0.31)
encounter.tick(time.monotonic())
assert encounter.state == CombatState.WINDOW
# WINDOW → RESOLVE
time.sleep(0.85) time.sleep(0.85)
encounter.tick(time.monotonic()) encounter.tick(time.monotonic())
assert encounter.state == CombatState.RESOLVE assert encounter.state == CombatState.RESOLVE
@ -215,12 +234,13 @@ def test_full_state_machine_cycle(attacker, defender, punch):
def test_combat_state_enum(): def test_combat_state_enum():
"""Test CombatState enum values.""" """Test CombatState enum values."""
assert CombatState.IDLE.value == "idle" assert CombatState.IDLE.value == "idle"
assert CombatState.PENDING.value == "pending" assert CombatState.TELEGRAPH.value == "telegraph"
assert CombatState.WINDOW.value == "window"
assert CombatState.RESOLVE.value == "resolve" assert CombatState.RESOLVE.value == "resolve"
def test_resolve_knockout_does_not_end_combat(attacker, defender, punch): def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch):
"""KO should not end combat by itself.""" """Test resolve returns combat_ended=True when defender PL <= 0."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
# Set defender to low PL so attack will knock them out # Set defender to low PL so attack will knock them out
@ -230,12 +250,12 @@ def test_resolve_knockout_does_not_end_combat(attacker, defender, punch):
result = encounter.resolve() result = encounter.resolve()
assert defender.pl <= 0 assert defender.pl <= 0
assert result.combat_ended is False assert result.combat_ended is True
assert result.damage > 0 assert result.damage > 0
def test_resolve_exhaustion_does_not_end_combat(attacker, defender, punch): def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch):
"""Exhaustion should not end combat by itself.""" """Test resolve returns combat_ended=True when attacker stamina <= 0."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
# Set attacker stamina to exactly the cost so attack depletes it # Set attacker stamina to exactly the cost so attack depletes it
@ -245,19 +265,7 @@ def test_resolve_exhaustion_does_not_end_combat(attacker, defender, punch):
result = encounter.resolve() result = encounter.resolve()
assert attacker.stamina <= 0 assert attacker.stamina <= 0
assert result.combat_ended is False assert result.combat_ended is True
def test_resolve_exhausted_defender_does_not_end_combat(attacker, defender, punch):
"""Exhausted defender still does not auto-end encounter."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
defender.stamina = 0.0
encounter.attack(punch)
result = encounter.resolve()
assert defender.stamina <= 0
assert result.combat_ended is False
def test_resolve_returns_combat_continues_normally(attacker, defender, punch): def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
@ -304,22 +312,41 @@ def test_resolve_counter_template_indicates_counter(attacker, defender, punch, d
# --- Attack switching (feint) tests --- # --- Attack switching (feint) tests ---
def test_switch_attack_during_pending(attacker, defender, punch, sweep): def test_switch_attack_during_telegraph(attacker, defender, punch, sweep):
"""Test attack during PENDING replaces move and restarts timer.""" """Test attack during TELEGRAPH replaces move and keeps timer."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
original_start = encounter.move_started_at original_start = encounter.move_started_at
assert encounter.state == CombatState.PENDING assert encounter.state == CombatState.TELEGRAPH
# Switch to sweep during pending # Switch to sweep during telegraph
time.sleep(0.1) # Small delay to ensure timer would differ
encounter.attack(sweep) encounter.attack(sweep)
assert encounter.current_move is sweep assert encounter.current_move is sweep
assert encounter.state == CombatState.PENDING assert encounter.state == CombatState.TELEGRAPH
# Timer should restart on switch # Timer should NOT restart
assert encounter.move_started_at > original_start assert encounter.move_started_at == original_start
def test_switch_attack_during_window(attacker, defender, punch, sweep):
"""Test attack during WINDOW replaces move and keeps timer."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
original_start = encounter.move_started_at
# Advance to WINDOW
time.sleep(0.31)
encounter.tick(time.monotonic())
assert encounter.state == CombatState.WINDOW
# Switch to sweep during window
encounter.attack(sweep)
assert encounter.current_move is sweep
assert encounter.state == CombatState.WINDOW
# Timer should NOT restart
assert encounter.move_started_at == original_start
def test_switch_refunds_old_stamina(attacker, defender, punch, sweep): def test_switch_refunds_old_stamina(attacker, defender, punch, sweep):
@ -366,288 +393,27 @@ def test_resolve_uses_final_move(attacker, defender, punch, sweep):
assert result.resolve_template != "" assert result.resolve_template != ""
# --- last_action_at (last landed damage) tracking tests --- # --- last_action_at tracking tests ---
def test_last_action_at_not_updated_on_attack(attacker, defender, punch): def test_last_action_at_updates_on_attack(attacker, defender, punch):
"""Attack startup should not reset timeout until damage lands.""" """Test last_action_at is set when attack() is called."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
assert encounter.last_action_at == 0.0 assert encounter.last_action_at == 0.0
encounter.attack(punch)
assert encounter.last_action_at == 0.0
def test_last_action_at_not_updated_on_defend(attacker, defender, punch, dodge):
"""Defense input should not reset timeout without landed damage."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
assert encounter.last_action_at == 0.0
encounter.defend(dodge)
assert encounter.last_action_at == 0.0
def test_last_action_at_updates_when_damage_lands(attacker, defender, punch):
"""Landed damage should refresh timeout timestamp."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
assert encounter.last_action_at == 0.0
encounter.attack(punch)
before = time.monotonic() before = time.monotonic()
encounter.resolve() encounter.attack(punch)
assert encounter.last_action_at >= before assert encounter.last_action_at >= before
def test_last_action_at_unchanged_when_attack_is_countered( def test_last_action_at_updates_on_defend(attacker, defender, punch, dodge):
attacker, defender, punch, dodge """Test last_action_at is set when defend() is called."""
): encounter = CombatEncounter(attacker=attacker, defender=defender)
"""No damage (successful counter) should not refresh timeout timestamp."""
encounter = CombatEncounter(
attacker=attacker, defender=defender, last_action_at=10.0
)
encounter.attack(punch) encounter.attack(punch)
first_action = encounter.last_action_at
time.sleep(0.01)
encounter.defend(dodge) encounter.defend(dodge)
encounter.resolve()
assert encounter.last_action_at == 10.0
assert encounter.last_action_at > first_action
# --- Defense active/recovery window tests ---
def test_defense_expired_before_resolve(attacker, defender):
"""Defense activates, active_ms passes, attack resolves.
Defense should NOT counter (hit lands).
"""
# Create attack with 800ms hit time
quick_attack = CombatMove(
name="quick punch",
move_type="attack",
stamina_cost=5.0,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["quick dodge"],
)
# Create defense with short active window (200ms)
quick_dodge = CombatMove(
name="quick dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(quick_attack)
encounter.defend(quick_dodge)
# Wait for defense to expire (200ms) but before attack lands (800ms)
time.sleep(0.31)
encounter.tick(time.monotonic())
# Now resolve — defense should be expired
initial_pl = defender.pl
result = encounter.resolve()
# Defense expired, so attack should hit for full damage
expected_damage = attacker.pl * quick_attack.damage_pct * 1.5
assert defender.pl == initial_pl - expected_damage
assert result.damage == expected_damage
assert result.countered is False
def test_defense_active_during_resolve(attacker, defender):
"""Defense activates within active_ms of resolve — defense SHOULD counter."""
# Create attack with 800ms hit time
quick_attack = CombatMove(
name="quick punch",
move_type="attack",
stamina_cost=5.0,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["quick dodge"],
)
# Create defense with long active window (1000ms)
quick_dodge = CombatMove(
name="quick dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=1000,
recovery_ms=300,
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(quick_attack)
encounter.defend(quick_dodge)
# Immediately resolve — defense is still active
initial_pl = defender.pl
result = encounter.resolve()
# Defense is active, should counter successfully
assert defender.pl == initial_pl
assert result.damage == 0.0
assert result.countered is True
def test_defense_queuing_during_recovery(attacker, defender):
"""defend() while in recovery should set queued_defense, not pending_defense."""
quick_dodge = CombatMove(
name="quick dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
# First defend — activates immediately
encounter.defend(quick_dodge)
assert encounter.pending_defense is quick_dodge
assert encounter.defense_activated_at is not None
# Wait for defense to expire and enter recovery
time.sleep(0.31)
encounter.tick(time.monotonic())
assert encounter.pending_defense is None
assert encounter.defense_recovery_until is not None
# Try to defend during recovery — should queue
second_dodge = CombatMove(
name="second dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter.defend(second_dodge)
# Should be queued, not pending
assert encounter.pending_defense is None
assert encounter.queued_defense is second_dodge
def test_queued_defense_activates_after_recovery(attacker, defender):
"""After recovery_ms passes, tick() should activate the queued defense."""
quick_dodge = CombatMove(
name="quick dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
# First defend
encounter.defend(quick_dodge)
# Wait for defense to expire
time.sleep(0.31)
encounter.tick(time.monotonic())
# Queue second defense during recovery
second_dodge = CombatMove(
name="second dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter.defend(second_dodge)
assert encounter.queued_defense is second_dodge
assert encounter.pending_defense is None
# Wait for recovery to finish
time.sleep(0.31)
encounter.tick(time.monotonic())
# Queued defense should now be active
assert encounter.pending_defense is second_dodge
assert encounter.queued_defense is None
assert encounter.defense_activated_at is not None
def test_recovery_persists_after_resolve(attacker, defender):
"""resolve() should NOT clear defense_recovery_until.
Recovery carries across attacks.
"""
quick_attack = CombatMove(
name="quick punch",
move_type="attack",
stamina_cost=5.0,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["quick dodge"],
)
quick_dodge = CombatMove(
name="quick dodge",
move_type="defense",
stamina_cost=3.0,
active_ms=200,
recovery_ms=300,
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
# Defend, then wait for defense to expire and enter recovery
encounter.defend(quick_dodge)
time.sleep(0.31)
encounter.tick(time.monotonic())
recovery_until = encounter.defense_recovery_until
assert recovery_until is not None
# Attack and resolve during recovery period
encounter.attack(quick_attack)
encounter.resolve()
# Recovery should persist after resolve
assert encounter.defense_recovery_until == recovery_until
def test_attack_switch_restarts_timer(attacker, defender):
"""attack(), then attack() again with different move.
move_started_at should reset.
"""
first_attack = CombatMove(
name="first punch",
move_type="attack",
stamina_cost=5.0,
hit_time_ms=800,
damage_pct=0.15,
countered_by=[],
)
second_attack = CombatMove(
name="second punch",
move_type="attack",
stamina_cost=5.0,
hit_time_ms=800,
damage_pct=0.15,
countered_by=[],
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(first_attack)
first_start = encounter.move_started_at
# Small delay to ensure time difference
time.sleep(0.1)
# Switch to second attack
encounter.attack(second_attack)
second_start = encounter.move_started_at
# Timer should have restarted
assert second_start > first_start
assert encounter.current_move is second_attack

View file

@ -49,7 +49,7 @@ def punch():
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
hit_time_ms=800, timing_window_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -107,7 +107,7 @@ async def test_process_combat_advances_encounters(attacker, defender, punch):
time.sleep(0.31) time.sleep(0.31)
await process_combat() await process_combat()
assert encounter.state == CombatState.PENDING assert encounter.state == CombatState.WINDOW
@pytest.mark.asyncio @pytest.mark.asyncio
@ -127,8 +127,8 @@ async def test_process_combat_handles_multiple_encounters(punch):
time.sleep(0.31) time.sleep(0.31)
await process_combat() await process_combat()
assert enc1.state == CombatState.PENDING assert enc1.state == CombatState.WINDOW
assert enc2.state == CombatState.PENDING assert enc2.state == CombatState.WINDOW
@pytest.mark.asyncio @pytest.mark.asyncio
@ -141,7 +141,7 @@ async def test_process_combat_auto_resolves_expired_windows(attacker, defender,
# Skip past telegraph and window # Skip past telegraph and window
time.sleep(0.31) # Telegraph time.sleep(0.31) # Telegraph
await process_combat() await process_combat()
assert encounter.state == CombatState.PENDING assert encounter.state == CombatState.WINDOW
time.sleep(0.85) # Window time.sleep(0.85) # Window
await process_combat() await process_combat()
@ -201,8 +201,8 @@ async def test_encounter_cleanup_after_resolution(attacker, defender, punch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_combat_keeps_encounter_after_knockout(punch): async def test_process_combat_ends_encounter_on_knockout(punch):
"""KO should not end combat; encounter stays active.""" """Test process_combat ends encounter when defender is knocked out."""
w = _mock_writer w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w()) attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0, writer=w()) defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0, writer=w())
@ -220,16 +220,17 @@ async def test_process_combat_keeps_encounter_after_knockout(punch):
time.sleep(0.85) time.sleep(0.85)
await process_combat() await process_combat()
# Combat should remain active after KO # Combat should have ended and been cleaned up
assert get_encounter(attacker) is encounter assert get_encounter(attacker) is None
assert get_encounter(defender) is encounter assert get_encounter(defender) is None
assert attacker.mode_stack == ["normal", "combat"] # Mode stacks should have combat popped
assert defender.mode_stack == ["normal", "combat"] assert attacker.mode_stack == ["normal"]
assert defender.mode_stack == ["normal"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_combat_keeps_encounter_after_exhaustion(punch): async def test_process_combat_ends_encounter_on_exhaustion(punch):
"""Exhaustion should not end combat; encounter stays active.""" """Test process_combat ends encounter when attacker is exhausted."""
w = _mock_writer w = _mock_writer
attacker = Player( attacker = Player(
name="Goku", name="Goku",
@ -261,68 +262,11 @@ async def test_process_combat_keeps_encounter_after_exhaustion(punch):
time.sleep(0.85) time.sleep(0.85)
await process_combat() await process_combat()
# Combat should remain active # Combat should have ended
assert get_encounter(attacker) is encounter assert get_encounter(attacker) is None
assert get_encounter(defender) is encounter assert get_encounter(defender) is None
assert attacker.mode_stack == ["normal", "combat"] assert attacker.mode_stack == ["normal"]
assert defender.mode_stack == ["normal", "combat"] assert defender.mode_stack == ["normal"]
@pytest.mark.asyncio
async def test_process_combat_keeps_encounter_when_defender_already_exhausted(punch):
"""Defender exhaustion should not auto-end encounter."""
w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=0.0, writer=w())
attacker.mode_stack.append("combat")
defender.mode_stack.append("combat")
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
time.sleep(0.31)
await process_combat()
time.sleep(0.85)
await process_combat()
assert get_encounter(attacker) is encounter
assert get_encounter(defender) is encounter
assert attacker.mode_stack == ["normal", "combat"]
assert defender.mode_stack == ["normal", "combat"]
@pytest.mark.asyncio
async def test_process_combat_keeps_encounter_when_both_unconscious(punch):
"""Double unconscious should not auto-end; timeout/finisher decides."""
w = _mock_writer
attacker = Player(
name="Goku",
x=0,
y=0,
pl=100.0,
stamina=punch.stamina_cost,
writer=w(),
)
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=0.0, writer=w())
attacker.mode_stack.append("combat")
defender.mode_stack.append("combat")
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
time.sleep(0.31)
await process_combat()
time.sleep(0.85)
await process_combat()
assert get_encounter(attacker) is encounter
assert get_encounter(defender) is encounter
assert attacker.kills == 0
assert defender.kills == 0
assert attacker.deaths == 0
assert defender.deaths == 0
@pytest.mark.asyncio @pytest.mark.asyncio
@ -391,7 +335,7 @@ async def test_process_combat_sends_messages_on_resolve(punch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_idle_timeout_ends_encounter(): async def test_idle_timeout_ends_encounter():
"""Encounter times out after 30s without landed damage.""" """Test encounter times out after 30s of no actions."""
w = _mock_writer w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, writer=w()) attacker = Player(name="Goku", x=0, y=0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, writer=w()) defender = Player(name="Vegeta", x=0, y=0, writer=w())
@ -449,7 +393,7 @@ async def test_idle_timeout_pops_combat_mode():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_recent_action_prevents_timeout(): async def test_recent_action_prevents_timeout():
"""Fresh encounter start prevents immediate timeout.""" """Test recent action prevents idle timeout."""
w = _mock_writer w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, writer=w()) attacker = Player(name="Goku", x=0, y=0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, writer=w()) defender = Player(name="Vegeta", x=0, y=0, writer=w())
@ -467,7 +411,7 @@ async def test_recent_action_prevents_timeout():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_encounter_sets_last_action_at(): async def test_start_encounter_sets_last_action_at():
"""start_encounter initializes no-damage timeout clock.""" """Test start_encounter initializes last_action_at."""
attacker = Entity(name="Goku", x=0, y=0) attacker = Entity(name="Goku", x=0, y=0)
defender = Entity(name="Vegeta", x=0, y=0) defender = Entity(name="Vegeta", x=0, y=0)
@ -475,25 +419,3 @@ async def test_start_encounter_sets_last_action_at():
encounter = start_encounter(attacker, defender) encounter = start_encounter(attacker, defender)
assert encounter.last_action_at >= before assert encounter.last_action_at >= before
@pytest.mark.asyncio
async def test_landed_damage_refreshes_timeout_clock(punch):
"""Successful hit should refresh timeout timer."""
w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, writer=w())
attacker.mode_stack.append("combat")
defender.mode_stack.append("combat")
encounter = start_encounter(attacker, defender)
# Keep close to timeout, but still allow resolve to land damage first.
encounter.last_action_at = time.monotonic() - 28.5
encounter.attack(punch)
time.sleep(0.31)
await process_combat()
time.sleep(0.85)
await process_combat()
# A landed hit should keep encounter alive by refreshing the timer.
assert get_encounter(attacker) is encounter

View file

@ -13,7 +13,7 @@ def test_combat_move_dataclass():
aliases=["pr"], aliases=["pr"],
stamina_cost=5.0, stamina_cost=5.0,
telegraph="{attacker} winds up a right hook!", telegraph="{attacker} winds up a right hook!",
hit_time_ms=800, timing_window_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left", "parry high"], countered_by=["dodge left", "parry high"],
command="punch", command="punch",
@ -24,7 +24,7 @@ def test_combat_move_dataclass():
assert move.aliases == ["pr"] assert move.aliases == ["pr"]
assert move.stamina_cost == 5.0 assert move.stamina_cost == 5.0
assert move.telegraph == "{attacker} winds up a right hook!" assert move.telegraph == "{attacker} winds up a right hook!"
assert move.hit_time_ms == 800 assert move.timing_window_ms == 800
assert move.damage_pct == 0.15 assert move.damage_pct == 0.15
assert move.countered_by == ["dodge left", "parry high"] assert move.countered_by == ["dodge left", "parry high"]
assert move.handler is None assert move.handler is None
@ -38,14 +38,14 @@ def test_combat_move_minimal():
name="test move", name="test move",
move_type="attack", move_type="attack",
stamina_cost=10.0, stamina_cost=10.0,
hit_time_ms=500, timing_window_ms=500,
) )
assert move.name == "test move" assert move.name == "test move"
assert move.move_type == "attack" assert move.move_type == "attack"
assert move.aliases == [] assert move.aliases == []
assert move.stamina_cost == 10.0 assert move.stamina_cost == 10.0
assert move.telegraph == "" assert move.telegraph == ""
assert move.hit_time_ms == 500 assert move.timing_window_ms == 500
assert move.damage_pct == 0.0 assert move.damage_pct == 0.0
assert move.countered_by == [] assert move.countered_by == []
assert move.command == "" assert move.command == ""
@ -60,7 +60,7 @@ aliases = ["rh"]
move_type = "attack" move_type = "attack"
stamina_cost = 8.0 stamina_cost = 8.0
telegraph = "{attacker} spins into a roundhouse kick!" telegraph = "{attacker} spins into a roundhouse kick!"
hit_time_ms = 600 timing_window_ms = 600
damage_pct = 0.25 damage_pct = 0.25
countered_by = ["duck", "parry high", "parry low"] countered_by = ["duck", "parry high", "parry low"]
""" """
@ -83,7 +83,7 @@ def test_load_variant_move_from_toml(tmp_path):
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 800 timing_window_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.left] [variants.left]
@ -114,7 +114,7 @@ countered_by = ["dodge left", "parry high"]
assert left.countered_by == ["dodge right", "parry high"] assert left.countered_by == ["dodge right", "parry high"]
# Inherited from parent # Inherited from parent
assert left.stamina_cost == 5.0 assert left.stamina_cost == 5.0
assert left.hit_time_ms == 800 assert left.timing_window_ms == 800
assert left.damage_pct == 0.15 assert left.damage_pct == 0.15
right = by_name["punch right"] right = by_name["punch right"]
@ -130,13 +130,13 @@ def test_variant_inherits_shared_properties(tmp_path):
name = "kick" name = "kick"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 800 timing_window_ms = 800
damage_pct = 0.10 damage_pct = 0.10
[variants.low] [variants.low]
aliases = ["kl"] aliases = ["kl"]
damage_pct = 0.08 damage_pct = 0.08
hit_time_ms = 600 timing_window_ms = 600
[variants.high] [variants.high]
aliases = ["kh"] aliases = ["kh"]
@ -150,12 +150,12 @@ damage_pct = 0.15
low = by_name["kick low"] low = by_name["kick low"]
assert low.damage_pct == 0.08 assert low.damage_pct == 0.08
assert low.hit_time_ms == 600 # overridden assert low.timing_window_ms == 600 # overridden
assert low.stamina_cost == 5.0 # inherited assert low.stamina_cost == 5.0 # inherited
high = by_name["kick high"] high = by_name["kick high"]
assert high.damage_pct == 0.15 assert high.damage_pct == 0.15
assert high.hit_time_ms == 800 # inherited assert high.timing_window_ms == 800 # inherited
assert high.stamina_cost == 5.0 # inherited assert high.stamina_cost == 5.0 # inherited
@ -165,8 +165,7 @@ def test_load_move_with_defaults(tmp_path):
name = "basic move" name = "basic move"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
active_ms = 600 timing_window_ms = 600
recovery_ms = 2700
""" """
toml_file = tmp_path / "basic.toml" toml_file = tmp_path / "basic.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)
@ -186,7 +185,7 @@ def test_load_move_missing_name(tmp_path):
toml_content = """ toml_content = """
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 800 timing_window_ms = 800
""" """
toml_file = tmp_path / "bad.toml" toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)
@ -214,7 +213,7 @@ def test_load_move_missing_stamina_cost(tmp_path):
toml_content = """ toml_content = """
name = "test" name = "test"
move_type = "attack" move_type = "attack"
hit_time_ms = 800 timing_window_ms = 800
""" """
toml_file = tmp_path / "bad.toml" toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)
@ -223,8 +222,8 @@ hit_time_ms = 800
load_move(toml_file) load_move(toml_file)
def test_load_attack_missing_hit_time_raises(tmp_path): def test_load_move_missing_timing_window(tmp_path):
"""Test loading attack without hit_time_ms raises error.""" """Test loading move without timing_window_ms raises error."""
toml_content = """ toml_content = """
name = "test" name = "test"
move_type = "attack" move_type = "attack"
@ -233,89 +232,10 @@ stamina_cost = 5.0
toml_file = tmp_path / "bad.toml" toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)
with pytest.raises(ValueError, match="hit_time_ms"): with pytest.raises(ValueError, match="missing required field.*timing_window_ms"):
load_move(toml_file) load_move(toml_file)
def test_load_attack_zero_hit_time_raises(tmp_path):
"""Test loading attack with hit_time_ms = 0 raises error."""
toml_content = """
name = "test"
move_type = "attack"
stamina_cost = 5.0
hit_time_ms = 0
"""
toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content)
with pytest.raises(ValueError, match="hit_time_ms"):
load_move(toml_file)
def test_load_defense_missing_active_ms_raises(tmp_path):
"""Test loading defense without active_ms raises error."""
toml_content = """
name = "test"
move_type = "defense"
stamina_cost = 3.0
recovery_ms = 2000
"""
toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content)
with pytest.raises(ValueError, match="active_ms"):
load_move(toml_file)
def test_load_defense_zero_active_ms_raises(tmp_path):
"""Test loading defense with active_ms = 0 raises error."""
toml_content = """
name = "test"
move_type = "defense"
stamina_cost = 3.0
active_ms = 0
recovery_ms = 2000
"""
toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content)
with pytest.raises(ValueError, match="active_ms"):
load_move(toml_file)
def test_load_attack_valid_passes(tmp_path):
"""Test loading attack with valid hit_time_ms passes."""
toml_content = """
name = "test"
move_type = "attack"
stamina_cost = 5.0
hit_time_ms = 500
"""
toml_file = tmp_path / "valid.toml"
toml_file.write_text(toml_content)
moves = load_move(toml_file)
assert len(moves) == 1
assert moves[0].hit_time_ms == 500
def test_load_defense_valid_passes(tmp_path):
"""Test loading defense with valid active_ms passes."""
toml_content = """
name = "test"
move_type = "defense"
stamina_cost = 3.0
active_ms = 500
recovery_ms = 2000
"""
toml_file = tmp_path / "valid.toml"
toml_file.write_text(toml_content)
moves = load_move(toml_file)
assert len(moves) == 1
assert moves[0].active_ms == 500
def test_load_moves_from_directory(tmp_path): def test_load_moves_from_directory(tmp_path):
"""Test loading all moves from a directory.""" """Test loading all moves from a directory."""
# Create a variant move # Create a variant move
@ -325,7 +245,7 @@ def test_load_moves_from_directory(tmp_path):
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 800 timing_window_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.right] [variants.right]
@ -342,8 +262,7 @@ countered_by = ["dodge left"]
name = "duck" name = "duck"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
active_ms = 500 timing_window_ms = 500
recovery_ms = 2700
""" """
) )
@ -384,7 +303,7 @@ name = "move one"
aliases = ["m"] aliases = ["m"]
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 800 timing_window_ms = 800
""" """
) )
@ -395,8 +314,7 @@ name = "move two"
aliases = ["m"] aliases = ["m"]
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
active_ms = 500 timing_window_ms = 500
recovery_ms = 2700
""" """
) )
@ -412,7 +330,7 @@ def test_load_moves_name_collision(tmp_path):
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 800 timing_window_ms = 800
""" """
) )
@ -422,7 +340,7 @@ hit_time_ms = 800
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 800 timing_window_ms = 800
""" """
) )
@ -440,7 +358,7 @@ def test_load_moves_validates_countered_by_refs(tmp_path, caplog):
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 800 timing_window_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.right] [variants.right]
@ -454,8 +372,7 @@ countered_by = ["dodge left", "nonexistent move"]
name = "dodge" name = "dodge"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
active_ms = 500 timing_window_ms = 500
recovery_ms = 2700
[variants.left] [variants.left]
aliases = ["dl"] aliases = ["dl"]
@ -484,7 +401,7 @@ def test_load_moves_valid_countered_by_refs_no_warning(tmp_path, caplog):
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
hit_time_ms = 800 timing_window_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.right] [variants.right]
@ -498,8 +415,7 @@ countered_by = ["dodge left", "parry high"]
name = "dodge" name = "dodge"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
active_ms = 500 timing_window_ms = 500
recovery_ms = 2700
[variants.left] [variants.left]
aliases = ["dl"] aliases = ["dl"]
@ -512,8 +428,7 @@ aliases = ["dl"]
name = "parry" name = "parry"
move_type = "defense" move_type = "defense"
stamina_cost = 3.0 stamina_cost = 3.0
active_ms = 500 timing_window_ms = 500
recovery_ms = 2700
[variants.high] [variants.high]
aliases = ["f"] aliases = ["f"]

View file

@ -17,7 +17,7 @@ def attack_move():
variant="left", variant="left",
move_type="attack", move_type="attack",
stamina_cost=5, stamina_cost=5,
hit_time_ms=850, timing_window_ms=850,
telegraph="telegraphs a left punch at {defender}", telegraph="telegraphs a left punch at {defender}",
telegraph_color="yellow", telegraph_color="yellow",
aliases=[], aliases=[],

View file

@ -136,7 +136,7 @@ async def test_flying_during_window_causes_miss(player, target, punch_right):
encounter.attack(punch_right) encounter.attack(punch_right)
# Advance to WINDOW phase # Advance to WINDOW phase
encounter.state = CombatState.PENDING encounter.state = CombatState.WINDOW
# Defender flies during window # Defender flies during window
target.flying = True target.flying = True
@ -159,7 +159,7 @@ async def test_both_flying_at_resolve_attack_lands(player, target, punch_right):
encounter.attack(punch_right) encounter.attack(punch_right)
# Advance to WINDOW phase (no altitude change) # Advance to WINDOW phase (no altitude change)
encounter.state = CombatState.PENDING encounter.state = CombatState.WINDOW
# Resolve # Resolve
result = encounter.resolve() result = encounter.resolve()
@ -180,7 +180,7 @@ async def test_attacker_flies_during_window_causes_miss(player, target, punch_ri
encounter.attack(punch_right) encounter.attack(punch_right)
# Advance to WINDOW phase # Advance to WINDOW phase
encounter.state = CombatState.PENDING encounter.state = CombatState.WINDOW
# Attacker flies during window # Attacker flies during window
player.flying = True player.flying = True
@ -205,7 +205,7 @@ async def test_flying_dodge_messages_correct_grammar(player, target, punch_right
encounter.attack(punch_right) encounter.attack(punch_right)
# Advance to WINDOW phase # Advance to WINDOW phase
encounter.state = CombatState.PENDING encounter.state = CombatState.WINDOW
# Defender flies during window # Defender flies during window
target.flying = True target.flying = True

View file

@ -21,6 +21,19 @@ def _clean_test_commands():
commands._registry.update(snapshot) commands._registry.update(snapshot)
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def test_zone(): def test_zone():
# Create a 100x100 zone filled with passable terrain # Create a 100x100 zone filled with passable terrain
@ -84,6 +97,7 @@ async def test_dispatch_routes_to_handler(player):
commands.register(CommandDefinition("testcmd", test_handler)) commands.register(CommandDefinition("testcmd", test_handler))
await commands.dispatch(player, "testcmd arg1 arg2") await commands.dispatch(player, "testcmd arg1 arg2")
assert called
assert received_args == "arg1 arg2" assert received_args == "arg1 arg2"
@ -346,7 +360,7 @@ async def test_dispatch_allows_wildcard_mode(player):
commands.register(CommandDefinition("universal", any_handler, mode="*")) commands.register(CommandDefinition("universal", any_handler, mode="*"))
await commands.dispatch(player, "universal") await commands.dispatch(player, "universal")
assert called is True assert called
@pytest.mark.asyncio @pytest.mark.asyncio
@ -362,7 +376,7 @@ async def test_dispatch_allows_matching_mode(player):
player.mode_stack.append("combat") player.mode_stack.append("combat")
await commands.dispatch(player, "strike") await commands.dispatch(player, "strike")
assert called is True assert called
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -1,6 +1,7 @@
"""Tests for the commands listing command.""" """Tests for the commands listing command."""
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -17,6 +18,19 @@ from mudlib.commands import (
) )
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer): def player(mock_reader, mock_writer):
from mudlib.player import Player from mudlib.player import Player
@ -161,7 +175,7 @@ async def test_commands_detail_simple_combat_move(player, combat_moves):
assert "roundhouse" in output assert "roundhouse" in output
assert "type: attack" in output assert "type: attack" in output
assert "stamina: 8.0" in output assert "stamina: 8.0" in output
assert "hit time: 3000ms" in output assert "timing window: 2000ms" in output
assert "damage: 25%" in output assert "damage: 25%" in output
assert "{attacker} shifts {his} weight back..." in output assert "{attacker} shifts {his} weight back..." in output
assert "countered by: duck, parry high, parry low" in output assert "countered by: duck, parry high, parry low" in output
@ -187,7 +201,7 @@ async def test_commands_detail_variant_base(player, combat_moves):
# Should show shared properties in each variant # Should show shared properties in each variant
assert "stamina: 5.0" in output assert "stamina: 5.0" in output
assert "hit time: 3000ms" in output assert "timing window: 1800ms" in output
assert "damage: 15%" in output assert "damage: 15%" in output
@ -200,7 +214,7 @@ async def test_commands_detail_specific_variant(player, combat_moves):
assert "punch left" in output assert "punch left" in output
assert "type: attack" in output assert "type: attack" in output
assert "stamina: 5.0" in output assert "stamina: 5.0" in output
assert "hit time: 3000ms" in output assert "timing window: 1800ms" in output
assert "damage: 15%" in output assert "damage: 15%" in output
assert "{attacker} retracts {his} left arm..." in output assert "{attacker} retracts {his} left arm..." in output
assert "countered by: dodge right, parry high" in output assert "countered by: dodge right, parry high" in output

View file

@ -1,44 +1,60 @@
"""Tests for the Container class.""" """Tests for the Container class."""
import pytest
from mudlib.container import Container from mudlib.container import Container
from mudlib.object import Object from mudlib.object import Object
from mudlib.player import Player
from mudlib.thing import Thing from mudlib.thing import Thing
from mudlib.zone import Zone from mudlib.zone import Zone
# --- fixtures ---
@pytest.fixture
def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="testzone",
width=10,
height=10,
toroidal=True,
terrain=terrain,
)
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(
name="TestPlayer",
x=5,
y=5,
reader=mock_reader,
writer=mock_writer,
location=test_zone,
)
return p
# --- construction --- # --- construction ---
def test_container_creation_minimal():
"""Container can be created with just a name."""
c = Container(name="chest")
assert c.name == "chest"
assert c.capacity == 10
assert c.closed is False
assert c.locked is False
def test_container_creation_with_custom_capacity():
"""Container can have a custom capacity."""
c = Container(name="pouch", capacity=5)
assert c.capacity == 5
def test_container_creation_closed():
"""Container can be created in closed state."""
c = Container(name="chest", closed=True)
assert c.closed is True
def test_container_creation_locked():
"""Container can be created in locked state."""
c = Container(name="chest", locked=True)
assert c.locked is True
def test_container_is_thing_subclass():
"""Container is a Thing subclass."""
c = Container(name="chest")
assert isinstance(c, Thing)
assert isinstance(c, Object)
def test_container_inherits_thing_properties():
"""Container has all Thing properties."""
c = Container(
name="ornate chest",
description="a beautifully carved wooden chest",
portable=False,
aliases=["chest", "box"],
)
assert c.description == "a beautifully carved wooden chest"
assert c.portable is False
assert c.aliases == ["chest", "box"]
# --- can_accept --- # --- can_accept ---
@ -116,105 +132,3 @@ def test_container_with_contents():
assert sword in chest.contents assert sword in chest.contents
assert gem in chest.contents assert gem in chest.contents
assert len(chest.contents) == 2 assert len(chest.contents) == 2
# --- look command container display ---
@pytest.mark.asyncio
async def test_look_shows_closed_container(player, test_zone, mock_writer):
"""look shows closed containers with (closed) suffix."""
from mudlib.commands.look import cmd_look
Container(name="chest", location=test_zone, x=5, y=5, closed=True)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "chest (closed)" in output
@pytest.mark.asyncio
async def test_look_shows_open_empty_container(player, test_zone, mock_writer):
"""look shows open empty containers with (open, empty) suffix."""
from mudlib.commands.look import cmd_look
Container(name="chest", location=test_zone, x=5, y=5, closed=False)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "chest (open, empty)" in output
@pytest.mark.asyncio
async def test_look_shows_open_container_with_contents(player, test_zone, mock_writer):
"""look shows open containers with their contents."""
from mudlib.commands.look import cmd_look
chest = Container(name="chest", location=test_zone, x=5, y=5, closed=False)
Thing(name="rock", location=chest)
Thing(name="coin", location=chest)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "chest (open, containing: rock, coin)" in output
@pytest.mark.asyncio
async def test_look_shows_regular_things_unchanged(player, test_zone, mock_writer):
"""look shows regular Things without container suffixes."""
from mudlib.commands.look import cmd_look
Thing(name="rock", location=test_zone, x=5, y=5)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "On the ground: rock" in output
assert "(closed)" not in output
assert "(open" not in output
# --- inventory command container display ---
@pytest.mark.asyncio
async def test_inventory_shows_closed_container(player, mock_writer):
"""inventory shows closed containers with (closed) suffix."""
from mudlib.commands.things import cmd_inventory
Container(name="sack", location=player, closed=True)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "sack (closed)" in output
@pytest.mark.asyncio
async def test_inventory_shows_open_empty_container(player, mock_writer):
"""inventory shows open empty containers with (open, empty) suffix."""
from mudlib.commands.things import cmd_inventory
Container(name="sack", location=player, closed=False)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "sack (open, empty)" in output
@pytest.mark.asyncio
async def test_inventory_shows_container_with_contents(player, mock_writer):
"""inventory shows open containers with their contents."""
from mudlib.commands.things import cmd_inventory
sack = Container(name="sack", location=player, closed=False)
Thing(name="rock", location=sack)
Thing(name="gem", location=sack)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "sack (open, containing: rock, gem)" in output
@pytest.mark.asyncio
async def test_inventory_shows_regular_things_unchanged(player, mock_writer):
"""inventory shows regular Things without container suffixes."""
from mudlib.commands.things import cmd_inventory
Thing(name="rock", location=player)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert " rock\r\n" in output
assert "(closed)" not in output
assert "(open" not in output

View file

@ -0,0 +1,150 @@
"""Tests for container state display in look and inventory commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.container import Container
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
@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 test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="testzone",
width=10,
height=10,
toroidal=True,
terrain=terrain,
)
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(
name="TestPlayer",
x=5,
y=5,
reader=mock_reader,
writer=mock_writer,
location=test_zone,
)
return p
# --- look command container display ---
@pytest.mark.asyncio
async def test_look_shows_closed_container(player, test_zone, mock_writer):
"""look shows closed containers with (closed) suffix."""
from mudlib.commands.look import cmd_look
Container(name="chest", location=test_zone, x=5, y=5, closed=True)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "chest (closed)" in output
@pytest.mark.asyncio
async def test_look_shows_open_empty_container(player, test_zone, mock_writer):
"""look shows open empty containers with (open, empty) suffix."""
from mudlib.commands.look import cmd_look
Container(name="chest", location=test_zone, x=5, y=5, closed=False)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "chest (open, empty)" in output
@pytest.mark.asyncio
async def test_look_shows_open_container_with_contents(player, test_zone, mock_writer):
"""look shows open containers with their contents."""
from mudlib.commands.look import cmd_look
chest = Container(name="chest", location=test_zone, x=5, y=5, closed=False)
Thing(name="rock", location=chest)
Thing(name="coin", location=chest)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "chest (open, containing: rock, coin)" in output
@pytest.mark.asyncio
async def test_look_shows_regular_things_unchanged(player, test_zone, mock_writer):
"""look shows regular Things without container suffixes."""
from mudlib.commands.look import cmd_look
Thing(name="rock", location=test_zone, x=5, y=5)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "On the ground: rock" in output
assert "(closed)" not in output
assert "(open" not in output
# --- inventory command container display ---
@pytest.mark.asyncio
async def test_inventory_shows_closed_container(player, mock_writer):
"""inventory shows closed containers with (closed) suffix."""
from mudlib.commands.things import cmd_inventory
Container(name="sack", location=player, closed=True)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "sack (closed)" in output
@pytest.mark.asyncio
async def test_inventory_shows_open_empty_container(player, mock_writer):
"""inventory shows open empty containers with (open, empty) suffix."""
from mudlib.commands.things import cmd_inventory
Container(name="sack", location=player, closed=False)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "sack (open, empty)" in output
@pytest.mark.asyncio
async def test_inventory_shows_container_with_contents(player, mock_writer):
"""inventory shows open containers with their contents."""
from mudlib.commands.things import cmd_inventory
sack = Container(name="sack", location=player, closed=False)
Thing(name="rock", location=sack)
Thing(name="gem", location=sack)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "sack (open, containing: rock, gem)" in output
@pytest.mark.asyncio
async def test_inventory_shows_regular_things_unchanged(player, mock_writer):
"""inventory shows regular Things without container suffixes."""
from mudlib.commands.things import cmd_inventory
Thing(name="rock", location=player)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert " rock\r\n" in output
assert "(closed)" not in output
assert "(open" not in output

View file

@ -3,6 +3,7 @@
import logging import logging
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -10,6 +11,19 @@ from mudlib.content import load_command, load_commands
from mudlib.player import Player from mudlib.player import Player
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer): def player(mock_reader, mock_writer):
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer) return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)

View file

@ -5,6 +5,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from mudlib.container import Container
from mudlib.corpse import Corpse, create_corpse from mudlib.corpse import Corpse, create_corpse
from mudlib.entity import Mob from mudlib.entity import Mob
from mudlib.mobs import mobs from mudlib.mobs import mobs
@ -62,6 +63,36 @@ def potion():
return Thing(name="potion", description="a health potion", portable=True) return Thing(name="potion", description="a health potion", portable=True)
class TestCorpseClass:
def test_corpse_is_container_subclass(self):
"""Corpse is a subclass of Container."""
assert issubclass(Corpse, Container)
def test_corpse_not_portable(self, test_zone):
"""Corpse is not portable (can't pick up a corpse)."""
corpse = Corpse(name="test corpse", location=test_zone, x=0, y=0)
assert corpse.portable is False
def test_corpse_always_open(self, test_zone):
"""Corpse is always open (closed=False)."""
corpse = Corpse(name="test corpse", location=test_zone, x=0, y=0)
assert corpse.closed is False
def test_corpse_has_decompose_at_field(self, test_zone):
"""Corpse has decompose_at field (float, monotonic time)."""
decompose_time = time.monotonic() + 300
corpse = Corpse(
name="test corpse",
location=test_zone,
x=0,
y=0,
decompose_at=decompose_time,
)
assert hasattr(corpse, "decompose_at")
assert isinstance(corpse.decompose_at, float)
assert corpse.decompose_at == decompose_time
class TestCreateCorpseFactory: class TestCreateCorpseFactory:
def test_creates_corpse_at_mob_position(self, goblin_mob, test_zone): def test_creates_corpse_at_mob_position(self, goblin_mob, test_zone):
"""create_corpse creates a corpse at mob's x, y in the zone.""" """create_corpse creates a corpse at mob's x, y in the zone."""
@ -188,7 +219,7 @@ class TestCorpseAsContainer:
class TestCombatDeathCorpse: class TestCombatDeathCorpse:
"""Knockouts do not create corpses until a finisher is used.""" """Tests for corpse spawning when a mob dies in combat."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clear_corpses(self): def clear_corpses(self):
@ -199,8 +230,8 @@ class TestCombatDeathCorpse:
active_corpses.clear() active_corpses.clear()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_knockout_in_combat_does_not_spawn_corpse(self, test_zone): async def test_mob_death_in_combat_spawns_corpse(self, test_zone):
"""KO in combat should not create a corpse by itself.""" """Mob death in combat spawns a corpse at mob's position."""
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import ( from mudlib.combat.engine import (
active_encounters, active_encounters,
@ -242,7 +273,7 @@ class TestCombatDeathCorpse:
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
hit_time_ms=800, timing_window_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -252,17 +283,16 @@ class TestCombatDeathCorpse:
# Process combat to trigger resolve # Process combat to trigger resolve
await process_combat() await process_combat()
# Check no corpse spawned yet # Check for corpse at mob's position
corpses = [ corpses = [
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse) obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
] ]
assert len(corpses) == 0 assert len(corpses) == 1
assert mob in mobs assert corpses[0].name == "goblin's corpse"
assert mob.alive is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_knockout_keeps_inventory_on_mob(self, test_zone, sword): async def test_mob_death_transfers_inventory_to_corpse(self, test_zone, sword):
"""KO should not transfer inventory to a corpse until finished.""" """Mob death transfers inventory to corpse."""
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import ( from mudlib.combat.engine import (
active_encounters, active_encounters,
@ -303,7 +333,7 @@ class TestCombatDeathCorpse:
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
hit_time_ms=800, timing_window_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -313,17 +343,20 @@ class TestCombatDeathCorpse:
# Process combat # Process combat
await process_combat() await process_combat()
# No corpse yet # Find corpse
corpses = [ corpses = [
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse) obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
] ]
assert len(corpses) == 0 assert len(corpses) == 1
assert sword in mob._contents corpse = corpses[0]
assert sword.location is mob
# Verify sword is in corpse
assert sword in corpse._contents
assert sword.location is corpse
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_corpse_in_zone_contents_after_ko(self, test_zone): async def test_corpse_appears_in_zone_contents(self, test_zone):
"""Zone should not contain corpse from a plain KO.""" """Corpse appears in zone.contents_at after mob death."""
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import ( from mudlib.combat.engine import (
active_encounters, active_encounters,
@ -363,7 +396,7 @@ class TestCombatDeathCorpse:
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
hit_time_ms=800, timing_window_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -373,50 +406,14 @@ class TestCombatDeathCorpse:
# Process combat # Process combat
await process_combat() await process_combat()
# Verify no corpse in zone contents # Verify corpse is in zone contents
contents = list(test_zone.contents_at(5, 10)) contents = list(test_zone.contents_at(5, 10))
corpse_count = sum(1 for obj in contents if isinstance(obj, Corpse)) corpse_count = sum(1 for obj in contents if isinstance(obj, Corpse))
assert corpse_count == 0 assert corpse_count == 1
@pytest.mark.asyncio # Verify it's the goblin's corpse
async def test_snapneck_finisher_spawns_corpse(self, test_zone): corpse = next(obj for obj in contents if isinstance(obj, Corpse))
"""Explicit finisher kill should create a corpse.""" assert corpse.name == "goblin's corpse"
from unittest.mock import AsyncMock
from mudlib.commands.snapneck import cmd_snap_neck
from mudlib.player import Player, players
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
attacker = Player(name="hero", x=5, y=10, reader=reader, writer=writer)
attacker.location = test_zone
test_zone._contents.append(attacker)
players[attacker.name] = attacker
mob = Mob(
name="goblin",
x=5,
y=10,
location=test_zone,
pl=0.0,
stamina=0.0,
)
mobs.append(mob)
from mudlib.combat.engine import start_encounter
start_encounter(attacker, mob)
attacker.mode_stack.append("combat")
await cmd_snap_neck(attacker, "goblin")
corpses = [
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
]
assert len(corpses) == 1
assert corpses[0].name == "goblin's corpse"
players.clear()
class TestCorpseDisplay: class TestCorpseDisplay:

View file

@ -1,6 +1,6 @@
"""Tests for editor integration with the shell and command system.""" """Tests for editor integration with the shell and command system."""
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -10,6 +10,19 @@ from mudlib.editor import Editor
from mudlib.player import Player from mudlib.player import Player
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer): def player(mock_reader, mock_writer):
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer) return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
@ -34,6 +47,7 @@ async def test_edit_command_sends_welcome_message(player, mock_writer):
"""Test that edit command sends welcome message.""" """Test that edit command sends welcome message."""
await cmd_edit(player, "") await cmd_edit(player, "")
assert mock_writer.write.called
output = "".join([call[0][0] for call in mock_writer.write.call_args_list]) output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "editor" in output.lower() assert "editor" in output.lower()
assert ":h" in output assert ":h" in output
@ -91,6 +105,7 @@ async def test_editor_save_callback_sends_message(player, mock_writer):
assert response.saved is True assert response.saved is True
# Save callback should have sent a message # Save callback should have sent a message
assert mock_writer.write.called
output = "".join([call[0][0] for call in mock_writer.write.call_args_list]) output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "saved" in output.lower() assert "saved" in output.lower()
@ -172,7 +187,7 @@ async def test_edit_combat_move_opens_toml(player, tmp_path):
aliases = ["rh"] aliases = ["rh"]
move_type = "attack" move_type = "attack"
stamina_cost = 8.0 stamina_cost = 8.0
hit_time_ms = 2000 timing_window_ms = 2000
""" """
toml_file = tmp_path / "roundhouse.toml" toml_file = tmp_path / "roundhouse.toml"
toml_file.write_text(toml_content) toml_file.write_text(toml_content)
@ -222,6 +237,7 @@ move_type = "attack"
# Check that file was written # Check that file was written
saved_content = toml_file.read_text() saved_content = toml_file.read_text()
assert "stamina_cost = 9.0" in saved_content assert "stamina_cost = 9.0" in saved_content
assert mock_writer.write.called
@pytest.mark.asyncio @pytest.mark.asyncio
@ -264,6 +280,7 @@ async def test_edit_unknown_content_shows_error(player, mock_writer, tmp_path):
assert player.editor is None assert player.editor is None
assert player.mode == "normal" assert player.mode == "normal"
assert mock_writer.write.called
output = "".join([call[0][0] for call in mock_writer.write.call_args_list]) output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "unknown" in output.lower() assert "unknown" in output.lower()
assert "nonexistent" in output.lower() assert "nonexistent" in output.lower()

View file

@ -111,7 +111,7 @@ def test_mud_filesystem_save_restore(tmp_path):
test_data = b"\x01\x02\x03\x04\x05" test_data = b"\x01\x02\x03\x04\x05"
success = filesystem.save_game(test_data) success = filesystem.save_game(test_data)
assert success is True assert success
assert save_path.exists() assert save_path.exists()
restored = filesystem.restore_game() restored = filesystem.restore_game()
@ -163,6 +163,7 @@ async def test_embedded_session_handle_input():
response = await session.handle_input("look") response = await session.handle_input("look")
assert response is not None
assert response.done is False assert response.done is False
assert len(response.output) > 0 assert len(response.output) > 0
# Looking should describe the starting location # Looking should describe the starting location

View file

@ -11,6 +11,19 @@ from mudlib.zone import Zone
from mudlib.zones import register_zone, zone_registry from mudlib.zones import register_zone, zone_registry
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def test_zone(): def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)] terrain = [["." for _ in range(10)] for _ in range(10)]

87
tests/test_entity.py Normal file
View file

@ -0,0 +1,87 @@
"""Tests for entity combat stats."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.entity import Entity, Mob
from mudlib.player import Player
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
def test_entity_has_combat_stats():
"""Test that Entity has PL and stamina stats."""
entity = Entity(name="Test", x=0, y=0)
assert entity.pl == 100.0
assert entity.stamina == 100.0
assert entity.max_stamina == 100.0
assert entity.defense_locked_until == 0.0
def test_entity_combat_stats_can_be_customized():
"""Test that combat stats can be set on initialization."""
entity = Entity(name="Weak", x=0, y=0, pl=50.0, stamina=30.0, max_stamina=30.0)
assert entity.pl == 50.0
assert entity.stamina == 30.0
assert entity.max_stamina == 30.0
def test_mob_inherits_combat_stats():
"""Test that Mob inherits combat stats from Entity."""
mob = Mob(name="Goku", x=10, y=10, description="A powerful fighter")
assert mob.pl == 100.0
assert mob.stamina == 100.0
assert mob.max_stamina == 100.0
def test_mob_combat_stats_can_be_customized():
"""Test that Mob can have custom combat stats."""
mob = Mob(
name="Boss",
x=5,
y=5,
description="Strong",
pl=200.0,
max_stamina=150.0,
stamina=150.0,
)
assert mob.pl == 200.0
assert mob.stamina == 150.0
assert mob.max_stamina == 150.0
def test_player_inherits_combat_stats(mock_reader, mock_writer):
"""Test that Player inherits combat stats from Entity."""
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
assert player.pl == 100.0
assert player.stamina == 100.0
assert player.max_stamina == 100.0
def test_player_combat_stats_can_be_customized(mock_reader, mock_writer):
"""Test that Player can have custom combat stats."""
player = Player(
name="Veteran",
x=0,
y=0,
reader=mock_reader,
writer=mock_writer,
pl=150.0,
stamina=120.0,
max_stamina=120.0,
)
assert player.pl == 150.0
assert player.stamina == 120.0
assert player.max_stamina == 120.0

View file

@ -1,9 +1,26 @@
"""Tests for Entity.posture property.""" """Tests for Entity.posture property."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.entity import Entity, Mob from mudlib.entity import Entity, Mob
from mudlib.player import Player from mudlib.player import Player
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
def test_entity_default_posture(): def test_entity_default_posture():
"""Entity with no special state should be 'standing'.""" """Entity with no special state should be 'standing'."""
entity = Entity(name="Test", x=0, y=0) entity = Entity(name="Test", x=0, y=0)

View file

@ -1,3 +1,5 @@
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib.commands.examine import cmd_examine from mudlib.commands.examine import cmd_examine
@ -8,6 +10,19 @@ from mudlib.thing import Thing
from mudlib.zone import Zone from mudlib.zone import Zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def test_zone(): def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)] terrain = [["." for _ in range(10)] for _ in range(10)]

View file

@ -11,6 +11,19 @@ from mudlib.player import Player, players
from mudlib.zone import Zone from mudlib.zone import Zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def test_zone(): def test_zone():
terrain = [["." for _ in range(100)] for _ in range(100)] terrain = [["." for _ in range(100)] for _ in range(100)]

View file

@ -1,5 +1,7 @@
"""Tests for get and drop commands.""" """Tests for get and drop commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib.commands import _registry from mudlib.commands import _registry
@ -9,6 +11,19 @@ from mudlib.thing import Thing
from mudlib.zone import Zone from mudlib.zone import Zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def test_zone(): def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)] terrain = [["." for _ in range(10)] for _ in range(10)]

View file

@ -370,7 +370,7 @@ async def test_char_vitals_sent_on_combat_resolve():
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
hit_time_ms=800, timing_window_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -413,9 +413,11 @@ async def test_char_vitals_sent_on_combat_resolve():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_char_status_sent_on_combat_end(): async def test_char_status_sent_on_combat_end():
"""Test Char.Status is sent when combat ends (timeout).""" """Test Char.Status is sent when combat ends (victory/defeat)."""
import time
from mudlib.combat.engine import active_encounters, process_combat, start_encounter from mudlib.combat.engine import active_encounters, process_combat, start_encounter
from mudlib.combat.moves import CombatMove
# Clear encounters # Clear encounters
active_encounters.clear() active_encounters.clear()
@ -441,9 +443,22 @@ async def test_char_status_sent_on_combat_end():
attacker.mode_stack.append("combat") attacker.mode_stack.append("combat")
defender.mode_stack.append("combat") defender.mode_stack.append("combat")
# Create encounter and force timeout end. # Create encounter and attack (will kill defender)
encounter = start_encounter(attacker, defender) encounter = start_encounter(attacker, defender)
encounter.last_action_at -= 31.0 punch = CombatMove(
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
damage_pct=0.15,
countered_by=["dodge left"],
)
encounter.attack(punch)
# Advance past telegraph and window to trigger resolution
time.sleep(0.31)
await process_combat()
time.sleep(0.85)
# Reset mocks before the resolution call # Reset mocks before the resolution call
mock_writer_1.send_gmcp.reset_mock() mock_writer_1.send_gmcp.reset_mock()

137
tests/test_help_command.py Normal file
View file

@ -0,0 +1,137 @@
"""Tests for the standalone help command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib import commands
# Import command modules to register their commands
from mudlib.commands import (
help, # noqa: F401
look, # noqa: F401
movement, # noqa: F401
)
from mudlib.commands.help import _help_topics
from mudlib.content import load_help_topics
@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):
from mudlib.player import Player
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
@pytest.fixture
def admin_player(mock_reader, mock_writer):
from mudlib.player import Player
p = Player(name="AdminPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
p.is_admin = True
return p
@pytest.fixture(autouse=True)
def _load_zones_topic():
"""Load the zones help topic for tests that need it."""
from pathlib import Path
help_dir = Path(__file__).resolve().parents[1] / "content" / "help"
if help_dir.exists():
loaded = load_help_topics(help_dir)
_help_topics.update(loaded)
yield
_help_topics.clear()
@pytest.mark.asyncio
async def test_help_command_is_registered():
"""The help command should be registered in the command registry."""
assert "help" in commands._registry
@pytest.mark.asyncio
async def test_help_has_wildcard_mode():
"""Help should work from any mode."""
cmd_def = commands._registry["help"]
assert cmd_def.mode == "*"
@pytest.mark.asyncio
async def test_help_no_args_shows_usage(player):
"""help with no args shows usage hint."""
await commands.dispatch(player, "help")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "help <command>" in output
assert "commands" in output
assert "skills" in output
@pytest.mark.asyncio
async def test_help_known_command_shows_detail(player):
"""help <known command> shows detail view."""
await commands.dispatch(player, "help look")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "look" in output.lower()
assert "mode:" in output.lower()
@pytest.mark.asyncio
async def test_help_unknown_command_shows_error(player):
"""help <unknown> shows error message."""
await commands.dispatch(player, "help nonexistent")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "nonexistent" in output.lower()
assert "unknown" in output.lower() or "not found" in output.lower()
@pytest.mark.asyncio
async def test_help_and_commands_both_exist():
"""Both help and commands should be registered independently."""
assert "help" in commands._registry
assert "commands" in commands._registry
# They should be different functions
assert commands._registry["help"].handler != commands._registry["commands"].handler
@pytest.mark.asyncio
async def test_help_zones_shows_guide(admin_player):
"""help zones shows zone guide text with command references."""
await commands.dispatch(admin_player, "help zones")
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
assert "zones" in output
assert "@zones" in output
assert "@goto" in output
assert "@dig" in output
assert "@paint" in output
assert "@save" in output
@pytest.mark.asyncio
async def test_help_zones_shows_see_also(admin_player):
"""help zones output contains see also cross-references."""
await commands.dispatch(admin_player, "help zones")
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
assert "see:" in output
@pytest.mark.asyncio
async def test_help_zones_requires_admin(player):
"""Non-admin players cannot see admin help topics."""
await commands.dispatch(player, "help zones")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "unknown" in output.lower()

View file

@ -1,17 +1,13 @@
"""Tests for TOML help topic loading.""" """Tests for TOML help topic loading."""
import textwrap import textwrap
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib import commands from mudlib import commands
from mudlib.commands import help as help_mod # noqa: F401 from mudlib.commands import help as help_mod # noqa: F401
from mudlib.commands import ( from mudlib.commands import helpadmin # noqa: F401
helpadmin, # noqa: F401
look, # noqa: F401
movement, # noqa: F401
)
from mudlib.commands.help import _help_topics from mudlib.commands.help import _help_topics
from mudlib.content import HelpTopic, load_help_topics from mudlib.content import HelpTopic, load_help_topics
@ -89,31 +85,32 @@ def test_load_help_topics_skips_bad_files(tmp_path):
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer): def mock_writer():
from mudlib.player import Player writer = MagicMock()
writer.write = MagicMock()
return Player(name="Tester", x=0, y=0, reader=mock_reader, writer=mock_writer) writer.drain = AsyncMock()
return writer
@pytest.fixture @pytest.fixture
def admin_player(mock_reader, mock_writer): def player(mock_writer):
from mudlib.player import Player from mudlib.player import Player
p = Player(name="Admin", x=0, y=0, reader=mock_reader, writer=mock_writer) return Player(name="Tester", x=0, y=0, reader=MagicMock(), writer=mock_writer)
@pytest.fixture
def admin_player(mock_writer):
from mudlib.player import Player
p = Player(name="Admin", x=0, y=0, reader=MagicMock(), writer=mock_writer)
p.is_admin = True p.is_admin = True
return p return p
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _clear_and_load_topics(): def _clear_topics():
"""Clear help topics, load content topics, then clear again after tests."""
from pathlib import Path
_help_topics.clear() _help_topics.clear()
help_dir = Path(__file__).resolve().parents[1] / "content" / "help"
if help_dir.exists():
loaded = load_help_topics(help_dir)
_help_topics.update(loaded)
yield yield
_help_topics.clear() _help_topics.clear()
@ -264,82 +261,3 @@ def test_at_help_toml_loads_from_content():
topics = load_help_topics(help_dir) topics = load_help_topics(help_dir)
assert "@help" in topics assert "@help" in topics
assert topics["@help"].admin is True assert topics["@help"].admin is True
@pytest.mark.asyncio
async def test_help_command_is_registered():
"""The help command should be registered in the command registry."""
assert "help" in commands._registry
@pytest.mark.asyncio
async def test_help_has_wildcard_mode():
"""Help should work from any mode."""
cmd_def = commands._registry["help"]
assert cmd_def.mode == "*"
@pytest.mark.asyncio
async def test_help_no_args_shows_usage(player):
"""help with no args shows usage hint."""
await commands.dispatch(player, "help")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "help <command>" in output
assert "commands" in output
assert "skills" in output
@pytest.mark.asyncio
async def test_help_known_command_shows_detail(player):
"""help <known command> shows detail view."""
await commands.dispatch(player, "help look")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "look" in output.lower()
assert "mode:" in output.lower()
@pytest.mark.asyncio
async def test_help_unknown_command_shows_error(player):
"""help <unknown> shows error message."""
await commands.dispatch(player, "help nonexistent")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "nonexistent" in output.lower()
assert "unknown" in output.lower() or "not found" in output.lower()
@pytest.mark.asyncio
async def test_help_and_commands_both_exist():
"""Both help and commands should be registered independently."""
assert "help" in commands._registry
assert "commands" in commands._registry
# They should be different functions
assert commands._registry["help"].handler != commands._registry["commands"].handler
@pytest.mark.asyncio
async def test_help_zones_shows_guide(admin_player):
"""help zones shows zone guide text with command references."""
await commands.dispatch(admin_player, "help zones")
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
assert "zones" in output
assert "@zones" in output
assert "@goto" in output
assert "@dig" in output
assert "@paint" in output
assert "@save" in output
@pytest.mark.asyncio
async def test_help_zones_shows_see_also(admin_player):
"""help zones output contains see also cross-references."""
await commands.dispatch(admin_player, "help zones")
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
assert "see:" in output
@pytest.mark.asyncio
async def test_help_zones_requires_admin(player):
"""Non-admin players cannot see admin help topics."""
await commands.dispatch(player, "help zones")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "unknown" in output.lower()

View file

@ -1,6 +1,6 @@
"""Tests for help command showing unlock status for combat moves.""" """Tests for help command showing unlock status for combat moves."""
from unittest.mock import patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -17,6 +17,14 @@ def clear_state():
players.clear() players.clear()
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture @pytest.fixture
def player(mock_writer): def player(mock_writer):
return Player(name="Test", writer=mock_writer) return Player(name="Test", writer=mock_writer)
@ -29,7 +37,7 @@ def mock_move_kill_count():
name="roundhouse", name="roundhouse",
move_type="attack", move_type="attack",
stamina_cost=30.0, stamina_cost=30.0,
hit_time_ms=850, timing_window_ms=850,
aliases=["rh"], aliases=["rh"],
description="A powerful spinning kick", description="A powerful spinning kick",
damage_pct=0.35, damage_pct=0.35,
@ -44,7 +52,7 @@ def mock_move_mob_kills():
name="goblin slayer", name="goblin slayer",
move_type="attack", move_type="attack",
stamina_cost=25.0, stamina_cost=25.0,
hit_time_ms=800, timing_window_ms=800,
description="Specialized technique against goblins", description="Specialized technique against goblins",
damage_pct=0.40, damage_pct=0.40,
unlock_condition=UnlockCondition( unlock_condition=UnlockCondition(
@ -60,7 +68,7 @@ def mock_move_no_unlock():
name="jab", name="jab",
move_type="attack", move_type="attack",
stamina_cost=10.0, stamina_cost=10.0,
hit_time_ms=600, timing_window_ms=600,
description="A quick straight punch", description="A quick straight punch",
damage_pct=0.15, damage_pct=0.15,
) )

View file

@ -8,6 +8,19 @@ from mudlib.if_session import IFResponse, IFSession
from mudlib.player import Player from mudlib.player import Player
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer): def player(mock_reader, mock_writer):
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer) return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)

View file

@ -4,7 +4,33 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from mudlib.if_session import IFSession from mudlib.if_session import IFResponse, IFSession
@pytest.mark.asyncio
async def test_if_response_dataclass():
"""IFResponse dataclass can be created."""
response = IFResponse(output="test output", done=False)
assert response.output == "test output"
assert response.done is False
@pytest.mark.asyncio
async def test_if_response_done():
"""IFResponse can signal completion."""
response = IFResponse(output="", done=True)
assert response.done is True
@pytest.mark.asyncio
async def test_if_session_init():
"""IFSession can be initialized."""
player = MagicMock()
session = IFSession(player, "/path/to/story.z5", "story")
assert session.player == player
assert session.story_path == "/path/to/story.z5"
assert session.game_name == "story"
assert session.process is None
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -9,6 +9,19 @@ from mudlib.player import Player, players
from mudlib.zone import Zone from mudlib.zone import Zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clear_players(): def clear_players():
"""Clear players registry before and after each test.""" """Clear players registry before and after each test."""

5
tests/test_import.py Normal file
View file

@ -0,0 +1,5 @@
from mudlib import __version__
def test_version():
assert __version__ == "0.1.0"

View file

@ -1,5 +1,7 @@
"""Tests for inventory command.""" """Tests for inventory command."""
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib import commands from mudlib import commands
@ -21,6 +23,18 @@ def test_zone():
) )
@pytest.fixture
def mock_writer():
"""Create a mock writer."""
return MagicMock(write=MagicMock(), drain=AsyncMock())
@pytest.fixture
def mock_reader():
"""Create a mock reader."""
return MagicMock()
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer, test_zone):
"""Create a test player.""" """Create a test player."""

View file

@ -1,3 +1,5 @@
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib.container import Container from mudlib.container import Container
@ -6,6 +8,19 @@ from mudlib.thing import Thing
from mudlib.zone import Zone from mudlib.zone import Zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def test_zone(): def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)] terrain = [["." for _ in range(10)] for _ in range(10)]

View file

@ -4,25 +4,54 @@ import time
import pytest import pytest
from mudlib.combat.engine import start_encounter from mudlib.combat.engine import (
from mudlib.commands.snapneck import cmd_snap_neck process_combat,
start_encounter,
)
from mudlib.combat.moves import CombatMove
from mudlib.entity import Mob from mudlib.entity import Mob
from mudlib.player import accumulate_play_time from mudlib.player import accumulate_play_time
@pytest.fixture
def punch_move():
"""Create a basic punch move for testing."""
return CombatMove(
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
damage_pct=0.15,
countered_by=[],
resolve_hit="{attacker} hits {defender}!",
resolve_miss="{defender} dodges!",
announce="{attacker} punches!",
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_finishes_mob_increments_stats(player, test_zone): async def test_player_kills_mob_increments_stats(player, test_zone, punch_move):
"""Snap-neck kill increments kills and mob_kills.""" """Player kills mob -> kills incremented, mob_kills tracked."""
# Create a goblin mob # Create a goblin mob
goblin = Mob(name="goblin", x=0, y=0) goblin = Mob(name="goblin", x=0, y=0)
goblin.location = test_zone goblin.location = test_zone
test_zone._contents.append(goblin) test_zone._contents.append(goblin)
# Start encounter and make target unconscious # Start encounter
start_encounter(player, goblin) encounter = start_encounter(player, goblin)
player.mode_stack.append("combat")
goblin.pl = 0.0 # Execute attack
await cmd_snap_neck(player, "goblin") encounter.attack(punch_move)
# Advance past telegraph (0.3s) + window (0.8s)
encounter.tick(time.monotonic() + 0.31) # -> WINDOW
encounter.tick(time.monotonic() + 1.2) # -> RESOLVE
# Set defender to very low pl so damage kills them
goblin.pl = 1.0
# Process combat (this will resolve and end encounter)
await process_combat()
# Verify stats # Verify stats
assert player.kills == 1 assert player.kills == 1
@ -30,32 +59,50 @@ async def test_player_finishes_mob_increments_stats(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_finished_by_mob_increments_deaths(player, nearby_player): async def test_player_killed_by_mob_increments_deaths(player, test_zone, punch_move):
"""Snap-neck finisher from opponent increments deaths.""" """Player killed by mob -> deaths incremented."""
start_encounter(nearby_player, player) # Create a goblin mob
nearby_player.mode_stack.append("combat") goblin = Mob(name="goblin", x=0, y=0)
player.mode_stack.append("combat") goblin.location = test_zone
player.pl = 0.0 test_zone._contents.append(goblin)
await cmd_snap_neck(nearby_player, "Goku")
# Start encounter with mob as attacker
encounter = start_encounter(goblin, player)
# Execute attack
encounter.attack(punch_move)
# Advance to RESOLVE
encounter.tick(time.monotonic() + 0.31)
encounter.tick(time.monotonic() + 1.2)
# Set player to low pl so they die
player.pl = 1.0
# Process combat
await process_combat()
# Verify deaths incremented # Verify deaths incremented
assert player.deaths == 1 assert player.deaths == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_multiple_finisher_kills_accumulate(player, test_zone): async def test_multiple_kills_accumulate(player, test_zone, punch_move):
"""After 3 finishers, kill counters accumulate correctly.""" """After killing 3 goblins, player.kills == 3, player.mob_kills["goblin"] == 3."""
for _ in range(3): for _ in range(3):
# Create goblin # Create goblin
goblin = Mob(name="goblin", x=0, y=0) goblin = Mob(name="goblin", x=0, y=0)
goblin.location = test_zone goblin.location = test_zone
test_zone._contents.append(goblin) test_zone._contents.append(goblin)
# Create encounter and finish # Create and resolve encounter
start_encounter(player, goblin) encounter = start_encounter(player, goblin)
player.mode_stack.append("combat") encounter.attack(punch_move)
goblin.pl = 0.0 encounter.tick(time.monotonic() + 0.31)
await cmd_snap_neck(player, "goblin") encounter.tick(time.monotonic() + 1.2)
goblin.pl = 1.0
await process_combat()
# Verify accumulated kills # Verify accumulated kills
assert player.kills == 3 assert player.kills == 3

View file

@ -26,6 +26,19 @@ def _reset_globals():
mudlib.weather._current_weather = None mudlib.weather._current_weather = None
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def test_zone(): def test_zone():
"""Create a test zone with simple terrain.""" """Create a test zone with simple terrain."""
@ -85,7 +98,7 @@ async def test_look_includes_exits_line(player):
"""Look output should include 'Exits:' line.""" """Look output should include 'Exits:' line."""
await cmd_look(player, "") await cmd_look(player, "")
output = get_output(player) output = get_output(player)
assert "Exits: north south east west up" in output assert "Exits: north south east west" in output
@pytest.mark.asyncio @pytest.mark.asyncio
@ -156,36 +169,14 @@ async def test_look_shows_portals(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_look_with_args_routes_to_examine(player, test_zone): async def test_look_with_args_routes_to_examine(player, test_zone):
"""look <thing> should route to examine command logic.""" """look <thing> should route to examine command logic."""
called = {} # Add an item to examine (location param auto-adds to zone)
Thing(name="sword", x=25, y=25, description="A sharp blade.", location=test_zone)
async def fake_examine(p, args, *, prefer_inventory=True):
called["args"] = args
called["prefer_inventory"] = prefer_inventory
await p.send("examined\r\n")
import mudlib.commands.examine
original = mudlib.commands.examine.examine_target
mudlib.commands.examine.examine_target = fake_examine # type: ignore[invalid-assignment]
try:
await cmd_look(player, "sword")
finally:
mudlib.commands.examine.examine_target = original
await cmd_look(player, "sword")
output = get_output(player) output = get_output(player)
assert called["args"] == "sword"
assert called["prefer_inventory"] is False
assert "examined" in output
# Should see the item's description (examine behavior)
@pytest.mark.asyncio assert "A sharp blade." in output
async def test_look_flying_shows_down_exit(player):
"""Flying players should see down as a vertical exit."""
player.flying = True
await cmd_look(player, "")
output = get_output(player)
assert "Exits: north south east west down" in output
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -2,6 +2,7 @@
import time import time
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -50,6 +51,19 @@ def test_zone():
return zone return zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer, test_zone):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
@ -118,7 +132,7 @@ class TestMobAttackAI:
await process_mobs(moves) await process_mobs(moves)
# Mob should have attacked — encounter state should be TELEGRAPH # Mob should have attacked — encounter state should be TELEGRAPH
assert encounter.state == CombatState.PENDING assert encounter.state == CombatState.TELEGRAPH
assert encounter.current_move is not None assert encounter.current_move is not None
@pytest.mark.asyncio @pytest.mark.asyncio
@ -272,7 +286,7 @@ class TestMobDefenseAI:
# Player attacks, putting encounter in TELEGRAPH # Player attacks, putting encounter in TELEGRAPH
encounter.attack(punch_right) encounter.attack(punch_right)
assert encounter.state == CombatState.PENDING assert encounter.state == CombatState.TELEGRAPH
await process_mobs(moves) await process_mobs(moves)

View file

@ -1,6 +1,7 @@
"""Tests for mob AI integration with behavior states.""" """Tests for mob AI integration with behavior states."""
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -42,6 +43,19 @@ def test_zone():
return zone return zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer, test_zone):
p = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer) p = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)

View file

@ -1,7 +1,7 @@
"""Tests for mob templates, registry, spawn/despawn, and combat integration.""" """Tests for mob templates, registry, spawn/despawn, and combat integration."""
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -182,6 +182,19 @@ class TestGetNearbyMob:
# --- Phase 2: target resolution tests --- # --- 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 @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer, test_zone):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
@ -375,8 +388,8 @@ class TestMobDefeat:
return spawn_mob(template, 0, 0, test_zone) return spawn_mob(template, 0, 0, test_zone)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_not_despawned_on_pl_zero(self, player, goblin_mob, punch_right): async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
"""KO does not despawn mob without an explicit finisher.""" """Mob with PL <= 0 gets despawned after combat resolves."""
from mudlib.combat.engine import process_combat, start_encounter from mudlib.combat.engine import process_combat, start_encounter
encounter = start_encounter(player, goblin_mob) encounter = start_encounter(player, goblin_mob)
@ -391,14 +404,12 @@ class TestMobDefeat:
await process_combat() await process_combat()
assert goblin_mob in mobs assert goblin_mob not in mobs
assert goblin_mob.alive is True assert goblin_mob.alive is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_gets_no_victory_message_on_ko( async def test_player_gets_victory_message(self, player, goblin_mob, punch_right):
self, player, goblin_mob, punch_right """Player receives a victory message when mob is defeated."""
):
"""KO should not be treated as a defeat/kill message."""
from mudlib.combat.engine import process_combat, start_encounter from mudlib.combat.engine import process_combat, start_encounter
encounter = start_encounter(player, goblin_mob) encounter = start_encounter(player, goblin_mob)
@ -411,30 +422,29 @@ class TestMobDefeat:
await process_combat() await process_combat()
messages = [call[0][0] for call in player.writer.write.call_args_list] messages = [call[0][0] for call in player.writer.write.call_args_list]
assert not any("defeated" in msg.lower() for msg in messages) assert any("defeated" in msg.lower() for msg in messages)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_exhaustion_does_not_end_encounter( async def test_mob_stamina_depleted_despawns(self, player, goblin_mob, punch_right):
self, player, goblin_mob, punch_right """Mob is despawned when attacker stamina depleted (combat end)."""
):
"""Attacker exhaustion does not auto-end combat."""
from mudlib.combat.engine import process_combat, start_encounter from mudlib.combat.engine import process_combat, start_encounter
encounter = start_encounter(player, goblin_mob) encounter = start_encounter(player, goblin_mob)
player.mode_stack.append("combat") player.mode_stack.append("combat")
# Drain player stamina before resolve # Drain player stamina so combat ends on exhaustion
player.stamina = 0.0 player.stamina = 0.0
encounter.attack(punch_right) encounter.attack(punch_right)
encounter.state = CombatState.RESOLVE encounter.state = CombatState.RESOLVE
await process_combat() await process_combat()
assert get_encounter(player) is encounter # Encounter should have ended
assert get_encounter(player) is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_ko_not_despawned(self, player, goblin_mob, punch_right): async def test_player_defeat_not_despawned(self, player, goblin_mob, punch_right):
"""When player is KO'd, player remains present.""" """When player loses, player is not despawned."""
from mudlib.combat.engine import process_combat, start_encounter from mudlib.combat.engine import process_combat, start_encounter
# Mob attacks player — mob is attacker, player is defender # Mob attacks player — mob is attacker, player is defender
@ -447,8 +457,10 @@ class TestMobDefeat:
await process_combat() await process_combat()
# Player should get defeat message, not be despawned
messages = [call[0][0] for call in player.writer.write.call_args_list] messages = [call[0][0] for call in player.writer.write.call_args_list]
assert len(messages) > 0 assert any(
assert not any("defeated" in msg.lower() for msg in messages) "defeated" in msg.lower() or "damage" in msg.lower() for msg in messages
)
# Player is still in players dict (not removed) # Player is still in players dict (not removed)
assert player.name in players assert player.name in players

View file

@ -1,5 +1,7 @@
"""End-to-end integration tests for NPC system (behavior + dialogue + schedule).""" """End-to-end integration tests for NPC system (behavior + dialogue + schedule)."""
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib.commands.talk import cmd_reply, cmd_talk, dialogue_trees from mudlib.commands.talk import cmd_reply, cmd_talk, dialogue_trees
@ -42,6 +44,19 @@ def test_zone():
return zone return zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer, test_zone):
p = Player(name="Hero", x=10, y=10, reader=mock_reader, writer=mock_writer) p = Player(name="Hero", x=10, y=10, reader=mock_reader, writer=mock_writer)

View file

@ -1,5 +1,7 @@
"""Tests for open and close commands.""" """Tests for open and close commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib.commands import _registry from mudlib.commands import _registry
@ -9,6 +11,19 @@ from mudlib.thing import Thing
from mudlib.zone import Zone from mudlib.zone import Zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def test_zone(): def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)] terrain = [["." for _ in range(10)] for _ in range(10)]

View file

@ -2,6 +2,7 @@
import os import os
import tempfile import tempfile
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -24,6 +25,21 @@ def temp_db():
os.unlink(db_path) os.unlink(db_path)
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
writer.close = MagicMock()
writer.is_closing = MagicMock(return_value=False)
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_quit_saves_player_state(temp_db, mock_reader, mock_writer): async def test_quit_saves_player_state(temp_db, mock_reader, mock_writer):
"""Quit command saves player state before disconnecting.""" """Quit command saves player state before disconnecting."""

View file

@ -1,12 +1,20 @@
"""Tests for the play command.""" """Tests for the play command."""
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from mudlib.zone import Zone from mudlib.zone import Zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture @pytest.fixture
def test_zone(): def test_zone():
"""Create a test zone for spatial queries.""" """Create a test zone for spatial queries."""

View file

@ -1,8 +1,25 @@
"""Tests for player description and home_zone fields.""" """Tests for player description and home_zone fields."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.player import Player from mudlib.player import Player
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
def test_player_default_description(mock_reader, mock_writer): def test_player_default_description(mock_reader, mock_writer):
"""Test that Player has default empty description.""" """Test that Player has default empty description."""
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer) player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)

View file

@ -1,77 +1,49 @@
"""Tests for the Portal class.""" """Tests for the Portal class."""
import pytest
from mudlib.object import Object from mudlib.object import Object
from mudlib.player import Player
from mudlib.portal import Portal from mudlib.portal import Portal
from mudlib.thing import Thing from mudlib.thing import Thing
from mudlib.zone import Zone from mudlib.zone import Zone
from mudlib.zones import register_zone, zone_registry
# --- fixtures ---
@pytest.fixture
def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="testzone",
width=10,
height=10,
toroidal=True,
terrain=terrain,
)
@pytest.fixture
def zone_a():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="zone_a",
width=10,
height=10,
toroidal=True,
terrain=terrain,
)
@pytest.fixture
def zone_b():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="zone_b",
width=10,
height=10,
toroidal=True,
terrain=terrain,
)
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(
name="TestPlayer",
x=5,
y=5,
reader=mock_reader,
writer=mock_writer,
location=test_zone,
)
return p
@pytest.fixture(autouse=True)
def clear_zones():
"""Clear zone registry before and after each test."""
zone_registry.clear()
yield
zone_registry.clear()
# --- construction --- # --- construction ---
def test_portal_creation_minimal():
"""Portal can be created with just a name."""
p = Portal(name="portal")
assert p.name == "portal"
assert p.location is None
assert p.target_zone == ""
assert p.target_x == 0
assert p.target_y == 0
def test_portal_creation_with_target():
"""Portal can be created with target zone and coordinates."""
p = Portal(name="gateway", target_zone="dungeon", target_x=5, target_y=10)
assert p.target_zone == "dungeon"
assert p.target_x == 5
assert p.target_y == 10
def test_portal_is_thing_subclass():
"""Portal inherits from Thing."""
p = Portal(name="portal")
assert isinstance(p, Thing)
def test_portal_is_object_subclass():
"""Portal inherits from Object (via Thing)."""
p = Portal(name="portal")
assert isinstance(p, Object)
def test_portal_always_non_portable():
"""Portal is always non-portable (cannot be picked up)."""
p = Portal(name="portal")
assert p.portable is False
def test_portal_forced_non_portable(): def test_portal_forced_non_portable():
"""Portal forces portable=False even if explicitly set True.""" """Portal forces portable=False even if explicitly set True."""
# Even if we try to make it portable, it should be forced to False # Even if we try to make it portable, it should be forced to False
@ -79,6 +51,18 @@ def test_portal_forced_non_portable():
assert p.portable is False assert p.portable is False
def test_portal_inherits_description():
"""Portal can have a description (from Thing)."""
p = Portal(name="gateway", description="a shimmering portal")
assert p.description == "a shimmering portal"
def test_portal_inherits_aliases():
"""Portal can have aliases (from Thing)."""
p = Portal(name="gateway", aliases=["portal", "gate"])
assert p.aliases == ["portal", "gate"]
def test_portal_in_zone(): def test_portal_in_zone():
"""Portal can exist in a zone with coordinates.""" """Portal can exist in a zone with coordinates."""
terrain = [["." for _ in range(10)] for _ in range(10)] terrain = [["." for _ in range(10)] for _ in range(10)]
@ -110,140 +94,3 @@ def test_portal_rejects_things():
p = Portal(name="portal") p = Portal(name="portal")
thing = Thing(name="sword") thing = Thing(name="sword")
assert p.can_accept(thing) is False assert p.can_accept(thing) is False
# --- portal display ---
@pytest.mark.asyncio
async def test_look_shows_portal_at_position(player, test_zone, mock_writer):
"""look command shows portals at player position."""
from mudlib.commands.look import cmd_look
Portal(
name="shimmering doorway",
location=test_zone,
x=5,
y=5,
target_zone="elsewhere",
target_x=0,
target_y=0,
)
await cmd_look(player, "")
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
# New format: "You see {portal.name}."
assert "you see shimmering doorway." in output.lower()
@pytest.mark.asyncio
async def test_look_shows_multiple_portals(player, test_zone, mock_writer):
"""look command shows multiple portals at player position."""
from mudlib.commands.look import cmd_look
Portal(
name="red portal",
location=test_zone,
x=5,
y=5,
target_zone="redzone",
target_x=0,
target_y=0,
)
Portal(
name="blue portal",
location=test_zone,
x=5,
y=5,
target_zone="bluezone",
target_x=0,
target_y=0,
)
await cmd_look(player, "")
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "red portal" in output.lower()
assert "blue portal" in output.lower()
@pytest.mark.asyncio
async def test_look_no_portals_at_position(player, test_zone, mock_writer):
"""look command doesn't show portals when none at position."""
from mudlib.commands.look import cmd_look
Portal(
name="distant portal",
location=test_zone,
x=8,
y=8,
target_zone="elsewhere",
target_x=0,
target_y=0,
)
await cmd_look(player, "")
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
# Should not mention portals when none are at player position
assert "portal" not in output.lower() or "distant portal" not in output.lower()
# --- two-way portal transitions ---
@pytest.mark.asyncio
async def test_two_way_portal_transitions(mock_reader, mock_writer, zone_a, zone_b):
"""Portals work bidirectionally between zones."""
from mudlib.commands.portals import cmd_enter
# Create player in zone_a at (2, 2)
player = Player(
name="TestPlayer",
x=2,
y=2,
reader=mock_reader,
writer=mock_writer,
location=zone_a,
)
# Register zones
register_zone("zone_a", zone_a)
register_zone("zone_b", zone_b)
# Create portal in zone A pointing to zone B
Portal(
name="doorway to B",
location=zone_a,
x=2,
y=2,
target_zone="zone_b",
target_x=7,
target_y=7,
)
# Create portal in zone B pointing to zone A
Portal(
name="doorway to A",
location=zone_b,
x=7,
y=7,
target_zone="zone_a",
target_x=2,
target_y=2,
)
# Player starts in zone A at (2, 2)
assert player.location is zone_a
assert player.x == 2
assert player.y == 2
# Enter portal to zone B
await cmd_enter(player, "doorway to B")
assert player.location is zone_b
assert player.x == 7
assert player.y == 7
# Enter portal back to zone A
await cmd_enter(player, "doorway to A")
assert player.location is zone_a
assert player.x == 2
assert player.y == 2

View file

@ -1,5 +1,7 @@
"""Tests for auto-triggering portals on movement.""" """Tests for auto-triggering portals on movement."""
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib.player import Player from mudlib.player import Player
@ -8,6 +10,19 @@ from mudlib.zone import Zone
from mudlib.zones import register_zone, zone_registry from mudlib.zones import register_zone, zone_registry
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def test_zone(): def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)] terrain = [["." for _ in range(10)] for _ in range(10)]

View file

@ -0,0 +1,119 @@
"""Tests for portal display in look command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.player import Player
from mudlib.portal import Portal
from mudlib.zone import Zone
@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 test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="testzone",
width=10,
height=10,
toroidal=True,
terrain=terrain,
)
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(
name="TestPlayer",
x=5,
y=5,
reader=mock_reader,
writer=mock_writer,
location=test_zone,
)
return p
@pytest.mark.asyncio
async def test_look_shows_portal_at_position(player, test_zone, mock_writer):
"""look command shows portals at player position."""
from mudlib.commands.look import cmd_look
Portal(
name="shimmering doorway",
location=test_zone,
x=5,
y=5,
target_zone="elsewhere",
target_x=0,
target_y=0,
)
await cmd_look(player, "")
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
# New format: "You see {portal.name}."
assert "you see shimmering doorway." in output.lower()
@pytest.mark.asyncio
async def test_look_shows_multiple_portals(player, test_zone, mock_writer):
"""look command shows multiple portals at player position."""
from mudlib.commands.look import cmd_look
Portal(
name="red portal",
location=test_zone,
x=5,
y=5,
target_zone="redzone",
target_x=0,
target_y=0,
)
Portal(
name="blue portal",
location=test_zone,
x=5,
y=5,
target_zone="bluezone",
target_x=0,
target_y=0,
)
await cmd_look(player, "")
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "red portal" in output.lower()
assert "blue portal" in output.lower()
@pytest.mark.asyncio
async def test_look_no_portals_at_position(player, test_zone, mock_writer):
"""look command doesn't show portals when none at position."""
from mudlib.commands.look import cmd_look
Portal(
name="distant portal",
location=test_zone,
x=8,
y=8,
target_zone="elsewhere",
target_x=0,
target_y=0,
)
await cmd_look(player, "")
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
# Should not mention portals when none are at player position
assert "portal" not in output.lower() or "distant portal" not in output.lower()

View file

@ -1,11 +1,26 @@
"""Tests for prefix matching in command dispatch.""" """Tests for prefix matching in command dispatch."""
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib import commands from mudlib import commands
from mudlib.commands import CommandDefinition from mudlib.commands import CommandDefinition
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer): def player(mock_reader, mock_writer):
from mudlib.player import Player from mudlib.player import Player

View file

@ -399,7 +399,7 @@ def test_move_shows_name_when_in_combat_with_active_move():
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
hit_time_ms=800, timing_window_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -463,7 +463,7 @@ def test_combat_state_shows_state_when_in_combat():
name="punch right", name="punch right",
move_type="attack", move_type="attack",
stamina_cost=5.0, stamina_cost=5.0,
hit_time_ms=800, timing_window_ms=800,
damage_pct=0.15, damage_pct=0.15,
countered_by=["dodge left"], countered_by=["dodge left"],
) )
@ -473,7 +473,7 @@ def test_combat_state_shows_state_when_in_combat():
active_encounters.append(encounter) active_encounters.append(encounter)
result = render_prompt(player) result = render_prompt(player)
assert result == "[pending] > " assert result == "[telegraph] > "
def test_terrain_variable_grass(): def test_terrain_variable_grass():

View file

@ -1,11 +1,26 @@
"""Tests for the prompt command.""" """Tests for the prompt command."""
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib.commands.prompt import cmd_prompt from mudlib.commands.prompt import cmd_prompt
from mudlib.prompt import render_prompt from mudlib.prompt import render_prompt
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer): def player(mock_reader, mock_writer):
from mudlib.player import Player from mudlib.player import Player

View file

@ -1,5 +1,7 @@
"""Tests for put and take-from commands.""" """Tests for put and take-from commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib.commands import _registry from mudlib.commands import _registry
@ -9,6 +11,19 @@ from mudlib.thing import Thing
from mudlib.zone import Zone from mudlib.zone import Zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture @pytest.fixture
def test_zone(): def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)] terrain = [["." for _ in range(10)] for _ in range(10)]

Some files were not shown because too many files have changed in this diff Show more