Compare commits

...

34 commits

Author SHA1 Message Date
f450952e04
Add admin help topics for building, spawning, editing, content, and commands 2026-02-16 16:45:47 -05:00
10c9d907b5
Add player help topics for combat, movement, and skills 2026-02-16 16:45:47 -05:00
bb5fd4d64f
Add player help topics for crafting, mobs, world, and containers 2026-02-16 16:45:46 -05:00
7832c31f62
Add player help topics for getting started and interactive fiction 2026-02-16 16:45:46 -05:00
7e73fb5510
Update index.rst with new system documentation 2026-02-16 16:31:34 -05:00
1a91b384b5
Add content loading pipeline documentation 2026-02-16 16:31:34 -05:00
19d0836e2b
Add crafting system documentation 2026-02-16 16:31:34 -05:00
7ae292c5d7
Add loot/corpse system documentation 2026-02-16 16:31:34 -05:00
0c43859651
Add target resolution system documentation 2026-02-16 16:31:33 -05:00
0e1c46cdcc
Add visual effects system documentation 2026-02-16 16:31:33 -05:00
b1ae0bcb81
Add time/season/weather system documentation 2026-02-16 16:31:33 -05:00
a350b3e1f6
Add Thing/verb system documentation 2026-02-16 16:31:33 -05:00
db8b395257
Add NPC/mob system documentation 2026-02-16 16:31:33 -05:00
50a1d356a8
Strengthen loose assertions in test suite
Remove redundant bare-truthy and .called checks where more specific
content or entity validation already exists on subsequent lines.
2026-02-16 16:10:38 -05:00
87b971abcc
Delete trivial version-check test 2026-02-16 16:10:38 -05:00
23b201a0d0
Merge portal display and two-way tests into test_portal.py 2026-02-16 16:10:38 -05:00
7afdeb72a5
Merge help command tests into test_help_topics.py 2026-02-16 16:10:38 -05:00
d030d8e026
Merge container display tests into test_container.py 2026-02-16 16:10:38 -05:00
37d1c86b34
Delete trivial constructor and property tests
Removed 32 tests that only verified constructor args are stored as
properties. Type annotations and behavioral tests already cover this.
2026-02-16 16:10:38 -05:00
9c480f8d47
Remove duplicate mock_writer/mock_reader fixtures
Removed identical local copies from 45 test files. These fixtures
are already defined in conftest.py.
2026-02-16 15:29:21 -05:00
5c2cc28415
Validate timing fields per move type in load_move 2026-02-16 14:49:09 -05:00
0a9a7f74bc
Add validation tests for combat move timing fields 2026-02-16 14:49:06 -05:00
03d04f0a33
Update combat docs to match 3-state machine and correct field names 2026-02-16 14:48:39 -05:00
13eb6a947b
Update builder manual combat examples with correct field names 2026-02-16 14:48:22 -05:00
baf1c6ea25
Add project health audit findings and cleanup plan 2026-02-16 12:19:02 -05:00
edbad4666f
Rework combat state machine
PENDING phase, defense active/recovery windows
2026-02-16 12:17:34 -05:00
312da1dbac
Update combat TOML schema
hit_time_ms for attacks, active_ms/recovery_ms for defenses
2026-02-16 12:17:28 -05:00
30e7123912
Add dbzfe log parser 2026-02-15 15:40:50 -05:00
d9739b4f09
Fix tintin aliases 2026-02-15 15:02:10 -05:00
e0406e39e5
Make snapneck the explicit kill/death/corpse finisher path 2026-02-15 12:40:25 -05:00
a4a866a77c
Change combat flow: KO persists, timeout requires no landed damage 2026-02-15 12:40:21 -05:00
f40ee68f9a
Refactor look/examine targeting and improve room rendering 2026-02-15 12:40:16 -05:00
8424404d27
Normalize alias casing across command and persistence 2026-02-15 12:40:10 -05:00
3d1279475e
Add a top level redirecting agent config 2026-02-15 10:49:38 -05:00
118 changed files with 4000 additions and 2974 deletions

1
AGENTS.md Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ telegraph = "{attacker} shifts {his} weight back..."
announce = "{attacker} launch{es} a roundhouse kick at {defender}!"
resolve_hit = "{attacker}'s roundhouse slams into {defender}!"
resolve_miss = "{defender} counter{s} {attacker}'s roundhouse!"
timing_window_ms = 2000
hit_time_ms = 3000
damage_pct = 0.25
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!"
resolve_hit = "{attacker}'s sweep catches {defender}'s legs!"
resolve_miss = "{defender} jump{s} over {attacker}'s sweep!"
timing_window_ms = 1800
hit_time_ms = 3000
damage_pct = 0.18
countered_by = ["jump", "parry low"]

View file

@ -0,0 +1,42 @@
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

@ -0,0 +1,38 @@
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.
"""

52
content/help/combat.toml Normal file
View file

@ -0,0 +1,52 @@
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

@ -0,0 +1,34 @@
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).
"""

43
content/help/content.toml Normal file
View file

@ -0,0 +1,43 @@
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

@ -0,0 +1,25 @@
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'.
"""

32
content/help/editing.toml Normal file
View file

@ -0,0 +1,32 @@
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

@ -0,0 +1,28 @@
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

@ -0,0 +1,30 @@
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.
"""

26
content/help/mobs.toml Normal file
View file

@ -0,0 +1,26 @@
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

@ -0,0 +1,40 @@
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.
"""

44
content/help/skills.toml Normal file
View file

@ -0,0 +1,44 @@
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

@ -0,0 +1,27 @@
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.
"""

31
content/help/world.toml Normal file
View file

@ -0,0 +1,31 @@
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"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 1800
hit_time_ms = 1800
damage_pct = 0.15
[variants.left]
@ -266,7 +266,8 @@ name = "dodge"
description = "a quick sidestep to evade incoming attacks"
move_type = "defense"
stamina_cost = 3.0
timing_window_ms = 800
active_ms = 800
recovery_ms = 2700
[variants.left]
@ -279,7 +280,9 @@ timing_window_ms = 800
- `description` - shown in help/skills
- `move_type` - "attack" or "defense"
- `stamina_cost` - stamina consumed per use
- `timing_window_ms` - how long the window is open (attacks: time to defend, defenses: commitment time)
- `hit_time_ms` - (attacks) time in ms from initiation to impact
- `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)
- `[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)

View file

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

View file

@ -0,0 +1,151 @@
=============================
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/

86
docs/how/crafting.rst Normal file
View file

@ -0,0 +1,86 @@
====================
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

86
docs/how/effects.rst Normal file
View file

@ -0,0 +1,86 @@
==============
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

@ -0,0 +1,104 @@
=======================
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

193
docs/how/npc-mobs.rst Normal file
View file

@ -0,0 +1,193 @@
================
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

96
docs/how/targeting.rst Normal file
View file

@ -0,0 +1,96 @@
=======================
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

@ -0,0 +1,134 @@
=====================================
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

@ -0,0 +1,121 @@
=============================
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,6 +28,14 @@ mostly standalone subsystems.
- ``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/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
------

View file

@ -0,0 +1,164 @@
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

13
docs/research/dbzfe Executable file
View file

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

16
mud.tin
View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import time
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
from mudlib.combat.stamina import check_stamina_cues
from mudlib.entity import Entity, Mob
from mudlib.entity import Entity
from mudlib.gmcp import send_char_status, send_char_vitals
from mudlib.render.colors import colorize
from mudlib.render.pov import render_pov
@ -89,7 +89,7 @@ async def process_combat() -> None:
now = time.monotonic()
for encounter in active_encounters[:]: # Copy list to allow modification
# Check for idle timeout
# Check for no-damage timeout.
if now - encounter.last_action_at > IDLE_TIMEOUT:
await encounter.attacker.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
encounter.tick(now)
# Send announce message on TELEGRAPH → WINDOW transition
# Send announce message on PENDING → RESOLVE transition
if (
previous_state == CombatState.TELEGRAPH
and encounter.state == CombatState.WINDOW
previous_state == CombatState.PENDING
and encounter.state == CombatState.RESOLVE
and encounter.current_move
and encounter.current_move.announce
):
@ -156,67 +156,3 @@ async def process_combat() -> None:
# Check stamina cues after damage
await check_stamina_cues(encounter.attacker)
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,7 +28,9 @@ class CombatMove:
name: str
move_type: str # "attack" or "defense"
stamina_cost: float
timing_window_ms: int
hit_time_ms: int = 0 # for attacks: ms from initiation to impact
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)
telegraph: str = ""
damage_pct: float = 0.0
@ -69,12 +71,22 @@ def load_move(path: Path) -> list[CombatMove]:
data = tomllib.load(f)
# Required fields
required_fields = ["name", "move_type", "stamina_cost", "timing_window_ms"]
required_fields = ["name", "move_type", "stamina_cost"]
for field_name in required_fields:
if field_name not in data:
msg = f"missing required field: {field_name}"
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"]
variants = data.get("variants")
@ -97,8 +109,12 @@ def load_move(path: Path) -> list[CombatMove]:
name=qualified_name,
move_type=data["move_type"],
stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]),
timing_window_ms=variant_data.get(
"timing_window_ms", data["timing_window_ms"]
hit_time_ms=variant_data.get(
"hit_time_ms", data.get("hit_time_ms", 0)
),
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", []),
telegraph=variant_data.get("telegraph", data.get("telegraph", "")),
@ -139,7 +155,9 @@ def load_move(path: Path) -> list[CombatMove]:
name=base_name,
move_type=data["move_type"],
stamina_cost=data["stamina_cost"],
timing_window_ms=data["timing_window_ms"],
hit_time_ms=data.get("hit_time_ms", 0),
active_ms=data.get("active_ms", 0),
recovery_ms=data.get("recovery_ms", 0),
aliases=data.get("aliases", []),
telegraph=data.get("telegraph", ""),
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
parts = args.split(None, 1)
alias_name = parts[0]
alias_name = parts[0].lower()
if len(parts) == 1:
# Show single alias
@ -39,7 +39,7 @@ async def cmd_alias(player: Player, args: str) -> None:
return
# Create alias
expansion = parts[1]
expansion = parts[1].strip()
# Cannot alias over built-in commands
if alias_name in _registry:
@ -56,7 +56,7 @@ async def cmd_unalias(player: Player, args: str) -> None:
Usage:
unalias <name>
"""
alias_name = args.strip()
alias_name = args.strip().lower()
if not alias_name:
await player.send("Usage: unalias <name>\r\n")

View file

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

View file

@ -123,7 +123,11 @@ async def _show_single_command(
# Combat move specific details
if move is not None:
lines.append(f" stamina: {move.stamina_cost}")
lines.append(f" timing window: {move.timing_window_ms}ms")
if move.move_type == "attack":
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:
damage_pct = int(move.damage_pct * 100)
lines.append(f" damage: {damage_pct}%")
@ -188,7 +192,11 @@ async def _show_variant_overview(
lines.append(f" aliases: {aliases_str}")
lines.append(f" stamina: {move.stamina_cost}")
lines.append(f" timing window: {move.timing_window_ms}ms")
if move.move_type == "attack":
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:
damage_pct = int(move.damage_pct * 100)

View file

@ -37,52 +37,11 @@ async def cmd_look(player: Player, args: str) -> None:
player: The player executing the command
args: Command arguments (if provided, use targeting to resolve)
"""
# If args provided, use targeting to resolve
# If args provided, route directly to examine behavior.
if args.strip():
from mudlib.targeting import (
find_entity_on_tile,
find_in_inventory,
find_thing_on_tile,
)
from mudlib.commands.examine import examine_target
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")
await examine_target(player, args.strip(), prefer_inventory=False)
return
zone = player.location
@ -220,7 +179,7 @@ async def cmd_look(player: Player, args: str) -> None:
output.append(render_nearby(nearby_entities, player))
# Exits line
output.append(render_exits(zone, player.x, player.y))
output.append(render_exits(zone, player.x, player.y, player))
# Send to player
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.commands import CommandDefinition, register
from mudlib.player import Player, players
from mudlib.player import Player
DEATH_PL = -100.0
@ -14,37 +14,32 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
player: The player executing the command
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
target_name = args.strip()
if not target_name:
await player.send("Snap whose neck?\r\n")
return
# Find target
target = players.get(target_name)
if target is None and player.location is not None:
from mudlib.mobs import get_nearby_mob
from mudlib.zone import Zone
# Must be used during an active encounter.
encounter = get_encounter(player)
if encounter is None:
await player.send("You're not in combat.\r\n")
return
if isinstance(player.location, Zone):
target = get_nearby_mob(target_name, player.x, player.y, player.location)
# Find target on this tile.
from mudlib.targeting import find_entity_on_tile
target = find_entity_on_tile(target_name, player)
if target is None:
await player.send(f"You don't see {target_name} here.\r\n")
return
# Verify target is in the encounter
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")
if target is player:
await player.send("You can't do that to yourself.\r\n")
return
if encounter.attacker is not target and encounter.defender is not target:
# Snap neck can only target your current opponent.
if target not in (encounter.attacker, encounter.defender):
await player.send("You're not in combat with that target.\r\n")
return
@ -64,16 +59,38 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
from mudlib.entity import Mob
from mudlib.gmcp import send_char_vitals
if not isinstance(target, Mob):
if isinstance(target, Player):
send_char_vitals(target)
# Handle mob despawn
# Award kill/death stats on explicit finishers only.
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):
from mudlib.mobs import despawn_mob
from mudlib.corpse import create_corpse
from mudlib.mobs import despawn_mob, mob_templates
from mudlib.zone import Zone
despawn_mob(target)
zone = target.location
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 if they're Players
# Pop combat mode from both entities.
from mudlib.gmcp import send_char_status
if isinstance(player, Player) and player.mode == "combat":

View file

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

View file

@ -80,7 +80,7 @@ def render_nearby(entities: list, viewer) -> str:
return f"Nearby: ({count}) {names}"
def render_exits(zone, x: int, y: int) -> str:
def render_exits(zone, x: int, y: int, viewer=None) -> str:
"""Render available exits from current position.
Args:
@ -94,7 +94,6 @@ def render_exits(zone, x: int, y: int) -> str:
exits = []
# 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)
exits.append("north")
if zone.is_passable(x, y + 1): # south
@ -103,6 +102,11 @@ def render_exits(zone, x: int, y: int) -> str:
exits.append("east")
if zone.is_passable(x - 1, y): # 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:
return f"Exits: {' '.join(exits)}"
@ -111,6 +115,7 @@ def render_exits(zone, x: int, y: int) -> str:
_POSTURE_MESSAGES = {
"standing": "is standing here.",
"sleeping": "is sleeping here.",
"resting": "is resting here.",
"flying": "is flying above.",
"fighting": "is fighting here.",

View file

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

View file

@ -38,19 +38,6 @@ def zone():
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):
p = Player(
name=name,

View file

@ -25,11 +25,11 @@ def test_save_and_load_aliases_roundtrip():
db_path = Path(tmpdir) / "test.db"
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)
loaded = load_aliases("goku", db_path)
assert loaded == aliases
assert loaded == {"pr": "punch right", "pl": "punch left", "l": "look"}
def test_load_aliases_empty():
@ -76,6 +76,16 @@ async def test_alias_create(player):
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
async def test_alias_list_with_aliases(player):
"""alias with no args lists all aliases."""
@ -113,6 +123,16 @@ async def test_unalias_removes_alias(player):
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
async def test_unalias_no_such_alias(player):
"""unalias on non-existent alias shows error."""
@ -135,6 +155,18 @@ async def test_alias_cannot_override_builtin(player):
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
@pytest.mark.asyncio
async def test_alias_expands_in_dispatch(player):
@ -151,6 +183,21 @@ async def test_alias_expands_in_dispatch(player):
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
async def test_alias_with_extra_args(player):
"""Alias expansion preserves additional arguments."""

View file

@ -1,7 +1,5 @@
"""Tests for builder commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.player import Player, players
@ -26,19 +24,6 @@ def zone():
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
def player(zone, mock_writer, mock_reader):
p = Player(

View file

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

View file

@ -25,7 +25,7 @@ def punch():
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["dodge left", "parry high"],
)
@ -37,7 +37,8 @@ def dodge():
name="dodge left",
move_type="defense",
stamina_cost=3.0,
timing_window_ms=800,
active_ms=800,
recovery_ms=2700,
)
@ -47,7 +48,8 @@ def wrong_dodge():
name="dodge right",
move_type="defense",
stamina_cost=3.0,
timing_window_ms=800,
active_ms=800,
recovery_ms=2700,
)
@ -57,7 +59,7 @@ def sweep():
name="sweep",
move_type="attack",
stamina_cost=8.0,
timing_window_ms=600,
hit_time_ms=600,
damage_pct=0.20,
countered_by=["jump"],
)
@ -72,12 +74,12 @@ def test_combat_encounter_initial_state(attacker, defender):
assert encounter.move_started_at == 0.0
def test_attack_transitions_to_telegraph(attacker, defender, punch):
"""Test attacking transitions to TELEGRAPH state."""
def test_attack_transitions_to_pending(attacker, defender, punch):
"""Test attacking transitions to PENDING state."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
assert encounter.state == CombatState.TELEGRAPH
assert encounter.state == CombatState.PENDING
assert encounter.current_move is punch
assert encounter.move_started_at > 0.0
@ -92,12 +94,13 @@ def test_attack_applies_stamina_cost(attacker, defender, punch):
def test_defend_records_pending_defense(attacker, defender, punch, dodge):
"""Test defend records the defense move."""
"""Test defend records the defense move and activates it."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
encounter.defend(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):
@ -111,29 +114,12 @@ def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
assert defender.stamina == initial_stamina
def test_tick_telegraph_to_window(attacker, defender, punch):
"""Test tick advances from TELEGRAPH to WINDOW after brief delay."""
def test_tick_pending_to_resolve(attacker, defender, punch):
"""Test tick advances from PENDING to RESOLVE after hit time."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
# 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)
# Wait for hit_time_ms (800ms)
time.sleep(0.85)
now = time.monotonic()
encounter.tick(now)
@ -212,16 +198,11 @@ def test_full_state_machine_cycle(attacker, defender, punch):
"""Test complete state machine cycle from IDLE to IDLE."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
# IDLE → TELEGRAPH
# IDLE → PENDING
encounter.attack(punch)
assert encounter.state == CombatState.TELEGRAPH
assert encounter.state == CombatState.PENDING
# TELEGRAPH → WINDOW
time.sleep(0.31)
encounter.tick(time.monotonic())
assert encounter.state == CombatState.WINDOW
# WINDOW → RESOLVE
# PENDING → RESOLVE (after hit_time_ms)
time.sleep(0.85)
encounter.tick(time.monotonic())
assert encounter.state == CombatState.RESOLVE
@ -234,13 +215,12 @@ def test_full_state_machine_cycle(attacker, defender, punch):
def test_combat_state_enum():
"""Test CombatState enum values."""
assert CombatState.IDLE.value == "idle"
assert CombatState.TELEGRAPH.value == "telegraph"
assert CombatState.WINDOW.value == "window"
assert CombatState.PENDING.value == "pending"
assert CombatState.RESOLVE.value == "resolve"
def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch):
"""Test resolve returns combat_ended=True when defender PL <= 0."""
def test_resolve_knockout_does_not_end_combat(attacker, defender, punch):
"""KO should not end combat by itself."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
# Set defender to low PL so attack will knock them out
@ -250,12 +230,12 @@ def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch):
result = encounter.resolve()
assert defender.pl <= 0
assert result.combat_ended is True
assert result.combat_ended is False
assert result.damage > 0
def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch):
"""Test resolve returns combat_ended=True when attacker stamina <= 0."""
def test_resolve_exhaustion_does_not_end_combat(attacker, defender, punch):
"""Exhaustion should not end combat by itself."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
# Set attacker stamina to exactly the cost so attack depletes it
@ -265,7 +245,19 @@ def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch):
result = encounter.resolve()
assert attacker.stamina <= 0
assert result.combat_ended is True
assert result.combat_ended is False
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):
@ -312,41 +304,22 @@ def test_resolve_counter_template_indicates_counter(attacker, defender, punch, d
# --- Attack switching (feint) tests ---
def test_switch_attack_during_telegraph(attacker, defender, punch, sweep):
"""Test attack during TELEGRAPH replaces move and keeps timer."""
def test_switch_attack_during_pending(attacker, defender, punch, sweep):
"""Test attack during PENDING replaces move and restarts timer."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
original_start = encounter.move_started_at
assert encounter.state == CombatState.TELEGRAPH
assert encounter.state == CombatState.PENDING
# Switch to sweep during telegraph
# Switch to sweep during pending
time.sleep(0.1) # Small delay to ensure timer would differ
encounter.attack(sweep)
assert encounter.current_move is sweep
assert encounter.state == CombatState.TELEGRAPH
# Timer should NOT restart
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
assert encounter.state == CombatState.PENDING
# Timer should restart on switch
assert encounter.move_started_at > original_start
def test_switch_refunds_old_stamina(attacker, defender, punch, sweep):
@ -393,27 +366,288 @@ def test_resolve_uses_final_move(attacker, defender, punch, sweep):
assert result.resolve_template != ""
# --- last_action_at tracking tests ---
# --- last_action_at (last landed damage) tracking tests ---
def test_last_action_at_updates_on_attack(attacker, defender, punch):
"""Test last_action_at is set when attack() is called."""
def test_last_action_at_not_updated_on_attack(attacker, defender, punch):
"""Attack startup should not reset timeout until damage lands."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
assert encounter.last_action_at == 0.0
before = time.monotonic()
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()
encounter.resolve()
assert encounter.last_action_at >= before
def test_last_action_at_updates_on_defend(attacker, defender, punch, dodge):
"""Test last_action_at is set when defend() is called."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
def test_last_action_at_unchanged_when_attack_is_countered(
attacker, defender, punch, dodge
):
"""No damage (successful counter) should not refresh timeout timestamp."""
encounter = CombatEncounter(
attacker=attacker, defender=defender, last_action_at=10.0
)
encounter.attack(punch)
first_action = encounter.last_action_at
time.sleep(0.01)
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",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["dodge left"],
)
@ -107,7 +107,7 @@ async def test_process_combat_advances_encounters(attacker, defender, punch):
time.sleep(0.31)
await process_combat()
assert encounter.state == CombatState.WINDOW
assert encounter.state == CombatState.PENDING
@pytest.mark.asyncio
@ -127,8 +127,8 @@ async def test_process_combat_handles_multiple_encounters(punch):
time.sleep(0.31)
await process_combat()
assert enc1.state == CombatState.WINDOW
assert enc2.state == CombatState.WINDOW
assert enc1.state == CombatState.PENDING
assert enc2.state == CombatState.PENDING
@pytest.mark.asyncio
@ -141,7 +141,7 @@ async def test_process_combat_auto_resolves_expired_windows(attacker, defender,
# Skip past telegraph and window
time.sleep(0.31) # Telegraph
await process_combat()
assert encounter.state == CombatState.WINDOW
assert encounter.state == CombatState.PENDING
time.sleep(0.85) # Window
await process_combat()
@ -201,8 +201,8 @@ async def test_encounter_cleanup_after_resolution(attacker, defender, punch):
@pytest.mark.asyncio
async def test_process_combat_ends_encounter_on_knockout(punch):
"""Test process_combat ends encounter when defender is knocked out."""
async def test_process_combat_keeps_encounter_after_knockout(punch):
"""KO should not end combat; encounter stays active."""
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=10.0, stamina=50.0, writer=w())
@ -220,17 +220,16 @@ async def test_process_combat_ends_encounter_on_knockout(punch):
time.sleep(0.85)
await process_combat()
# Combat should have ended and been cleaned up
assert get_encounter(attacker) is None
assert get_encounter(defender) is None
# Mode stacks should have combat popped
assert attacker.mode_stack == ["normal"]
assert defender.mode_stack == ["normal"]
# Combat should remain active after KO
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_ends_encounter_on_exhaustion(punch):
"""Test process_combat ends encounter when attacker is exhausted."""
async def test_process_combat_keeps_encounter_after_exhaustion(punch):
"""Exhaustion should not end combat; encounter stays active."""
w = _mock_writer
attacker = Player(
name="Goku",
@ -262,11 +261,68 @@ async def test_process_combat_ends_encounter_on_exhaustion(punch):
time.sleep(0.85)
await process_combat()
# Combat should have ended
assert get_encounter(attacker) is None
assert get_encounter(defender) is None
assert attacker.mode_stack == ["normal"]
assert defender.mode_stack == ["normal"]
# Combat should remain active
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_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
@ -335,7 +391,7 @@ async def test_process_combat_sends_messages_on_resolve(punch):
@pytest.mark.asyncio
async def test_idle_timeout_ends_encounter():
"""Test encounter times out after 30s of no actions."""
"""Encounter times out after 30s without landed damage."""
w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, writer=w())
@ -393,7 +449,7 @@ async def test_idle_timeout_pops_combat_mode():
@pytest.mark.asyncio
async def test_recent_action_prevents_timeout():
"""Test recent action prevents idle timeout."""
"""Fresh encounter start prevents immediate timeout."""
w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, writer=w())
@ -411,7 +467,7 @@ async def test_recent_action_prevents_timeout():
@pytest.mark.asyncio
async def test_start_encounter_sets_last_action_at():
"""Test start_encounter initializes last_action_at."""
"""start_encounter initializes no-damage timeout clock."""
attacker = Entity(name="Goku", x=0, y=0)
defender = Entity(name="Vegeta", x=0, y=0)
@ -419,3 +475,25 @@ async def test_start_encounter_sets_last_action_at():
encounter = start_encounter(attacker, defender)
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"],
stamina_cost=5.0,
telegraph="{attacker} winds up a right hook!",
timing_window_ms=800,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["dodge left", "parry high"],
command="punch",
@ -24,7 +24,7 @@ def test_combat_move_dataclass():
assert move.aliases == ["pr"]
assert move.stamina_cost == 5.0
assert move.telegraph == "{attacker} winds up a right hook!"
assert move.timing_window_ms == 800
assert move.hit_time_ms == 800
assert move.damage_pct == 0.15
assert move.countered_by == ["dodge left", "parry high"]
assert move.handler is None
@ -38,14 +38,14 @@ def test_combat_move_minimal():
name="test move",
move_type="attack",
stamina_cost=10.0,
timing_window_ms=500,
hit_time_ms=500,
)
assert move.name == "test move"
assert move.move_type == "attack"
assert move.aliases == []
assert move.stamina_cost == 10.0
assert move.telegraph == ""
assert move.timing_window_ms == 500
assert move.hit_time_ms == 500
assert move.damage_pct == 0.0
assert move.countered_by == []
assert move.command == ""
@ -60,7 +60,7 @@ aliases = ["rh"]
move_type = "attack"
stamina_cost = 8.0
telegraph = "{attacker} spins into a roundhouse kick!"
timing_window_ms = 600
hit_time_ms = 600
damage_pct = 0.25
countered_by = ["duck", "parry high", "parry low"]
"""
@ -83,7 +83,7 @@ def test_load_variant_move_from_toml(tmp_path):
name = "punch"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
hit_time_ms = 800
damage_pct = 0.15
[variants.left]
@ -114,7 +114,7 @@ countered_by = ["dodge left", "parry high"]
assert left.countered_by == ["dodge right", "parry high"]
# Inherited from parent
assert left.stamina_cost == 5.0
assert left.timing_window_ms == 800
assert left.hit_time_ms == 800
assert left.damage_pct == 0.15
right = by_name["punch right"]
@ -130,13 +130,13 @@ def test_variant_inherits_shared_properties(tmp_path):
name = "kick"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
hit_time_ms = 800
damage_pct = 0.10
[variants.low]
aliases = ["kl"]
damage_pct = 0.08
timing_window_ms = 600
hit_time_ms = 600
[variants.high]
aliases = ["kh"]
@ -150,12 +150,12 @@ damage_pct = 0.15
low = by_name["kick low"]
assert low.damage_pct == 0.08
assert low.timing_window_ms == 600 # overridden
assert low.hit_time_ms == 600 # overridden
assert low.stamina_cost == 5.0 # inherited
high = by_name["kick high"]
assert high.damage_pct == 0.15
assert high.timing_window_ms == 800 # inherited
assert high.hit_time_ms == 800 # inherited
assert high.stamina_cost == 5.0 # inherited
@ -165,7 +165,8 @@ def test_load_move_with_defaults(tmp_path):
name = "basic move"
move_type = "defense"
stamina_cost = 3.0
timing_window_ms = 600
active_ms = 600
recovery_ms = 2700
"""
toml_file = tmp_path / "basic.toml"
toml_file.write_text(toml_content)
@ -185,7 +186,7 @@ def test_load_move_missing_name(tmp_path):
toml_content = """
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
hit_time_ms = 800
"""
toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content)
@ -213,7 +214,7 @@ def test_load_move_missing_stamina_cost(tmp_path):
toml_content = """
name = "test"
move_type = "attack"
timing_window_ms = 800
hit_time_ms = 800
"""
toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content)
@ -222,8 +223,8 @@ timing_window_ms = 800
load_move(toml_file)
def test_load_move_missing_timing_window(tmp_path):
"""Test loading move without timing_window_ms raises error."""
def test_load_attack_missing_hit_time_raises(tmp_path):
"""Test loading attack without hit_time_ms raises error."""
toml_content = """
name = "test"
move_type = "attack"
@ -232,10 +233,89 @@ stamina_cost = 5.0
toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content)
with pytest.raises(ValueError, match="missing required field.*timing_window_ms"):
with pytest.raises(ValueError, match="hit_time_ms"):
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):
"""Test loading all moves from a directory."""
# Create a variant move
@ -245,7 +325,7 @@ def test_load_moves_from_directory(tmp_path):
name = "punch"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
hit_time_ms = 800
damage_pct = 0.15
[variants.right]
@ -262,7 +342,8 @@ countered_by = ["dodge left"]
name = "duck"
move_type = "defense"
stamina_cost = 3.0
timing_window_ms = 500
active_ms = 500
recovery_ms = 2700
"""
)
@ -303,7 +384,7 @@ name = "move one"
aliases = ["m"]
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
hit_time_ms = 800
"""
)
@ -314,7 +395,8 @@ name = "move two"
aliases = ["m"]
move_type = "defense"
stamina_cost = 3.0
timing_window_ms = 500
active_ms = 500
recovery_ms = 2700
"""
)
@ -330,7 +412,7 @@ def test_load_moves_name_collision(tmp_path):
name = "punch"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
hit_time_ms = 800
"""
)
@ -340,7 +422,7 @@ timing_window_ms = 800
name = "punch"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
hit_time_ms = 800
"""
)
@ -358,7 +440,7 @@ def test_load_moves_validates_countered_by_refs(tmp_path, caplog):
name = "punch"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
hit_time_ms = 800
damage_pct = 0.15
[variants.right]
@ -372,7 +454,8 @@ countered_by = ["dodge left", "nonexistent move"]
name = "dodge"
move_type = "defense"
stamina_cost = 3.0
timing_window_ms = 500
active_ms = 500
recovery_ms = 2700
[variants.left]
aliases = ["dl"]
@ -401,7 +484,7 @@ def test_load_moves_valid_countered_by_refs_no_warning(tmp_path, caplog):
name = "punch"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
hit_time_ms = 800
damage_pct = 0.15
[variants.right]
@ -415,7 +498,8 @@ countered_by = ["dodge left", "parry high"]
name = "dodge"
move_type = "defense"
stamina_cost = 3.0
timing_window_ms = 500
active_ms = 500
recovery_ms = 2700
[variants.left]
aliases = ["dl"]
@ -428,7 +512,8 @@ aliases = ["dl"]
name = "parry"
move_type = "defense"
stamina_cost = 3.0
timing_window_ms = 500
active_ms = 500
recovery_ms = 2700
[variants.high]
aliases = ["f"]

View file

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

View file

@ -136,7 +136,7 @@ async def test_flying_during_window_causes_miss(player, target, punch_right):
encounter.attack(punch_right)
# Advance to WINDOW phase
encounter.state = CombatState.WINDOW
encounter.state = CombatState.PENDING
# Defender flies during window
target.flying = True
@ -159,7 +159,7 @@ async def test_both_flying_at_resolve_attack_lands(player, target, punch_right):
encounter.attack(punch_right)
# Advance to WINDOW phase (no altitude change)
encounter.state = CombatState.WINDOW
encounter.state = CombatState.PENDING
# 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)
# Advance to WINDOW phase
encounter.state = CombatState.WINDOW
encounter.state = CombatState.PENDING
# Attacker flies during window
player.flying = True
@ -205,7 +205,7 @@ async def test_flying_dodge_messages_correct_grammar(player, target, punch_right
encounter.attack(punch_right)
# Advance to WINDOW phase
encounter.state = CombatState.WINDOW
encounter.state = CombatState.PENDING
# Defender flies during window
target.flying = True

View file

@ -21,19 +21,6 @@ def _clean_test_commands():
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
def test_zone():
# Create a 100x100 zone filled with passable terrain
@ -97,7 +84,6 @@ async def test_dispatch_routes_to_handler(player):
commands.register(CommandDefinition("testcmd", test_handler))
await commands.dispatch(player, "testcmd arg1 arg2")
assert called
assert received_args == "arg1 arg2"
@ -360,7 +346,7 @@ async def test_dispatch_allows_wildcard_mode(player):
commands.register(CommandDefinition("universal", any_handler, mode="*"))
await commands.dispatch(player, "universal")
assert called
assert called is True
@pytest.mark.asyncio
@ -376,7 +362,7 @@ async def test_dispatch_allows_matching_mode(player):
player.mode_stack.append("combat")
await commands.dispatch(player, "strike")
assert called
assert called is True
@pytest.mark.asyncio

View file

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

View file

@ -1,58 +1,42 @@
"""Tests for the Container class."""
import pytest
from mudlib.container import Container
from mudlib.object import Object
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
# --- construction ---
# --- fixtures ---
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"],
@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,
)
assert c.description == "a beautifully carved wooden chest"
assert c.portable is False
assert c.aliases == ["chest", "box"]
@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 ---
# --- can_accept ---
@ -132,3 +116,105 @@ def test_container_with_contents():
assert sword in chest.contents
assert gem in chest.contents
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

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

View file

@ -5,7 +5,6 @@ from unittest.mock import MagicMock
import pytest
from mudlib.container import Container
from mudlib.corpse import Corpse, create_corpse
from mudlib.entity import Mob
from mudlib.mobs import mobs
@ -63,36 +62,6 @@ def potion():
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:
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."""
@ -219,7 +188,7 @@ class TestCorpseAsContainer:
class TestCombatDeathCorpse:
"""Tests for corpse spawning when a mob dies in combat."""
"""Knockouts do not create corpses until a finisher is used."""
@pytest.fixture(autouse=True)
def clear_corpses(self):
@ -230,8 +199,8 @@ class TestCombatDeathCorpse:
active_corpses.clear()
@pytest.mark.asyncio
async def test_mob_death_in_combat_spawns_corpse(self, test_zone):
"""Mob death in combat spawns a corpse at mob's position."""
async def test_mob_knockout_in_combat_does_not_spawn_corpse(self, test_zone):
"""KO in combat should not create a corpse by itself."""
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import (
active_encounters,
@ -273,7 +242,7 @@ class TestCombatDeathCorpse:
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["dodge left"],
)
@ -283,16 +252,17 @@ class TestCombatDeathCorpse:
# Process combat to trigger resolve
await process_combat()
# Check for corpse at mob's position
# Check no corpse spawned yet
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"
assert len(corpses) == 0
assert mob in mobs
assert mob.alive is True
@pytest.mark.asyncio
async def test_mob_death_transfers_inventory_to_corpse(self, test_zone, sword):
"""Mob death transfers inventory to corpse."""
async def test_mob_knockout_keeps_inventory_on_mob(self, test_zone, sword):
"""KO should not transfer inventory to a corpse until finished."""
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import (
active_encounters,
@ -333,7 +303,7 @@ class TestCombatDeathCorpse:
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["dodge left"],
)
@ -343,20 +313,17 @@ class TestCombatDeathCorpse:
# Process combat
await process_combat()
# Find corpse
# No corpse yet
corpses = [
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
]
assert len(corpses) == 1
corpse = corpses[0]
# Verify sword is in corpse
assert sword in corpse._contents
assert sword.location is corpse
assert len(corpses) == 0
assert sword in mob._contents
assert sword.location is mob
@pytest.mark.asyncio
async def test_corpse_appears_in_zone_contents(self, test_zone):
"""Corpse appears in zone.contents_at after mob death."""
async def test_no_corpse_in_zone_contents_after_ko(self, test_zone):
"""Zone should not contain corpse from a plain KO."""
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import (
active_encounters,
@ -396,7 +363,7 @@ class TestCombatDeathCorpse:
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["dodge left"],
)
@ -406,14 +373,50 @@ class TestCombatDeathCorpse:
# Process combat
await process_combat()
# Verify corpse is in zone contents
# Verify no corpse in zone contents
contents = list(test_zone.contents_at(5, 10))
corpse_count = sum(1 for obj in contents if isinstance(obj, Corpse))
assert corpse_count == 1
assert corpse_count == 0
# Verify it's the goblin's corpse
corpse = next(obj for obj in contents if isinstance(obj, Corpse))
assert corpse.name == "goblin's corpse"
@pytest.mark.asyncio
async def test_snapneck_finisher_spawns_corpse(self, test_zone):
"""Explicit finisher kill should create a 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:

View file

@ -1,6 +1,6 @@
"""Tests for editor integration with the shell and command system."""
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import MagicMock
import pytest
@ -10,19 +10,6 @@ from mudlib.editor import Editor
from mudlib.player import Player
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer):
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
@ -47,7 +34,6 @@ async def test_edit_command_sends_welcome_message(player, mock_writer):
"""Test that edit command sends welcome message."""
await cmd_edit(player, "")
assert mock_writer.write.called
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "editor" in output.lower()
assert ":h" in output
@ -105,7 +91,6 @@ async def test_editor_save_callback_sends_message(player, mock_writer):
assert response.saved is True
# 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])
assert "saved" in output.lower()
@ -187,7 +172,7 @@ async def test_edit_combat_move_opens_toml(player, tmp_path):
aliases = ["rh"]
move_type = "attack"
stamina_cost = 8.0
timing_window_ms = 2000
hit_time_ms = 2000
"""
toml_file = tmp_path / "roundhouse.toml"
toml_file.write_text(toml_content)
@ -237,7 +222,6 @@ move_type = "attack"
# Check that file was written
saved_content = toml_file.read_text()
assert "stamina_cost = 9.0" in saved_content
assert mock_writer.write.called
@pytest.mark.asyncio
@ -280,7 +264,6 @@ async def test_edit_unknown_content_shows_error(player, mock_writer, tmp_path):
assert player.editor is None
assert player.mode == "normal"
assert mock_writer.write.called
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "unknown" 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"
success = filesystem.save_game(test_data)
assert success
assert success is True
assert save_path.exists()
restored = filesystem.restore_game()
@ -163,7 +163,6 @@ async def test_embedded_session_handle_input():
response = await session.handle_input("look")
assert response is not None
assert response.done is False
assert len(response.output) > 0
# Looking should describe the starting location

View file

@ -11,19 +11,6 @@ from mudlib.zone import Zone
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
def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]

View file

@ -1,87 +0,0 @@
"""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,26 +1,9 @@
"""Tests for Entity.posture property."""
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_default_posture():
"""Entity with no special state should be 'standing'."""
entity = Entity(name="Test", x=0, y=0)

View file

@ -1,5 +1,3 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.examine import cmd_examine
@ -10,19 +8,6 @@ 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)]

View file

@ -11,19 +11,6 @@ from mudlib.player import Player, players
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(100)] for _ in range(100)]

View file

@ -1,7 +1,5 @@
"""Tests for get and drop commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import _registry
@ -11,19 +9,6 @@ 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)]

View file

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

View file

@ -1,137 +0,0 @@
"""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,13 +1,17 @@
"""Tests for TOML help topic loading."""
import textwrap
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import MagicMock
import pytest
from mudlib import commands
from mudlib.commands import help as help_mod # noqa: F401
from mudlib.commands import helpadmin # noqa: F401
from mudlib.commands import (
helpadmin, # noqa: F401
look, # noqa: F401
movement, # noqa: F401
)
from mudlib.commands.help import _help_topics
from mudlib.content import HelpTopic, load_help_topics
@ -85,32 +89,31 @@ def test_load_help_topics_skips_bad_files(tmp_path):
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
def player(mock_reader, mock_writer):
from mudlib.player import Player
return Player(name="Tester", x=0, y=0, reader=mock_reader, writer=mock_writer)
@pytest.fixture
def player(mock_writer):
def admin_player(mock_reader, mock_writer):
from mudlib.player import Player
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 = Player(name="Admin", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.is_admin = True
return p
@pytest.fixture(autouse=True)
def _clear_topics():
def _clear_and_load_topics():
"""Clear help topics, load content topics, then clear again after tests."""
from pathlib import Path
_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
_help_topics.clear()
@ -261,3 +264,82 @@ def test_at_help_toml_loads_from_content():
topics = load_help_topics(help_dir)
assert "@help" in topics
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."""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import patch
import pytest
@ -17,14 +17,6 @@ def clear_state():
players.clear()
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def player(mock_writer):
return Player(name="Test", writer=mock_writer)
@ -37,7 +29,7 @@ def mock_move_kill_count():
name="roundhouse",
move_type="attack",
stamina_cost=30.0,
timing_window_ms=850,
hit_time_ms=850,
aliases=["rh"],
description="A powerful spinning kick",
damage_pct=0.35,
@ -52,7 +44,7 @@ def mock_move_mob_kills():
name="goblin slayer",
move_type="attack",
stamina_cost=25.0,
timing_window_ms=800,
hit_time_ms=800,
description="Specialized technique against goblins",
damage_pct=0.40,
unlock_condition=UnlockCondition(
@ -68,7 +60,7 @@ def mock_move_no_unlock():
name="jab",
move_type="attack",
stamina_cost=10.0,
timing_window_ms=600,
hit_time_ms=600,
description="A quick straight punch",
damage_pct=0.15,
)

View file

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

View file

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

View file

@ -9,19 +9,6 @@ from mudlib.player import Player, players
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)
def clear_players():
"""Clear players registry before and after each test."""

View file

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

View file

@ -1,7 +1,5 @@
"""Tests for inventory command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib import commands
@ -23,18 +21,6 @@ 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
def player(mock_reader, mock_writer, test_zone):
"""Create a test player."""

View file

@ -1,5 +1,3 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.container import Container
@ -8,19 +6,6 @@ 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)]

View file

@ -4,54 +4,25 @@ import time
import pytest
from mudlib.combat.engine import (
process_combat,
start_encounter,
)
from mudlib.combat.moves import CombatMove
from mudlib.combat.engine import start_encounter
from mudlib.commands.snapneck import cmd_snap_neck
from mudlib.entity import Mob
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
async def test_player_kills_mob_increments_stats(player, test_zone, punch_move):
"""Player kills mob -> kills incremented, mob_kills tracked."""
async def test_player_finishes_mob_increments_stats(player, test_zone):
"""Snap-neck kill increments kills and mob_kills."""
# Create a goblin mob
goblin = Mob(name="goblin", x=0, y=0)
goblin.location = test_zone
test_zone._contents.append(goblin)
# Start encounter
encounter = start_encounter(player, goblin)
# Execute attack
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()
# Start encounter and make target unconscious
start_encounter(player, goblin)
player.mode_stack.append("combat")
goblin.pl = 0.0
await cmd_snap_neck(player, "goblin")
# Verify stats
assert player.kills == 1
@ -59,50 +30,32 @@ async def test_player_kills_mob_increments_stats(player, test_zone, punch_move):
@pytest.mark.asyncio
async def test_player_killed_by_mob_increments_deaths(player, test_zone, punch_move):
"""Player killed by mob -> deaths incremented."""
# Create a goblin mob
goblin = Mob(name="goblin", x=0, y=0)
goblin.location = test_zone
test_zone._contents.append(goblin)
# 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()
async def test_player_finished_by_mob_increments_deaths(player, nearby_player):
"""Snap-neck finisher from opponent increments deaths."""
start_encounter(nearby_player, player)
nearby_player.mode_stack.append("combat")
player.mode_stack.append("combat")
player.pl = 0.0
await cmd_snap_neck(nearby_player, "Goku")
# Verify deaths incremented
assert player.deaths == 1
@pytest.mark.asyncio
async def test_multiple_kills_accumulate(player, test_zone, punch_move):
"""After killing 3 goblins, player.kills == 3, player.mob_kills["goblin"] == 3."""
async def test_multiple_finisher_kills_accumulate(player, test_zone):
"""After 3 finishers, kill counters accumulate correctly."""
for _ in range(3):
# Create goblin
goblin = Mob(name="goblin", x=0, y=0)
goblin.location = test_zone
test_zone._contents.append(goblin)
# Create and resolve encounter
encounter = start_encounter(player, goblin)
encounter.attack(punch_move)
encounter.tick(time.monotonic() + 0.31)
encounter.tick(time.monotonic() + 1.2)
goblin.pl = 1.0
await process_combat()
# Create encounter and finish
start_encounter(player, goblin)
player.mode_stack.append("combat")
goblin.pl = 0.0
await cmd_snap_neck(player, "goblin")
# Verify accumulated kills
assert player.kills == 3

View file

@ -26,19 +26,6 @@ def _reset_globals():
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
def test_zone():
"""Create a test zone with simple terrain."""
@ -98,7 +85,7 @@ async def test_look_includes_exits_line(player):
"""Look output should include 'Exits:' line."""
await cmd_look(player, "")
output = get_output(player)
assert "Exits: north south east west" in output
assert "Exits: north south east west up" in output
@pytest.mark.asyncio
@ -169,14 +156,36 @@ async def test_look_shows_portals(player, test_zone):
@pytest.mark.asyncio
async def test_look_with_args_routes_to_examine(player, test_zone):
"""look <thing> should route to examine command logic."""
# 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)
called = {}
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)
assert called["args"] == "sword"
assert called["prefer_inventory"] is False
assert "examined" in output
# Should see the item's description (examine behavior)
assert "A sharp blade." in output
@pytest.mark.asyncio
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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,5 @@
"""Tests for open and close commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import _registry
@ -11,19 +9,6 @@ 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)]

View file

@ -2,7 +2,6 @@
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -25,21 +24,6 @@ def temp_db():
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
async def test_quit_saves_player_state(temp_db, mock_reader, mock_writer):
"""Quit command saves player state before disconnecting."""

View file

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

View file

@ -1,25 +1,8 @@
"""Tests for player description and home_zone fields."""
from unittest.mock import AsyncMock, MagicMock
import pytest
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):
"""Test that Player has default empty description."""
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)

View file

@ -1,49 +1,77 @@
"""Tests for the Portal class."""
import pytest
from mudlib.object import Object
from mudlib.player import Player
from mudlib.portal import Portal
from mudlib.thing import Thing
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 ---
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():
"""Portal forces portable=False even if explicitly set True."""
# Even if we try to make it portable, it should be forced to False
@ -51,18 +79,6 @@ def test_portal_forced_non_portable():
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():
"""Portal can exist in a zone with coordinates."""
terrain = [["." for _ in range(10)] for _ in range(10)]
@ -94,3 +110,140 @@ def test_portal_rejects_things():
p = Portal(name="portal")
thing = Thing(name="sword")
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,7 +1,5 @@
"""Tests for auto-triggering portals on movement."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.player import Player
@ -10,19 +8,6 @@ from mudlib.zone import Zone
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
def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]

View file

@ -1,119 +0,0 @@
"""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,26 +1,11 @@
"""Tests for prefix matching in command dispatch."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib import commands
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
def player(mock_reader, mock_writer):
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",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["dodge left"],
)
@ -463,7 +463,7 @@ def test_combat_state_shows_state_when_in_combat():
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
hit_time_ms=800,
damage_pct=0.15,
countered_by=["dodge left"],
)
@ -473,7 +473,7 @@ def test_combat_state_shows_state_when_in_combat():
active_encounters.append(encounter)
result = render_prompt(player)
assert result == "[telegraph] > "
assert result == "[pending] > "
def test_terrain_variable_grass():

View file

@ -1,26 +1,11 @@
"""Tests for the prompt command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.prompt import cmd_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
def player(mock_reader, mock_writer):
from mudlib.player import Player

View file

@ -1,7 +1,5 @@
"""Tests for put and take-from commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import _registry
@ -11,19 +9,6 @@ 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)]

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