Compare commits
No commits in common. "f450952e04265e37db2d5185527e6a91668fd70e" and "4991c87104931f94313d1dc3f7be62f66b779a00" have entirely different histories.
f450952e04
...
4991c87104
118 changed files with 2977 additions and 4003 deletions
|
|
@ -1 +0,0 @@
|
||||||
@.claude/CLAUDE.md
|
|
||||||
|
|
@ -2,8 +2,7 @@ name = "dodge"
|
||||||
description = "a quick sidestep to evade incoming attacks"
|
description = "a quick sidestep to evade incoming attacks"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
active_ms = 800
|
timing_window_ms = 800
|
||||||
recovery_ms = 2700
|
|
||||||
|
|
||||||
[variants.left]
|
[variants.left]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ description = "crouch down to avoid high attacks, leaving you vulnerable to low
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
telegraph = ""
|
telegraph = ""
|
||||||
active_ms = 600
|
timing_window_ms = 700
|
||||||
recovery_ms = 2900
|
|
||||||
damage_pct = 0.0
|
damage_pct = 0.0
|
||||||
countered_by = []
|
countered_by = []
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ description = "leap upward to evade low attacks, exposing you to high strikes"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 4.0
|
stamina_cost = 4.0
|
||||||
telegraph = ""
|
telegraph = ""
|
||||||
active_ms = 600
|
timing_window_ms = 700
|
||||||
recovery_ms = 2900
|
|
||||||
damage_pct = 0.0
|
damage_pct = 0.0
|
||||||
countered_by = []
|
countered_by = []
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ name = "parry"
|
||||||
description = "deflect an attack with precise timing, redirecting force rather than absorbing it"
|
description = "deflect an attack with precise timing, redirecting force rather than absorbing it"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 4.0
|
stamina_cost = 4.0
|
||||||
active_ms = 400
|
timing_window_ms = 1200
|
||||||
recovery_ms = 3100
|
|
||||||
|
|
||||||
[variants.high]
|
[variants.high]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ name = "punch"
|
||||||
description = "a close-range strike with the fist, quick but predictable"
|
description = "a close-range strike with the fist, quick but predictable"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 3000
|
timing_window_ms = 1800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
[variants.left]
|
[variants.left]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ telegraph = "{attacker} shifts {his} weight back..."
|
||||||
announce = "{attacker} launch{es} a roundhouse kick at {defender}!"
|
announce = "{attacker} launch{es} a roundhouse kick at {defender}!"
|
||||||
resolve_hit = "{attacker}'s roundhouse slams into {defender}!"
|
resolve_hit = "{attacker}'s roundhouse slams into {defender}!"
|
||||||
resolve_miss = "{defender} counter{s} {attacker}'s roundhouse!"
|
resolve_miss = "{defender} counter{s} {attacker}'s roundhouse!"
|
||||||
hit_time_ms = 3000
|
timing_window_ms = 2000
|
||||||
damage_pct = 0.25
|
damage_pct = 0.25
|
||||||
countered_by = ["duck", "parry high", "parry low"]
|
countered_by = ["duck", "parry high", "parry low"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ telegraph = "{attacker} drops low..."
|
||||||
announce = "{attacker} sweep{s} at {defender}'s legs!"
|
announce = "{attacker} sweep{s} at {defender}'s legs!"
|
||||||
resolve_hit = "{attacker}'s sweep catches {defender}'s legs!"
|
resolve_hit = "{attacker}'s sweep catches {defender}'s legs!"
|
||||||
resolve_miss = "{defender} jump{s} over {attacker}'s sweep!"
|
resolve_miss = "{defender} jump{s} over {attacker}'s sweep!"
|
||||||
hit_time_ms = 3000
|
timing_window_ms = 1800
|
||||||
damage_pct = 0.18
|
damage_pct = 0.18
|
||||||
countered_by = ["jump", "parry low"]
|
countered_by = ["jump", "parry low"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
name = "admin-commands"
|
|
||||||
title = "admin command reference"
|
|
||||||
admin = true
|
|
||||||
body = """
|
|
||||||
quick reference for admin @ commands.
|
|
||||||
|
|
||||||
world building
|
|
||||||
@zones list all zones
|
|
||||||
@goto <zone> teleport to zone spawn point
|
|
||||||
@dig <name> <w> <h> create new zone
|
|
||||||
@paint toggle terrain paint mode
|
|
||||||
@save save current zone to file
|
|
||||||
@place <thing> place a thing at your position
|
|
||||||
|
|
||||||
spawning
|
|
||||||
spawn <mob> spawn a mob at your tile
|
|
||||||
|
|
||||||
content management
|
|
||||||
reload <name> hot-reload TOML definition
|
|
||||||
edit <move> edit combat move in-game
|
|
||||||
|
|
||||||
help topics
|
|
||||||
@help list all help topics
|
|
||||||
@help create create new help topic
|
|
||||||
@help edit <topic> edit existing topic
|
|
||||||
@help remove <topic> remove topic
|
|
||||||
|
|
||||||
player management
|
|
||||||
@promote <player> grant admin status
|
|
||||||
@demote <player> revoke admin status
|
|
||||||
|
|
||||||
paint mode (after @paint)
|
|
||||||
p toggle painting on/off
|
|
||||||
brush <char> set brush character
|
|
||||||
movement paint or survey while moving
|
|
||||||
|
|
||||||
other useful commands
|
|
||||||
power up/down/<num> manage power level
|
|
||||||
snapneck <target> instant kill unconscious target
|
|
||||||
|
|
||||||
see also: help building, help spawning, help editing, help content
|
|
||||||
"""
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
name = "building"
|
|
||||||
title = "building zones"
|
|
||||||
admin = true
|
|
||||||
body = """
|
|
||||||
zones are spatial containers with terrain grids. this is the full workflow
|
|
||||||
for creating and editing zones.
|
|
||||||
|
|
||||||
creating zones
|
|
||||||
@dig <name> <w> <h> create a blank zone and teleport there
|
|
||||||
example: @dig castle 20 15
|
|
||||||
|
|
||||||
editing terrain
|
|
||||||
@paint toggle paint mode on/off
|
|
||||||
p toggle painting (while in paint mode)
|
|
||||||
brush <char> set the brush character
|
|
||||||
movement paint or survey as you move
|
|
||||||
|
|
||||||
paint mode lets you edit terrain tile-by-tile. after entering paint mode:
|
|
||||||
- move around to survey
|
|
||||||
- set a brush character (. for grass, # for wall, etc)
|
|
||||||
- press 'p' to start painting
|
|
||||||
- move to paint tiles
|
|
||||||
- press 'p' again to stop painting
|
|
||||||
|
|
||||||
placing objects
|
|
||||||
@place <thing> place a thing at your position
|
|
||||||
available things: bookshelf, chair, chest, fountain, lamp, nail,
|
|
||||||
painting, plank, rock, rug, sack, table
|
|
||||||
|
|
||||||
persisting changes
|
|
||||||
@save save current zone to content/zones/<name>.toml
|
|
||||||
|
|
||||||
navigating
|
|
||||||
@zones list all zones
|
|
||||||
@goto <zone> teleport to a zone's spawn point
|
|
||||||
|
|
||||||
zones are saved as TOML files in content/zones/ and persist across restarts.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
name = "combat"
|
|
||||||
title = "combat system"
|
|
||||||
body = """
|
|
||||||
starting a fight
|
|
||||||
use any attack move on a target to start combat. you need to be on the same
|
|
||||||
tile and altitude (both flying or both grounded). example: punch left goblin
|
|
||||||
|
|
||||||
stamina
|
|
||||||
all moves cost stamina. attacks and defenses both drain your stamina pool.
|
|
||||||
when you run out, you can't use moves until it regenerates. stamina refills
|
|
||||||
slowly over time.
|
|
||||||
|
|
||||||
combat timing
|
|
||||||
attacks have three phases:
|
|
||||||
telegraph (enemy sees your wind-up message)
|
|
||||||
timing window (defender has time to counter)
|
|
||||||
resolve (damage is dealt or countered)
|
|
||||||
|
|
||||||
telegraph time is built into the hit_time. for example, a punch has a
|
|
||||||
3000ms hit time - the telegraph goes out immediately, then after 3000ms
|
|
||||||
the attack resolves.
|
|
||||||
|
|
||||||
defenses and counters
|
|
||||||
each attack can be countered by specific defenses. timing matters - your
|
|
||||||
defense must be active when the attack resolves. defenses have:
|
|
||||||
active window (how long the defense can counter attacks)
|
|
||||||
recovery time (cooldown before you can defend again)
|
|
||||||
|
|
||||||
correct defense (counter) = 0 damage. wrong defense = normal damage.
|
|
||||||
no defense at all = 1.5x damage.
|
|
||||||
|
|
||||||
attack switching
|
|
||||||
during the telegraph phase (PENDING state), you can switch to a different
|
|
||||||
attack. your old stamina cost is refunded and the new attack starts fresh.
|
|
||||||
use this to adapt if your opponent reads your move.
|
|
||||||
|
|
||||||
idle timeout
|
|
||||||
if no damage lands for 30 seconds, combat fizzles out automatically.
|
|
||||||
this prevents stalled encounters from blocking the world.
|
|
||||||
|
|
||||||
available moves
|
|
||||||
attacks: punch (left/right), roundhouse, sweep
|
|
||||||
defenses: dodge (left/right), parry (high/low), duck, jump
|
|
||||||
|
|
||||||
use 'skills' to see all moves, 'help <move>' for details on timing and
|
|
||||||
counters. some moves are locked until you meet requirements.
|
|
||||||
|
|
||||||
altitude and flying
|
|
||||||
flying makes you immune to ground-based attacks and vice versa. use this
|
|
||||||
tactically - 'fly' to take off, 'fly' again to land. attacks auto-miss
|
|
||||||
when attacker and defender are at different altitudes.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
name = "containers"
|
|
||||||
title = "inventory and containers"
|
|
||||||
body = """
|
|
||||||
pick up items, carry them in your inventory, and store them
|
|
||||||
in containers like chests and bags.
|
|
||||||
|
|
||||||
basic commands
|
|
||||||
get <item> pick up an item from the ground
|
|
||||||
take <item> same as get
|
|
||||||
drop <item> drop an item at your feet
|
|
||||||
inventory list what you're carrying
|
|
||||||
i short for inventory
|
|
||||||
|
|
||||||
containers
|
|
||||||
containers are items that hold other items.
|
|
||||||
they can be open or closed, and some can be locked.
|
|
||||||
|
|
||||||
container commands
|
|
||||||
open <container> open a container
|
|
||||||
close <container> close a container
|
|
||||||
put <item> in <container> store an item in a container
|
|
||||||
get <item> from <container> take an item from a container
|
|
||||||
get all from <container> take everything from a container
|
|
||||||
|
|
||||||
where to find containers
|
|
||||||
containers can be on the ground or in your inventory.
|
|
||||||
put commands work with either location.
|
|
||||||
closed containers block access to their contents.
|
|
||||||
|
|
||||||
targeting
|
|
||||||
you can use partial names - 'get pla' matches 'plank'.
|
|
||||||
ordinals work too - 'get 2nd sword' if there are multiple.
|
|
||||||
container state shows in inventory - (open, empty) or (closed).
|
|
||||||
"""
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
name = "content"
|
|
||||||
title = "content system"
|
|
||||||
admin = true
|
|
||||||
body = """
|
|
||||||
the mud loads content from TOML files in the content/ directory.
|
|
||||||
content can be edited and hot-reloaded without restarting the server.
|
|
||||||
|
|
||||||
directory structure
|
|
||||||
content/
|
|
||||||
commands/ content commands (motd, etc)
|
|
||||||
combat/ combat move definitions
|
|
||||||
help/ help topics
|
|
||||||
mobs/ mob templates
|
|
||||||
things/ thing templates
|
|
||||||
zones/ zone definitions
|
|
||||||
recipes/ crafting recipes
|
|
||||||
dialogue/ npc dialogue trees
|
|
||||||
|
|
||||||
hot-reloading
|
|
||||||
reload <name> reload a TOML definition
|
|
||||||
example: reload punch
|
|
||||||
example: reload motd
|
|
||||||
|
|
||||||
reload works for:
|
|
||||||
- combat moves (content/combat/)
|
|
||||||
- content commands (content/commands/)
|
|
||||||
|
|
||||||
creating new content
|
|
||||||
- write a .toml file in the appropriate directory
|
|
||||||
- restart server to load it initially
|
|
||||||
- use reload to test changes after editing
|
|
||||||
|
|
||||||
editing content
|
|
||||||
edit <move> edit in-game (see 'help editing')
|
|
||||||
or edit files directly with your text editor
|
|
||||||
|
|
||||||
content format
|
|
||||||
all content files use TOML format. combat moves follow a specific
|
|
||||||
schema defined in docs/how/combat.rst. content commands use a simpler
|
|
||||||
format with name, handler/message, and optional fields.
|
|
||||||
|
|
||||||
see also: help editing, help admin-commands
|
|
||||||
"""
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
name = "crafting"
|
|
||||||
title = "crafting items from recipes"
|
|
||||||
body = """
|
|
||||||
combine ingredients to create new items using recipes.
|
|
||||||
|
|
||||||
commands
|
|
||||||
craft <recipe> craft an item if you have the ingredients
|
|
||||||
recipes list all available recipes
|
|
||||||
recipes <name> view details for a specific recipe
|
|
||||||
|
|
||||||
how it works
|
|
||||||
recipes define what ingredients are needed and what you get.
|
|
||||||
you need all ingredients in your inventory to craft.
|
|
||||||
ingredients are consumed when you craft.
|
|
||||||
|
|
||||||
example
|
|
||||||
wooden table requires 3 planks and 2 nails.
|
|
||||||
carrying those items? type 'craft wooden table'.
|
|
||||||
the ingredients disappear and you get a table.
|
|
||||||
|
|
||||||
finding recipes
|
|
||||||
use 'recipes' to see what you can make.
|
|
||||||
use 'recipes <name>' to see what ingredients you need.
|
|
||||||
prefix matching works - 'craft wood' matches 'wooden table'.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
name = "editing"
|
|
||||||
title = "in-game toml editor"
|
|
||||||
admin = true
|
|
||||||
body = """
|
|
||||||
edit command opens the in-game text editor for TOML content files.
|
|
||||||
|
|
||||||
usage
|
|
||||||
edit blank editor
|
|
||||||
edit <move> edit a combat move TOML
|
|
||||||
example: edit punch
|
|
||||||
|
|
||||||
the editor supports:
|
|
||||||
- syntax highlighting for TOML
|
|
||||||
- search and replace
|
|
||||||
- undo/redo
|
|
||||||
- color depth detection (256 color or 16 color)
|
|
||||||
|
|
||||||
editor commands (in editor mode)
|
|
||||||
:h show editor help
|
|
||||||
:w save and continue editing
|
|
||||||
:wq save and exit
|
|
||||||
:q quit without saving
|
|
||||||
|
|
||||||
editing combat moves
|
|
||||||
when you edit a combat move, the editor loads the TOML file from
|
|
||||||
content/combat/<move>.toml. saves write directly to the file.
|
|
||||||
|
|
||||||
after editing, use 'reload <move>' to hot-reload the changes without
|
|
||||||
restarting the server.
|
|
||||||
|
|
||||||
see also: help content, help admin-commands
|
|
||||||
"""
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
name = "getting-started"
|
|
||||||
title = "getting started"
|
|
||||||
body = """
|
|
||||||
welcome to the mud. here's how to play.
|
|
||||||
|
|
||||||
looking around
|
|
||||||
look see the world around you
|
|
||||||
look <thing> examine something in detail
|
|
||||||
|
|
||||||
moving
|
|
||||||
north / n move north (also: south, east, west)
|
|
||||||
northwest / nw diagonal movement (also: ne, sw, se)
|
|
||||||
|
|
||||||
finding commands
|
|
||||||
commands list all available commands
|
|
||||||
skills list combat moves
|
|
||||||
help <command> get help on a specific command
|
|
||||||
|
|
||||||
basics
|
|
||||||
say <message> talk to nearby players
|
|
||||||
tell <name> <msg> private message a player
|
|
||||||
inventory see what you're carrying
|
|
||||||
get <thing> pick something up
|
|
||||||
drop <thing> put something down
|
|
||||||
|
|
||||||
the world persists. your position and inventory save when you log out.
|
|
||||||
type 'quit' to disconnect safely.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
name = "interactive-fiction"
|
|
||||||
title = "interactive fiction"
|
|
||||||
body = """
|
|
||||||
you can play classic text adventure games from inside the mud.
|
|
||||||
other players in the room will see your gameplay on a virtual terminal.
|
|
||||||
|
|
||||||
starting a game
|
|
||||||
play list available games
|
|
||||||
play <game> start a game (e.g. "play zork")
|
|
||||||
|
|
||||||
available games
|
|
||||||
zork1 the great underground empire
|
|
||||||
curses a time-travel puzzle adventure
|
|
||||||
photopia a story about light and memory
|
|
||||||
shade a one-room mystery
|
|
||||||
anchor a nautical puzzle game
|
|
||||||
tangle a nature-themed exploration
|
|
||||||
lostpig find the pig
|
|
||||||
|
|
||||||
escape commands
|
|
||||||
::quit exit the game (auto-saves first)
|
|
||||||
::save force save to disk
|
|
||||||
::help show escape commands
|
|
||||||
|
|
||||||
your progress saves automatically. when you return to a game,
|
|
||||||
it restores from where you left off.
|
|
||||||
|
|
||||||
game commands use normal IF syntax (open mailbox, go north, etc).
|
|
||||||
anything not starting with :: goes to the game interpreter.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
name = "mobs"
|
|
||||||
title = "creatures and NPCs"
|
|
||||||
body = """
|
|
||||||
mobs are creatures that move around zones. some are friendly NPCs,
|
|
||||||
others are hostile and will fight you.
|
|
||||||
|
|
||||||
types
|
|
||||||
hostile mobs attack on sight or when provoked
|
|
||||||
friendly NPCs have schedules, can be talked to
|
|
||||||
training dummies stationary targets for practicing combat
|
|
||||||
|
|
||||||
interacting with NPCs
|
|
||||||
some mobs have an npc_name and follow daily schedules.
|
|
||||||
the librarian, for example, works during the day and rests at night.
|
|
||||||
approach them when they're active to start conversations.
|
|
||||||
|
|
||||||
combat
|
|
||||||
hostile mobs will engage you in combat when you're nearby.
|
|
||||||
see 'help combat' for how to fight.
|
|
||||||
defeating mobs may drop loot - check corpses after battle.
|
|
||||||
|
|
||||||
behavior
|
|
||||||
mobs can wander, patrol routes, flee when threatened, or stay put.
|
|
||||||
friendly NPCs transition between states based on their schedule.
|
|
||||||
some mobs stay within a home region and won't chase you far.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
name = "movement"
|
|
||||||
title = "movement and navigation"
|
|
||||||
body = """
|
|
||||||
basic directions
|
|
||||||
move with cardinal and diagonal directions:
|
|
||||||
n, s, e, w (or north, south, east, west)
|
|
||||||
ne, nw, se, sw (northeast, northwest, southeast, southwest)
|
|
||||||
|
|
||||||
terrain
|
|
||||||
different tiles have different properties. some are passable, some aren't.
|
|
||||||
impassable terrain (like water ~ or mountains ^) blocks movement unless
|
|
||||||
you have special abilities.
|
|
||||||
|
|
||||||
zones
|
|
||||||
the world is divided into zones - spatial areas with their own terrain grids.
|
|
||||||
most zones are toroidal, meaning if you walk off the east edge you wrap to
|
|
||||||
the west edge (same for north/south). this makes zones feel continuous.
|
|
||||||
|
|
||||||
portals
|
|
||||||
some tiles have portals that transport you to other zones. to use a portal,
|
|
||||||
type 'enter <portal>' when standing on the portal tile. portals are one-way
|
|
||||||
unless there's a return portal at the destination.
|
|
||||||
|
|
||||||
flying
|
|
||||||
'fly' toggles flight mode. while flying:
|
|
||||||
- you can move over impassable terrain like water and mountains
|
|
||||||
- 'fly <direction>' moves you 5 tiles in that direction
|
|
||||||
- you leave cloud trails that fade over time
|
|
||||||
- you're immune to ground-based attacks (altitude matters in combat)
|
|
||||||
- 'fly' again to land
|
|
||||||
|
|
||||||
home command
|
|
||||||
'home' teleports you to your personal zone (a private space just for you).
|
|
||||||
'home return' takes you back to where you were before going home.
|
|
||||||
your return point is saved when you leave, so you can bounce back and forth.
|
|
||||||
|
|
||||||
looking around
|
|
||||||
'look' shows your current viewport and what's nearby.
|
|
||||||
the viewport centers on you and shows terrain, other players, mobs, and items.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
name = "skills"
|
|
||||||
title = "combat skills"
|
|
||||||
body = """
|
|
||||||
what are skills?
|
|
||||||
skills are your combat moves - both attacks and defenses. each skill has
|
|
||||||
different timing, stamina cost, and tactical use. you unlock more skills
|
|
||||||
by defeating enemies and completing challenges.
|
|
||||||
|
|
||||||
seeing your skills
|
|
||||||
'skills' lists all available combat moves (attacks and defenses).
|
|
||||||
'help <skill>' shows detailed info: stamina cost, timing, counters.
|
|
||||||
|
|
||||||
attack skills
|
|
||||||
punch left/right quick jab, 5 stamina, 3000ms hit time, 15% damage
|
|
||||||
roundhouse heavy kick, 8 stamina, 3000ms hit time, 25% damage
|
|
||||||
sweep low kick, 6 stamina, 3000ms hit time, 18% damage
|
|
||||||
|
|
||||||
attacks always need a target. start combat with: punch left goblin
|
|
||||||
|
|
||||||
defense skills
|
|
||||||
dodge left/right sidestep, 3 stamina, 800ms window, 2700ms recovery
|
|
||||||
parry high/low deflection, 4 stamina, 400ms window, 3100ms recovery
|
|
||||||
duck crouch, 3 stamina, 600ms window, 2900ms recovery
|
|
||||||
jump leap, 4 stamina, 600ms window, 2900ms recovery
|
|
||||||
|
|
||||||
defenses work both in and out of combat. you can practice them anytime.
|
|
||||||
|
|
||||||
stamina costs
|
|
||||||
every skill drains stamina. attacks and defenses both cost stamina when used.
|
|
||||||
stamina regenerates slowly over time. if you run out, you can't use skills
|
|
||||||
until it refills. manage your stamina carefully in long fights.
|
|
||||||
|
|
||||||
locked skills
|
|
||||||
some skills require you to meet conditions before you can use them:
|
|
||||||
roundhouse: defeat 5 enemies
|
|
||||||
sweep: defeat 3 goblins
|
|
||||||
|
|
||||||
locked skills show [LOCKED] in their help text with unlock requirements.
|
|
||||||
|
|
||||||
learning more
|
|
||||||
'help combat' explains how timing, counters, and combat flow works.
|
|
||||||
'help <move>' shows specific details for any attack or defense.
|
|
||||||
experiment with different combinations to find what works for you.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
name = "spawning"
|
|
||||||
title = "spawning mobs and things"
|
|
||||||
admin = true
|
|
||||||
body = """
|
|
||||||
spawn command creates mobs at your current position.
|
|
||||||
|
|
||||||
usage
|
|
||||||
spawn <mob_type> spawn a mob at your tile
|
|
||||||
|
|
||||||
available mobs
|
|
||||||
goblin hostile creature
|
|
||||||
librarian friendly npc with dialogue
|
|
||||||
training_dummy practice target
|
|
||||||
|
|
||||||
spawned mobs appear on your current tile and persist until killed or
|
|
||||||
despawned. mobs with loot tables drop corpses when killed.
|
|
||||||
|
|
||||||
placing things
|
|
||||||
@place <thing> place a thing (see 'help building')
|
|
||||||
|
|
||||||
available things
|
|
||||||
bookshelf, chair, chest, fountain, lamp, nail, painting, plank,
|
|
||||||
rock, rug, sack, table
|
|
||||||
|
|
||||||
things are static objects. some are containers (chest, sack), others
|
|
||||||
are furniture or decorations.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
name = "world"
|
|
||||||
title = "zones, terrain, and navigation"
|
|
||||||
body = """
|
|
||||||
the world is made of zones - spatial areas with terrain grids.
|
|
||||||
zones can be toroidal (wrapping at edges) or bounded.
|
|
||||||
|
|
||||||
terrain types
|
|
||||||
each zone has a grid of terrain tiles with different properties.
|
|
||||||
some tiles are impassable (mountains ^, water ~).
|
|
||||||
the overworld is procedurally generated and wraps seamlessly.
|
|
||||||
|
|
||||||
zones
|
|
||||||
zones are separate areas - the overworld, dungeons, interiors.
|
|
||||||
each zone has a spawn point where you appear when entering.
|
|
||||||
zones can contain mobs, items, portals, and players.
|
|
||||||
|
|
||||||
navigation
|
|
||||||
move with cardinal directions: north, south, east, west.
|
|
||||||
use 'look' to see terrain and objects around you.
|
|
||||||
toroidal zones wrap - walk far enough and you loop back.
|
|
||||||
|
|
||||||
portals
|
|
||||||
portals connect zones (doorways, stairs, gates).
|
|
||||||
walk onto a portal tile and type 'enter <portal>'.
|
|
||||||
you'll teleport to the target zone at specific coordinates.
|
|
||||||
|
|
||||||
player homes
|
|
||||||
type 'home' to teleport to your personal zone.
|
|
||||||
this is a private space you can furnish and customize.
|
|
||||||
type 'home return' to go back to where you were.
|
|
||||||
"""
|
|
||||||
|
|
@ -241,7 +241,7 @@ name = "punch"
|
||||||
description = "a close-range strike with the fist, quick but predictable"
|
description = "a close-range strike with the fist, quick but predictable"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 1800
|
timing_window_ms = 1800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
[variants.left]
|
[variants.left]
|
||||||
|
|
@ -266,8 +266,7 @@ name = "dodge"
|
||||||
description = "a quick sidestep to evade incoming attacks"
|
description = "a quick sidestep to evade incoming attacks"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
active_ms = 800
|
timing_window_ms = 800
|
||||||
recovery_ms = 2700
|
|
||||||
|
|
||||||
[variants.left]
|
[variants.left]
|
||||||
|
|
||||||
|
|
@ -280,9 +279,7 @@ recovery_ms = 2700
|
||||||
- `description` - shown in help/skills
|
- `description` - shown in help/skills
|
||||||
- `move_type` - "attack" or "defense"
|
- `move_type` - "attack" or "defense"
|
||||||
- `stamina_cost` - stamina consumed per use
|
- `stamina_cost` - stamina consumed per use
|
||||||
- `hit_time_ms` - (attacks) time in ms from initiation to impact
|
- `timing_window_ms` - how long the window is open (attacks: time to defend, defenses: commitment time)
|
||||||
- `active_ms` - (defenses) how long defense blocks once activated, in ms
|
|
||||||
- `recovery_ms` - (defenses) lockout after active window ends, in ms
|
|
||||||
- `damage_pct` - fraction of attacker's PL dealt as damage (attacks only)
|
- `damage_pct` - fraction of attacker's PL dealt as damage (attacks only)
|
||||||
- `[variants.X]` - each variant becomes a separate command: "punch left", "punch right"
|
- `[variants.X]` - each variant becomes a separate command: "punch left", "punch right"
|
||||||
- POV templates: `{attacker}`, `{defender}`, `{s}` (third person s), `{es}`, `{his}`, `{him}`, `{y|ies}` (irregular conjugation)
|
- POV templates: `{attacker}`, `{defender}`, `{s}` (third person s), `{es}`, `{his}`, `{him}`, `{y|ies}` (irregular conjugation)
|
||||||
|
|
|
||||||
|
|
@ -50,21 +50,29 @@ the state machine
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
IDLE → PENDING → RESOLVE → IDLE
|
IDLE → TELEGRAPH → WINDOW → RESOLVE → IDLE
|
||||||
|
|
||||||
IDLE:
|
IDLE:
|
||||||
no active move. attacker can initiate attack. defender can do nothing
|
no active move. attacker can initiate attack. defender can do nothing
|
||||||
combat-specific.
|
combat-specific.
|
||||||
|
|
||||||
PENDING:
|
TELEGRAPH:
|
||||||
attacker has declared a move. telegraph message sent to defender.
|
attacker has declared a move. defender sees the telegraph message.
|
||||||
the move is now in flight — defender can queue a defense any time
|
"goku winds up a right hook!"
|
||||||
during this phase. duration is the attack's hit_time_ms.
|
defender can queue a defense during this phase.
|
||||||
|
duration: brief (implementation decides, not move-defined).
|
||||||
|
|
||||||
|
WINDOW:
|
||||||
|
the timing window opens. defender can still queue defense.
|
||||||
|
if defender queued correct counter during TELEGRAPH or WINDOW, they succeed.
|
||||||
|
duration: move.timing_window_ms (defined in TOML).
|
||||||
|
|
||||||
RESOLVE:
|
RESOLVE:
|
||||||
hit_time_ms elapsed. check if defender's active defense counters the
|
timing window closes. check if defense counters attack.
|
||||||
attack. calculate damage, apply stamina costs, check for knockouts
|
calculate damage based on attacker PL, damage_pct, and defense success.
|
||||||
or exhaustion. return to IDLE.
|
apply stamina costs.
|
||||||
|
check for knockouts (PL = 0) or exhaustion (stamina = 0).
|
||||||
|
return to IDLE.
|
||||||
|
|
||||||
|
|
||||||
entity stats
|
entity stats
|
||||||
|
|
@ -197,7 +205,7 @@ moves live in content/combat/. two formats: simple and variant.
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 8.0
|
stamina_cost = 8.0
|
||||||
telegraph = "{attacker} spins into a roundhouse kick!"
|
telegraph = "{attacker} spins into a roundhouse kick!"
|
||||||
hit_time_ms = 600
|
timing_window_ms = 600
|
||||||
damage_pct = 0.25
|
damage_pct = 0.25
|
||||||
countered_by = ["duck", "parry high", "parry low"]
|
countered_by = ["duck", "parry high", "parry low"]
|
||||||
|
|
||||||
|
|
@ -207,7 +215,7 @@ moves live in content/combat/. two formats: simple and variant.
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 800
|
timing_window_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
[variants.left]
|
[variants.left]
|
||||||
|
|
@ -220,7 +228,7 @@ moves live in content/combat/. two formats: simple and variant.
|
||||||
telegraph = "{attacker} winds up a right hook!"
|
telegraph = "{attacker} winds up a right hook!"
|
||||||
countered_by = ["dodge left", "parry high"]
|
countered_by = ["dodge left", "parry high"]
|
||||||
|
|
||||||
shared properties (stamina_cost, hit_time_ms, damage_pct) are defined at
|
shared properties (stamina_cost, timing_window_ms, damage_pct) are defined at
|
||||||
the top level. variants inherit these and can override them. variant-specific
|
the top level. variants inherit these and can override them. variant-specific
|
||||||
properties (telegraph, countered_by, aliases) live under [variants.<key>].
|
properties (telegraph, countered_by, aliases) live under [variants.<key>].
|
||||||
|
|
||||||
|
|
@ -228,28 +236,13 @@ each variant produces a CombatMove with a qualified name like "punch left".
|
||||||
the ``command`` field tracks the base command ("punch") and ``variant`` tracks
|
the ``command`` field tracks the base command ("punch") and ``variant`` tracks
|
||||||
the key ("left"). simple moves have command=name and variant="".
|
the key ("left"). simple moves have command=name and variant="".
|
||||||
|
|
||||||
**defense move** (directional)::
|
|
||||||
|
|
||||||
# content/combat/dodge.toml
|
|
||||||
name = "dodge"
|
|
||||||
move_type = "defense"
|
|
||||||
stamina_cost = 3.0
|
|
||||||
active_ms = 800
|
|
||||||
recovery_ms = 2700
|
|
||||||
|
|
||||||
[variants.left]
|
|
||||||
|
|
||||||
[variants.right]
|
|
||||||
|
|
||||||
TOML field reference:
|
TOML field reference:
|
||||||
|
|
||||||
- name: the command name (simple) or base command (variant)
|
- name: the command name (simple) or base command (variant)
|
||||||
- move_type: "attack" or "defense"
|
- move_type: "attack" or "defense"
|
||||||
- stamina_cost: stamina consumed when using this move
|
- stamina_cost: stamina consumed when using this move
|
||||||
- hit_time_ms: (attacks) time in ms from initiation to impact
|
- timing_window_ms: how long defender has to respond
|
||||||
- active_ms: (defenses) how long defense blocks once activated, in ms
|
- telegraph: shown to defender during TELEGRAPH phase. {attacker} replaced
|
||||||
- recovery_ms: (defenses) lockout after active window ends, in ms
|
|
||||||
- telegraph: shown to defender during PENDING phase. {attacker} replaced
|
|
||||||
- damage_pct: fraction of attacker's PL dealt as damage
|
- damage_pct: fraction of attacker's PL dealt as damage
|
||||||
- countered_by: list of qualified move names that counter this move
|
- countered_by: list of qualified move names that counter this move
|
||||||
- aliases: short command aliases registered as standalone commands
|
- aliases: short command aliases registered as standalone commands
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
=============================
|
|
||||||
content loading pipeline
|
|
||||||
=============================
|
|
||||||
|
|
||||||
the content pipeline converts static TOML definitions into runtime Python objects
|
|
||||||
at startup. all loading happens once before accepting player connections — no hot-reload.
|
|
||||||
|
|
||||||
startup sequence
|
|
||||||
================
|
|
||||||
|
|
||||||
from ``server.py run_server()``, in order::
|
|
||||||
|
|
||||||
1. init_db() → database
|
|
||||||
2. init_game_time() → game clock
|
|
||||||
3. World() + terrain → procedural terrain
|
|
||||||
4. Zone (overworld) → overworld from terrain
|
|
||||||
5. load_zones() → content zones (hub, tavern, etc) from content/zones/
|
|
||||||
6. load_commands() → TOML commands from content/commands/
|
|
||||||
7. load_help_topics() → help topics from content/help/
|
|
||||||
8. register_combat_commands() → combat moves from content/combat/
|
|
||||||
9. load_mob_templates() → mob definitions from content/mobs/
|
|
||||||
10. load_thing_templates() → thing/container definitions from content/things/
|
|
||||||
11. load_recipes() → crafting recipes from content/recipes/
|
|
||||||
12. load_all_dialogues() → NPC dialogue trees from content/dialogue/
|
|
||||||
|
|
||||||
common loader pattern
|
|
||||||
=====================
|
|
||||||
|
|
||||||
every content type follows the same pattern::
|
|
||||||
|
|
||||||
1. load_X(path) → parse single TOML file into dataclass
|
|
||||||
2. load_Xs(directory) → iterate .toml files, call load_X each, return dict
|
|
||||||
3. At startup: global_registry.update(load_Xs(dir))
|
|
||||||
|
|
||||||
the loader functions are synchronous. they read files, parse TOML, validate data,
|
|
||||||
and populate module-level registries. errors during load are logged but don't
|
|
||||||
crash the server (graceful degradation).
|
|
||||||
|
|
||||||
content directory structure
|
|
||||||
===========================
|
|
||||||
|
|
||||||
all content lives under ``content/``::
|
|
||||||
|
|
||||||
content/commands/ → command definitions (name, aliases, handler or message)
|
|
||||||
content/combat/ → combat moves (attacks, defenses, variants)
|
|
||||||
content/things/ → thing/container templates
|
|
||||||
content/mobs/ → mob templates with loot and schedules
|
|
||||||
content/zones/ → zone definitions (terrain grids, portals, spawns, ambient)
|
|
||||||
content/recipes/ → crafting recipes
|
|
||||||
content/dialogue/ → NPC dialogue trees
|
|
||||||
content/help/ → help topic files
|
|
||||||
content/stories/ → z-machine game files (not TOML, loaded separately)
|
|
||||||
|
|
||||||
each subdirectory contains multiple ``.toml`` files. the loader iterates them
|
|
||||||
all and merges results into a single registry.
|
|
||||||
|
|
||||||
global registries
|
|
||||||
=================
|
|
||||||
|
|
||||||
module-level dicts, populated at startup, read-only during play::
|
|
||||||
|
|
||||||
commands/__init__.py: _registry dict
|
|
||||||
combat/commands.py: combat_moves dict
|
|
||||||
things.py: thing_templates dict
|
|
||||||
mobs.py: mob_templates dict, mobs list
|
|
||||||
zones.py: zone_registry dict
|
|
||||||
crafting.py: recipes dict
|
|
||||||
commands/help.py: _help_topics dict
|
|
||||||
commands/talk.py: dialogue_trees dict
|
|
||||||
|
|
||||||
these dicts are never mutated after startup. new content requires a server restart.
|
|
||||||
|
|
||||||
handler resolution
|
|
||||||
==================
|
|
||||||
|
|
||||||
commands and thing verbs can reference Python functions via ``"module:function"``
|
|
||||||
strings. at load time, the loader uses ``importlib`` to resolve the string into
|
|
||||||
a callable.
|
|
||||||
|
|
||||||
commands can also have inline message text instead of a handler. in that case,
|
|
||||||
the loader wraps the message in a simple async handler that sends the text.
|
|
||||||
|
|
||||||
if handler resolution fails, the loader logs a warning and continues. the command
|
|
||||||
is registered but won't work (graceful degradation).
|
|
||||||
|
|
||||||
template vs instance
|
|
||||||
====================
|
|
||||||
|
|
||||||
templates are immutable definitions stored as dataclasses. spawning creates mutable
|
|
||||||
runtime objects (``Thing``, ``Mob``, etc).
|
|
||||||
|
|
||||||
templates stay in the registry forever. instances are created and destroyed during
|
|
||||||
play. when a mob dies, the template remains — you can spawn another.
|
|
||||||
|
|
||||||
this separation keeps content definitions clean and allows multiple instances of
|
|
||||||
the same template (ten wolves from one ``wolf`` template).
|
|
||||||
|
|
||||||
validation
|
|
||||||
==========
|
|
||||||
|
|
||||||
different content types validate at different levels:
|
|
||||||
|
|
||||||
dialogue trees
|
|
||||||
--------------
|
|
||||||
|
|
||||||
validate all node references at load time. if a choice points to a nonexistent node,
|
|
||||||
``load_dialogue()`` raises ``ValueError`` and the server won't start.
|
|
||||||
|
|
||||||
combat moves
|
|
||||||
------------
|
|
||||||
|
|
||||||
validate ``countered_by`` references. if a move references a nonexistent counter,
|
|
||||||
the loader logs a warning but continues. the move is still usable, just without
|
|
||||||
that counter.
|
|
||||||
|
|
||||||
zone portals
|
|
||||||
------------
|
|
||||||
|
|
||||||
parse ``"zone_name:x,y"`` target format at load time. if the format is invalid,
|
|
||||||
the portal won't work. if the zone doesn't exist yet (load order), it's fine —
|
|
||||||
the portal stores the string and runtime lookup happens when someone uses it.
|
|
||||||
|
|
||||||
code
|
|
||||||
====
|
|
||||||
|
|
||||||
startup sequence::
|
|
||||||
|
|
||||||
src/mudlib/server.py (run_server function)
|
|
||||||
|
|
||||||
loaders::
|
|
||||||
|
|
||||||
src/mudlib/commands/__init__.py (load_command, load_commands)
|
|
||||||
src/mudlib/combat/commands.py (load_combat_move, register_combat_commands)
|
|
||||||
src/mudlib/things.py (load_thing_templates)
|
|
||||||
src/mudlib/mobs.py (load_mob_template, load_mob_templates)
|
|
||||||
src/mudlib/zones.py (load_zone, load_zones)
|
|
||||||
src/mudlib/crafting.py (load_recipes)
|
|
||||||
src/mudlib/commands/help.py (load_help_topic, load_help_topics)
|
|
||||||
src/mudlib/commands/talk.py (load_dialogue, load_all_dialogues)
|
|
||||||
|
|
||||||
content directories::
|
|
||||||
|
|
||||||
content/commands/
|
|
||||||
content/combat/
|
|
||||||
content/things/
|
|
||||||
content/mobs/
|
|
||||||
content/zones/
|
|
||||||
content/recipes/
|
|
||||||
content/dialogue/
|
|
||||||
content/help/
|
|
||||||
content/stories/
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
====================
|
|
||||||
crafting and recipes
|
|
||||||
====================
|
|
||||||
|
|
||||||
the crafting system lets players combine materials to create new items. recipes
|
|
||||||
are defined in TOML files, loaded at startup, and executed via the ``craft``
|
|
||||||
command.
|
|
||||||
|
|
||||||
recipe structure
|
|
||||||
================
|
|
||||||
|
|
||||||
recipes live in ``content/recipes/`` as TOML files. each defines:
|
|
||||||
|
|
||||||
- ``name`` - unique identifier (lowercase, underscores)
|
|
||||||
- ``description`` - what you're making (shown in recipe listings)
|
|
||||||
- ``ingredients`` - list of thing template names (duplicates = need multiples)
|
|
||||||
- ``result`` - thing template name to spawn
|
|
||||||
|
|
||||||
example::
|
|
||||||
|
|
||||||
name = "wooden_table"
|
|
||||||
description = "Craft a sturdy table from planks and nails"
|
|
||||||
ingredients = ["plank", "plank", "plank", "nail", "nail"]
|
|
||||||
result = "table"
|
|
||||||
|
|
||||||
all ingredient and result names must match existing thing templates in the
|
|
||||||
``thing_templates`` registry.
|
|
||||||
|
|
||||||
loading
|
|
||||||
=======
|
|
||||||
|
|
||||||
``load_recipe(path)`` parses a single TOML file into a Recipe dataclass.
|
|
||||||
``load_recipes(dir)`` walks the directory and loads all ``.toml`` files.
|
|
||||||
|
|
||||||
the global ``recipes`` dict (keyed by name) is updated at server startup in
|
|
||||||
``server.py``. recipes are immutable after load.
|
|
||||||
|
|
||||||
crafting
|
|
||||||
========
|
|
||||||
|
|
||||||
``cmd_craft(player, args)`` executes the crafting flow:
|
|
||||||
|
|
||||||
1. parse recipe name from args (case-insensitive, prefix matching)
|
|
||||||
2. find matching recipe (error if ambiguous or not found)
|
|
||||||
3. count required ingredients via ``Counter()``
|
|
||||||
4. count available items in player inventory
|
|
||||||
5. check all ingredients present (list missing if not)
|
|
||||||
6. verify result template exists in ``thing_templates``
|
|
||||||
7. consume ingredients (remove from world)
|
|
||||||
8. spawn result via ``spawn_thing``, add to inventory
|
|
||||||
9. send success message
|
|
||||||
|
|
||||||
ingredient counting uses python's ``Counter`` to handle duplicates. "2x plank"
|
|
||||||
means the ingredients list contains ``"plank"`` twice.
|
|
||||||
|
|
||||||
browsing recipes
|
|
||||||
================
|
|
||||||
|
|
||||||
``cmd_recipes()`` shows available recipes:
|
|
||||||
|
|
||||||
- no args: lists all recipes with descriptions
|
|
||||||
- with args: shows detailed recipe for a specific name (prefix matching)
|
|
||||||
|
|
||||||
detail view shows ingredients with counts ("2x plank, 2x nail") and the result
|
|
||||||
item. ambiguous prefixes are detected and reported.
|
|
||||||
|
|
||||||
materials
|
|
||||||
=========
|
|
||||||
|
|
||||||
existing thing templates usable as materials:
|
|
||||||
|
|
||||||
- ``plank`` - wooden plank
|
|
||||||
- ``nail`` - iron nail
|
|
||||||
- ``table`` - wooden table
|
|
||||||
- ``chair`` - wooden chair
|
|
||||||
|
|
||||||
new materials require adding thing templates to ``things.py`` and
|
|
||||||
creating recipes that reference them.
|
|
||||||
|
|
||||||
code
|
|
||||||
====
|
|
||||||
|
|
||||||
- ``src/mudlib/crafting.py`` - Recipe dataclass, loading, registry
|
|
||||||
- ``src/mudlib/commands/crafting.py`` - craft and recipes commands
|
|
||||||
- ``content/recipes/`` - TOML recipe definitions
|
|
||||||
- ``src/mudlib/things.py`` - thing template registry
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
==============
|
|
||||||
visual effects
|
|
||||||
==============
|
|
||||||
|
|
||||||
the overlay system for transient visual effects like cloud trails.
|
|
||||||
|
|
||||||
overview
|
|
||||||
========
|
|
||||||
|
|
||||||
effects are temporary overlays displayed on top of terrain in the viewport.
|
|
||||||
each effect has a position, character, color, and expiration time. they're
|
|
||||||
rendered last-wins on overlap, cleaned up passively in the game loop.
|
|
||||||
|
|
||||||
data model
|
|
||||||
==========
|
|
||||||
|
|
||||||
effect dataclass
|
|
||||||
----------------
|
|
||||||
|
|
||||||
defined in ``effects.py``::
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Effect:
|
|
||||||
x: int
|
|
||||||
y: int
|
|
||||||
char: str
|
|
||||||
color: str # ANSI code
|
|
||||||
expires_at: float # monotonic time
|
|
||||||
|
|
||||||
global state
|
|
||||||
------------
|
|
||||||
|
|
||||||
single global list::
|
|
||||||
|
|
||||||
active_effects: list[Effect] = []
|
|
||||||
|
|
||||||
no per-zone partitioning. simple.
|
|
||||||
|
|
||||||
core functions
|
|
||||||
==============
|
|
||||||
|
|
||||||
``add_effect(x, y, char, color, ttl)``
|
|
||||||
creates effect with expiry at ``time.monotonic() + ttl``, appends to list
|
|
||||||
|
|
||||||
``get_effects_at(x, y)``
|
|
||||||
returns active effects at position, auto-filters expired ones
|
|
||||||
|
|
||||||
``clear_expired()``
|
|
||||||
in-place list mutation to drop expired effects. called once per tick
|
|
||||||
(10/sec) in ``server.py`` game loop
|
|
||||||
|
|
||||||
rendering
|
|
||||||
=========
|
|
||||||
|
|
||||||
in ``look.py``, for each viewport tile:
|
|
||||||
|
|
||||||
1. check ``get_effects_at(x, y)``
|
|
||||||
2. if effects exist, take ``effects[-1]`` (most recent)
|
|
||||||
3. display its ``char`` and ``color``, overlaying terrain
|
|
||||||
|
|
||||||
last-added effect wins on overlap.
|
|
||||||
|
|
||||||
timing
|
|
||||||
======
|
|
||||||
|
|
||||||
uses ``time.monotonic()`` for consistent async timing. ttl in seconds.
|
|
||||||
|
|
||||||
current usage: fly command creates cloud trail ("~" bright white) with
|
|
||||||
staggered ttls (1.5s base + 0.4s per step) so clouds fade origin to destination.
|
|
||||||
|
|
||||||
design decisions
|
|
||||||
================
|
|
||||||
|
|
||||||
- global state, not per-zone (simplicity)
|
|
||||||
- passive cleanup via tick-based ``clear_expired()``
|
|
||||||
- last-wins overlay (no z-index, no blending)
|
|
||||||
- just char + ANSI color (minimal rendering)
|
|
||||||
- monotonic time (no wall clock drift)
|
|
||||||
|
|
||||||
code
|
|
||||||
====
|
|
||||||
|
|
||||||
- ``src/mudlib/effects.py`` - dataclass, functions, global state
|
|
||||||
- ``src/mudlib/look.py`` - viewport rendering with effect overlay
|
|
||||||
- ``src/mudlib/server.py`` - game loop calls ``clear_expired()``
|
|
||||||
- ``content/commands/fly.toml`` - cloud trail effect usage
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
=======================
|
|
||||||
loot and corpse system
|
|
||||||
=======================
|
|
||||||
|
|
||||||
the death system has three phases: knockout, finisher, and decomposition.
|
|
||||||
corpses are containers that rot over time. loot is probabilistic.
|
|
||||||
|
|
||||||
loot tables
|
|
||||||
===========
|
|
||||||
|
|
||||||
loot is defined per-mob in toml with ``LootEntry`` records::
|
|
||||||
|
|
||||||
[[loot]]
|
|
||||||
name = "crude club"
|
|
||||||
chance = 0.8
|
|
||||||
description = "a crude wooden club"
|
|
||||||
|
|
||||||
[[loot]]
|
|
||||||
name = "gold coin"
|
|
||||||
chance = 0.5
|
|
||||||
min_count = 1
|
|
||||||
max_count = 3
|
|
||||||
|
|
||||||
fields:
|
|
||||||
|
|
||||||
- ``name`` - item name
|
|
||||||
- ``chance`` - probability (0.0-1.0) to drop
|
|
||||||
- ``min_count`` / ``max_count`` - quantity range (defaults to 1)
|
|
||||||
- ``description`` - item description string
|
|
||||||
|
|
||||||
``roll_loot()`` processes the table entry by entry. for each entry, it rolls
|
|
||||||
random against chance. if successful, picks a count in the min/max range and
|
|
||||||
creates that many ``Thing`` objects. returns a flat list of items.
|
|
||||||
|
|
||||||
death flow
|
|
||||||
==========
|
|
||||||
|
|
||||||
knockout (automatic)
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
when stamina or pl drops to/below zero, the entity's ``posture`` becomes
|
|
||||||
``"unconscious"``. the mob stays registered in ``mobs`` dict. no corpse is
|
|
||||||
created yet. items stay in the mob's inventory.
|
|
||||||
|
|
||||||
finisher (explicit)
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
requires a command (e.g. ``snapneck``) targeting an unconscious entity. sets
|
|
||||||
``pl`` to -100 (dead). loads mob template to get loot table. calls
|
|
||||||
``create_corpse()`` with the mob, zone, and loot table.
|
|
||||||
|
|
||||||
no corpse until finisher — knockout alone doesn't drop loot.
|
|
||||||
|
|
||||||
corpse creation
|
|
||||||
===============
|
|
||||||
|
|
||||||
``create_corpse(mob, zone, ttl=300, loot_table=None)``
|
|
||||||
|
|
||||||
1. creates ``Corpse`` object (extends ``Container``) at mob's coords
|
|
||||||
2. names it ``"{mob.name}'s corpse"``
|
|
||||||
3. transfers all items from mob inventory to corpse
|
|
||||||
4. rolls loot table, adds generated items to corpse
|
|
||||||
5. calls ``despawn_mob()`` to remove mob from world
|
|
||||||
6. registers corpse in global ``active_corpses`` list
|
|
||||||
7. returns the corpse
|
|
||||||
|
|
||||||
corpse properties:
|
|
||||||
|
|
||||||
- ``closed=False`` - always open
|
|
||||||
- ``portable=False`` - can't be picked up
|
|
||||||
- ``decompose_at`` - timestamp (now + ttl seconds)
|
|
||||||
|
|
||||||
corpses work with existing item commands (``get``, ``put``, ``look in``) because
|
|
||||||
they're containers.
|
|
||||||
|
|
||||||
decomposition
|
|
||||||
=============
|
|
||||||
|
|
||||||
``process_decomposing()`` runs every game loop tick. checks each corpse in
|
|
||||||
``active_corpses`` against current time. when ``decompose_at`` passes:
|
|
||||||
|
|
||||||
1. broadcasts "X decomposes." to entities on same tile
|
|
||||||
2. clears all items (items rot with corpse — no loot recovery)
|
|
||||||
3. removes corpse from world
|
|
||||||
4. removes from ``active_corpses`` registry
|
|
||||||
|
|
||||||
ttl-based cleanup ensures corpses don't clutter the world. default 300s (5min).
|
|
||||||
|
|
||||||
code
|
|
||||||
====
|
|
||||||
|
|
||||||
implementation:
|
|
||||||
|
|
||||||
- ``src/mudlib/loot.py`` - loot table dataclass and roll logic
|
|
||||||
- ``src/mudlib/corpse.py`` - Corpse container, creation, decomposition
|
|
||||||
- ``src/mudlib/commands/snapneck.py`` - finisher command
|
|
||||||
- ``content/mobs/*.toml`` - per-mob loot tables
|
|
||||||
|
|
||||||
related systems:
|
|
||||||
|
|
||||||
- ``src/mudlib/combat.py`` - knockout logic (posture="unconscious")
|
|
||||||
- ``src/mudlib/entity.py`` - Entity.posture, inventory
|
|
||||||
- ``src/mudlib/item.py`` - Thing, Container base classes
|
|
||||||
- ``src/mudlib/server.py`` - calls process_decomposing() each tick
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
================
|
|
||||||
npc/mob system
|
|
||||||
================
|
|
||||||
|
|
||||||
the npc/mob system handles non-player characters: enemies, allies, townsfolk. mobs can wander, patrol, converse, flee, and fight. they follow schedules and have behavior states that transition based on game events.
|
|
||||||
|
|
||||||
entity model
|
|
||||||
============
|
|
||||||
|
|
||||||
all characters (players and mobs) inherit from ``Entity`` in ``entity.py``. this gives them:
|
|
||||||
|
|
||||||
- ``pl`` (power level) - combat effectiveness
|
|
||||||
- ``stamina`` and ``max_stamina`` - resource for moves
|
|
||||||
- ``posture`` - computed @property with priority order: "unconscious", "fighting", "flying", "sleeping", "resting", "standing"
|
|
||||||
- ``x``, ``y``, ``zone`` - location in the world
|
|
||||||
|
|
||||||
the ``Mob`` subclass adds:
|
|
||||||
|
|
||||||
- ``description`` - what you see when you look at them
|
|
||||||
- ``alive`` - whether they're dead
|
|
||||||
- ``moves`` - list of combat move names they can use
|
|
||||||
- ``next_action_at`` - throttle for AI decisions
|
|
||||||
- ``home_x_min``, ``home_x_max``, ``home_y_min``, ``home_y_max`` - wander bounds
|
|
||||||
- ``behavior_state`` - current state: "idle", "patrol", "converse", "flee", "working"
|
|
||||||
- ``behavior_data`` - dict with state-specific data (waypoints, threat coords, etc)
|
|
||||||
- ``npc_name`` - key into dialogue tree registry
|
|
||||||
- ``schedule`` - ``NpcSchedule`` instance with hourly behavior changes
|
|
||||||
|
|
||||||
mob templates
|
|
||||||
=============
|
|
||||||
|
|
||||||
templates live in ``content/mobs/`` as TOML files. ``MobTemplate`` in ``mobs.py`` defines the schema::
|
|
||||||
|
|
||||||
name = "goblin"
|
|
||||||
description = "a snarling goblin with a crude club"
|
|
||||||
pl = 50.0
|
|
||||||
stamina = 40.0
|
|
||||||
max_stamina = 40.0
|
|
||||||
moves = ["punch left", "punch right", "sweep"]
|
|
||||||
|
|
||||||
[[loot]]
|
|
||||||
name = "crude club"
|
|
||||||
chance = 0.8
|
|
||||||
description = "a crude wooden club"
|
|
||||||
|
|
||||||
``spawn_mob(template, x, y, zone, home_region)`` creates a ``Mob`` from a template and registers it. ``despawn_mob(mob)`` marks it dead and removes it from the registry. ``get_nearby_mob(name, x, y, zone, radius)`` finds mobs by name within range (toroidal-aware).
|
|
||||||
|
|
||||||
behavior states
|
|
||||||
===============
|
|
||||||
|
|
||||||
the behavior system in ``npc_behavior.py`` is a state machine. ``process_behavior(mob)`` dispatches to handlers based on ``mob.behavior_state``:
|
|
||||||
|
|
||||||
- **idle** - default state, wanders toward home region center
|
|
||||||
- **patrol** - moves toward waypoints in ``behavior_data["waypoints"]``
|
|
||||||
- **converse** - locked in place, talking to a player
|
|
||||||
- **flee** - runs away from threat at ``behavior_data["flee_from"]``
|
|
||||||
- **working** - schedule-driven state (librarian at desk, blacksmith at forge)
|
|
||||||
|
|
||||||
transitions happen when:
|
|
||||||
|
|
||||||
- player starts conversation → "converse"
|
|
||||||
- schedule hour changes → state from active schedule entry
|
|
||||||
- conversation ends → restores ``previous_state`` from conversation data
|
|
||||||
- flee timeout expires → return to previous state
|
|
||||||
|
|
||||||
helper functions calculate movement direction:
|
|
||||||
|
|
||||||
- ``get_patrol_direction(mob)`` - toward next waypoint (toroidal)
|
|
||||||
- ``get_flee_direction(mob)`` - away from threat (toroidal)
|
|
||||||
|
|
||||||
schedules
|
|
||||||
=========
|
|
||||||
|
|
||||||
scheduled NPCs have ``NpcSchedule`` in ``npc_schedule.py``. a schedule is a list of ``ScheduleEntry``::
|
|
||||||
|
|
||||||
[[schedule]]
|
|
||||||
hour = 7
|
|
||||||
state = "working"
|
|
||||||
location = [10, 20, "town"]
|
|
||||||
|
|
||||||
[[schedule]]
|
|
||||||
hour = 21
|
|
||||||
state = "idle"
|
|
||||||
|
|
||||||
``get_active_entry(hour)`` returns the most recent entry at or before the given hour. ``process_schedules(game_time)`` is called from the game loop when the hour changes. it:
|
|
||||||
|
|
||||||
- finds all mobs with schedules
|
|
||||||
- skips dead mobs and mobs in conversation
|
|
||||||
- checks if the active entry changed
|
|
||||||
- teleports mob to new location if specified
|
|
||||||
- transitions to new state
|
|
||||||
- clears behavior_data if state changed
|
|
||||||
|
|
||||||
mob ai
|
|
||||||
======
|
|
||||||
|
|
||||||
combat ai
|
|
||||||
---------
|
|
||||||
|
|
||||||
``process_mobs()`` in ``mob_ai.py`` runs every tick (10/sec). for mobs in combat:
|
|
||||||
|
|
||||||
- **defender with incoming attack** - 40% chance of correct counter, 60% random affordable defense
|
|
||||||
- **attacker outside defend window** - random affordable attack
|
|
||||||
|
|
||||||
the ai respects throttles (``next_action_at``) and stamina costs. it uses ``get_affordable_moves()`` to filter by stamina.
|
|
||||||
|
|
||||||
movement ai
|
|
||||||
-----------
|
|
||||||
|
|
||||||
``process_mob_movement()`` runs every tick with a 3-second cooldown per mob. behavior-based movement:
|
|
||||||
|
|
||||||
- **patrol** - toward next waypoint, cycles through list
|
|
||||||
- **flee** - away from threat coordinates
|
|
||||||
- **idle** - toward home region center
|
|
||||||
- **working** - no movement
|
|
||||||
|
|
||||||
movement checks passability via ``world.is_passable(x, y, zone)``. broadcasts departure/arrival to nearby players. uses toroidal distance for direction calculation.
|
|
||||||
|
|
||||||
dialogue and conversation
|
|
||||||
=========================
|
|
||||||
|
|
||||||
``DialogueTree`` in ``dialogue.py`` has ``DialogueNode`` entries::
|
|
||||||
|
|
||||||
[dialogue.librarian.greeting]
|
|
||||||
text = "welcome to the library"
|
|
||||||
|
|
||||||
[[dialogue.librarian.greeting.choices]]
|
|
||||||
text = "what books do you have?"
|
|
||||||
next_node = "books"
|
|
||||||
|
|
||||||
``DialogueChoice`` has optional ``condition`` (python expression) and ``action`` (function name).
|
|
||||||
|
|
||||||
``ConversationState`` in ``conversation.py`` tracks:
|
|
||||||
|
|
||||||
- ``tree`` - the dialogue tree
|
|
||||||
- ``current_node`` - id of current node
|
|
||||||
- ``npc`` - the mob being talked to
|
|
||||||
- ``previous_state`` - mob's state before conversation
|
|
||||||
|
|
||||||
``start_conversation(player, npc)`` transitions the mob to "converse" state. ``end_conversation(player)`` restores the mob's previous state. active conversations are stored in a dict keyed by player name.
|
|
||||||
|
|
||||||
commands
|
|
||||||
========
|
|
||||||
|
|
||||||
talk and reply
|
|
||||||
--------------
|
|
||||||
|
|
||||||
``cmd_talk`` in ``commands/talk.py``:
|
|
||||||
|
|
||||||
- finds nearby NPC by name
|
|
||||||
- starts conversation
|
|
||||||
- shows greeting text and choices
|
|
||||||
|
|
||||||
``cmd_reply``:
|
|
||||||
|
|
||||||
- takes choice number as argument
|
|
||||||
- advances to next node
|
|
||||||
- ends conversation if no next node
|
|
||||||
|
|
||||||
spawn
|
|
||||||
-----
|
|
||||||
|
|
||||||
``cmd_spawn`` in ``commands/spawn.py``:
|
|
||||||
|
|
||||||
- takes template name
|
|
||||||
- creates mob at player location
|
|
||||||
- sets home region to 5-tile radius
|
|
||||||
|
|
||||||
game loop integration
|
|
||||||
=====================
|
|
||||||
|
|
||||||
the ``game_loop()`` function in ``server.py`` calls:
|
|
||||||
|
|
||||||
- ``process_mobs()`` - every tick (10/sec)
|
|
||||||
- ``process_mob_movement()`` - every tick (10/sec)
|
|
||||||
- ``process_schedules(game_time)`` - when hour changes
|
|
||||||
|
|
||||||
this keeps mob AI responsive while preventing spam. the throttles (``next_action_at`` for combat, 3s cooldown for movement) ensure mobs don't act too frequently.
|
|
||||||
|
|
||||||
code
|
|
||||||
====
|
|
||||||
|
|
||||||
- ``src/mudlib/entity.py`` - Entity and Mob classes
|
|
||||||
- ``src/mudlib/mobs.py`` - MobTemplate, spawn/despawn registry
|
|
||||||
- ``src/mudlib/npc_behavior.py`` - behavior state machine
|
|
||||||
- ``src/mudlib/npc_schedule.py`` - NpcSchedule and ScheduleEntry
|
|
||||||
- ``src/mudlib/mob_ai.py`` - combat and movement AI
|
|
||||||
- ``src/mudlib/dialogue.py`` - DialogueTree, DialogueNode, DialogueChoice
|
|
||||||
- ``src/mudlib/conversation.py`` - ConversationState, start/end conversation
|
|
||||||
- ``src/mudlib/commands/talk.py`` - talk and reply commands
|
|
||||||
- ``src/mudlib/commands/spawn.py`` - spawn command
|
|
||||||
- ``content/mobs/`` - mob template TOML files
|
|
||||||
- ``content/dialogue/`` - dialogue tree TOML files
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
=======================
|
|
||||||
target resolution
|
|
||||||
=======================
|
|
||||||
|
|
||||||
how we find what the player is talking about.
|
|
||||||
|
|
||||||
the problem
|
|
||||||
===========
|
|
||||||
|
|
||||||
when a player types "attack goblin" or "get sword", we need to figure out
|
|
||||||
which goblin, which sword. could be multiple matches. could be an alias.
|
|
||||||
could be "2.goblin" to mean the second one.
|
|
||||||
|
|
||||||
the system uses graceful degradation: try exact matches, fall back to prefix
|
|
||||||
matches, then aliases, then ordinal selection.
|
|
||||||
|
|
||||||
parsing the raw input
|
|
||||||
=====================
|
|
||||||
|
|
||||||
``parse_target(raw)`` extracts ordinal prefixes::
|
|
||||||
|
|
||||||
"goblin" → (1, "goblin")
|
|
||||||
"2.goblin" → (2, "goblin")
|
|
||||||
"3.rat" → (3, "rat")
|
|
||||||
|
|
||||||
only ordinals >= 1 are valid. "0.goblin" or "-1.goblin" are treated as literal
|
|
||||||
names (weird, but no crash).
|
|
||||||
|
|
||||||
matching priority
|
|
||||||
=================
|
|
||||||
|
|
||||||
``resolve_target(name, candidates, key=None)`` uses a priority-based matching
|
|
||||||
flow:
|
|
||||||
|
|
||||||
**four match priorities** (checked in order, stops at first non-empty result):
|
|
||||||
|
|
||||||
1. exact name match (case-insensitive)
|
|
||||||
2. name prefix match ("gob" matches "goblin")
|
|
||||||
3. exact alias match
|
|
||||||
4. alias prefix match ("gobb" matches alias "gobby")
|
|
||||||
|
|
||||||
collects ALL matches at each priority level before moving to the next. this
|
|
||||||
prevents a prefix match from shadowing a later exact alias match.
|
|
||||||
|
|
||||||
**ordinal selection**: if an ordinal was parsed (e.g., "2.goblin"), picks the
|
|
||||||
Nth match from whichever priority level produced results.
|
|
||||||
|
|
||||||
**fallback**: returns None if no matches found at any level.
|
|
||||||
|
|
||||||
custom key function lets you adapt to non-standard objects (e.g., dicts with
|
|
||||||
a "name" field).
|
|
||||||
|
|
||||||
finding entities
|
|
||||||
================
|
|
||||||
|
|
||||||
``find_entity_on_tile(name, player, z_filter=True)`` searches players and mobs
|
|
||||||
on the same tile.
|
|
||||||
|
|
||||||
filters:
|
|
||||||
- excludes the player themselves
|
|
||||||
- skips dead mobs
|
|
||||||
- z-axis filtering: only matches entities on same z-level (both grounded or
|
|
||||||
both flying). flying creatures can't attack grounded ones by default.
|
|
||||||
|
|
||||||
sorts results to prefer Players over Mobs.
|
|
||||||
|
|
||||||
finding things
|
|
||||||
==============
|
|
||||||
|
|
||||||
``find_thing_on_tile(name, zone, x, y)`` searches ground items at coordinates.
|
|
||||||
only Thing instances.
|
|
||||||
|
|
||||||
``find_in_inventory(name, player)`` searches player inventory. only Thing
|
|
||||||
instances.
|
|
||||||
|
|
||||||
ordinal disambiguation
|
|
||||||
======================
|
|
||||||
|
|
||||||
if there are three goblins, you can target the second with "2.goblin"::
|
|
||||||
|
|
||||||
attack 2.goblin
|
|
||||||
|
|
||||||
the ordinal is parsed, then resolve_target collects matches and picks the Nth
|
|
||||||
one. ordinal selection happens after all four priority levels have been tried,
|
|
||||||
operating on whichever level produced matches.
|
|
||||||
|
|
||||||
case sensitivity
|
|
||||||
================
|
|
||||||
|
|
||||||
all matching is case-insensitive. "Goblin", "goblin", "GOBLIN" all work.
|
|
||||||
|
|
||||||
code
|
|
||||||
====
|
|
||||||
|
|
||||||
- ``src/mudlib/commands/targeting.py`` - parse and resolve functions
|
|
||||||
- ``tests/test_targeting.py`` - test coverage for all priority levels
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
=====================================
|
|
||||||
things and verbs: how objects interact
|
|
||||||
=====================================
|
|
||||||
|
|
||||||
the thing/verb system lets world objects respond to player actions. instead of hardcoding behavior in commands, objects carry their own verb handlers. two ways to register verbs: decorator-based (python classes) or toml-based (content files). both converge to the same dispatch mechanism.
|
|
||||||
|
|
||||||
object base class
|
|
||||||
=================
|
|
||||||
|
|
||||||
``Object`` (object.py) is the foundation for all world entities. it has:
|
|
||||||
|
|
||||||
- ``name`` - what the object is called
|
|
||||||
- ``location`` - another Object or None (the entire containment system)
|
|
||||||
- ``x``, ``y`` - coordinates in the world
|
|
||||||
- ``_contents`` - list of objects inside this one
|
|
||||||
- ``_verbs`` - dict mapping verb names to handlers
|
|
||||||
|
|
||||||
``__post_init__`` scans the instance for methods decorated with ``@verb`` and auto-registers them in ``_verbs``.
|
|
||||||
|
|
||||||
thing and container
|
|
||||||
===================
|
|
||||||
|
|
||||||
``Thing`` (thing.py) extends Object for portable items::
|
|
||||||
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
portable: bool = True
|
|
||||||
aliases: list[str] = []
|
|
||||||
readable_text: str | None = None
|
|
||||||
tags: set[str] = field(default_factory=set)
|
|
||||||
|
|
||||||
``Container`` (container.py) extends Thing with containment rules::
|
|
||||||
|
|
||||||
capacity: int
|
|
||||||
closed: bool = False
|
|
||||||
locked: bool = False
|
|
||||||
|
|
||||||
``can_accept()`` checks object type, open state, and capacity before allowing insertions.
|
|
||||||
|
|
||||||
verb registration: decorator path
|
|
||||||
==================================
|
|
||||||
|
|
||||||
the ``@verb`` decorator (verbs.py) marks methods as verb handlers. it sets ``_verb_name`` on the function. ``Object.__post_init__`` discovers these and calls ``register_verb()``::
|
|
||||||
|
|
||||||
class Fountain(Thing):
|
|
||||||
@verb("drink")
|
|
||||||
async def drink(self, player, args):
|
|
||||||
await player.send("You drink from the fountain.\r\n")
|
|
||||||
|
|
||||||
when you instantiate the fountain, the drink handler automatically registers in ``_verbs["drink"]``.
|
|
||||||
|
|
||||||
verb registration: toml path
|
|
||||||
=============================
|
|
||||||
|
|
||||||
``ThingTemplate`` (things.py) defines objects in toml::
|
|
||||||
|
|
||||||
name = "chest"
|
|
||||||
description = "a sturdy wooden chest with iron bindings"
|
|
||||||
portable = false
|
|
||||||
capacity = 5
|
|
||||||
closed = true
|
|
||||||
locked = false
|
|
||||||
aliases = ["box"]
|
|
||||||
|
|
||||||
[verbs]
|
|
||||||
unlock = "verb_handlers:unlock_handler"
|
|
||||||
|
|
||||||
``spawn_thing()`` (things.py) creates a Thing or Container from the template. if ``capacity`` is set, you get a Container. if ``verbs`` dict is present, it resolves "module:function" strings via importlib and binds them with functools.partial.
|
|
||||||
|
|
||||||
``verb_handlers.py`` contains standalone functions referenced by toml. example::
|
|
||||||
|
|
||||||
async def unlock_handler(obj, player, args):
|
|
||||||
if not isinstance(obj, Container):
|
|
||||||
await player.send("That's not lockable.\r\n")
|
|
||||||
return
|
|
||||||
if not obj.locked:
|
|
||||||
await player.send("It's already unlocked.\r\n")
|
|
||||||
return
|
|
||||||
# check for key in inventory...
|
|
||||||
obj.locked = False
|
|
||||||
await player.send(f"You unlock the {obj.name}.\r\n")
|
|
||||||
|
|
||||||
two paths, one dispatch
|
|
||||||
=======================
|
|
||||||
|
|
||||||
decorator-based and toml-based verbs both populate ``Object._verbs``. the command dispatcher doesn't care which path was used.
|
|
||||||
|
|
||||||
finding objects
|
|
||||||
===============
|
|
||||||
|
|
||||||
``find_object()`` (verbs.py) searches for targets:
|
|
||||||
|
|
||||||
1. player inventory first
|
|
||||||
2. ground at player position second
|
|
||||||
3. matches by name or aliases (case-insensitive)
|
|
||||||
|
|
||||||
returns the object or None.
|
|
||||||
|
|
||||||
command dispatch fallback
|
|
||||||
=========================
|
|
||||||
|
|
||||||
the command dispatcher (commands/__init__.py) tries registered commands first. if no match, it tries verb dispatch:
|
|
||||||
|
|
||||||
1. parse input as "verb target" (e.g., "drink fountain")
|
|
||||||
2. call ``find_object()`` for target
|
|
||||||
3. check ``obj.has_verb(verb)``
|
|
||||||
4. call ``obj.call_verb(verb, player, args)``
|
|
||||||
|
|
||||||
this lets content authors add new interactions without modifying command code.
|
|
||||||
|
|
||||||
use command
|
|
||||||
===========
|
|
||||||
|
|
||||||
``use`` (commands/use.py) provides explicit verb access::
|
|
||||||
|
|
||||||
use fountain # calls fountain's "use" verb
|
|
||||||
use key on chest # calls chest's "use" verb with args="key on chest"
|
|
||||||
|
|
||||||
parses input, finds object, checks for "use" verb, calls handler.
|
|
||||||
|
|
||||||
code
|
|
||||||
====
|
|
||||||
|
|
||||||
relevant files::
|
|
||||||
|
|
||||||
src/mudlib/object.py # Object base class, verb registration
|
|
||||||
src/mudlib/thing.py # Thing class
|
|
||||||
src/mudlib/container.py # Container class
|
|
||||||
src/mudlib/verbs.py # @verb decorator, find_object()
|
|
||||||
src/mudlib/things.py # ThingTemplate, spawn_thing()
|
|
||||||
src/mudlib/verb_handlers.py # standalone verb functions for toml
|
|
||||||
src/mudlib/commands/__init__.py # command dispatcher with verb fallback
|
|
||||||
src/mudlib/commands/use.py # explicit verb command
|
|
||||||
content/things/ # toml thing definitions
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
=============================
|
|
||||||
time and weather system
|
|
||||||
=============================
|
|
||||||
|
|
||||||
the game simulates passing time, seasons, weather, and their effect on visibility. all together these create atmospheric descriptions in the look command and influence how far you can see.
|
|
||||||
|
|
||||||
game time
|
|
||||||
=========
|
|
||||||
|
|
||||||
time flows faster in the game world than in reality. by default, 1 real minute = 1 game hour, so a full game day passes in 24 real minutes.
|
|
||||||
|
|
||||||
the ``GameTime`` class converts real timestamps to game time using an epoch (when the game started) and a ratio. methods::
|
|
||||||
|
|
||||||
get_game_hour() -> int # 0-23
|
|
||||||
get_game_time() -> tuple # (hour, minute)
|
|
||||||
get_game_day() -> int # days since epoch, 0-based
|
|
||||||
|
|
||||||
``init_game_time()`` is called at server startup to establish the global game clock. all other systems query this clock to determine current conditions.
|
|
||||||
|
|
||||||
time of day
|
|
||||||
===========
|
|
||||||
|
|
||||||
game hours map to periods: dawn (5-6), day (7-17), dusk (18-19), night (20-4).
|
|
||||||
|
|
||||||
``get_sky_description(hour)`` picks variant descriptions for each period. the selection is deterministic using ``hour % len(variants)`` so the same hour always shows the same description within that day. examples::
|
|
||||||
|
|
||||||
dawn: "pale light seeps across the horizon"
|
|
||||||
day: "the sun hangs high overhead"
|
|
||||||
dusk: "the day's light begins to soften"
|
|
||||||
night: "stars wheel slowly overhead"
|
|
||||||
|
|
||||||
there are 3-4 variants per period to add variety across different hours.
|
|
||||||
|
|
||||||
seasons
|
|
||||||
=======
|
|
||||||
|
|
||||||
the game year is 28 days long with 4 seasons of 7 days each: spring, summer, autumn, winter. seasons cycle infinitely.
|
|
||||||
|
|
||||||
``get_season(game_day)`` returns the current season name. ``get_season_description(season, terrain)`` returns descriptive text, but only for grass and forest terrain. other terrain types return empty strings. examples::
|
|
||||||
|
|
||||||
spring + grass: "fresh green grass springs up everywhere"
|
|
||||||
winter + forest: "bare branches reach toward the sky"
|
|
||||||
summer + forest: "a thick green canopy spreads overhead"
|
|
||||||
|
|
||||||
this is layered into the atmosphere line when looking at the world.
|
|
||||||
|
|
||||||
weather
|
|
||||||
=======
|
|
||||||
|
|
||||||
weather is a global simulation that evolves each game hour.
|
|
||||||
|
|
||||||
conditions
|
|
||||||
----------
|
|
||||||
|
|
||||||
``WeatherCondition`` enum: clear, cloudy, rain, storm, snow, fog.
|
|
||||||
|
|
||||||
``WeatherState`` tracks the current condition plus intensity (0.0-1.0). intensity affects descriptions::
|
|
||||||
|
|
||||||
rain < 0.3: "a light drizzle falls"
|
|
||||||
rain 0.3-0.6: "rain patters steadily"
|
|
||||||
rain >= 0.6: "rain hammers down relentlessly"
|
|
||||||
|
|
||||||
similar tiers exist for snow, fog, and storm.
|
|
||||||
|
|
||||||
climate profiles
|
|
||||||
----------------
|
|
||||||
|
|
||||||
``advance_weather()`` is probabilistic with three climate profiles:
|
|
||||||
|
|
||||||
- **temperate**: balanced transitions between all conditions
|
|
||||||
- **arid**: 90% chance clear stays clear, rare rain, no snow
|
|
||||||
- **arctic**: heavy emphasis on snow and fog
|
|
||||||
|
|
||||||
season filtering prevents snow in spring/summer.
|
|
||||||
|
|
||||||
global state
|
|
||||||
------------
|
|
||||||
|
|
||||||
``init_weather()`` sets up the initial condition. ``get_current_weather()`` returns the current state. ``tick_weather()`` is called each game hour to advance the simulation.
|
|
||||||
|
|
||||||
visibility
|
|
||||||
==========
|
|
||||||
|
|
||||||
weather and time of day affect how far you can see.
|
|
||||||
|
|
||||||
``get_visibility(hour, weather, base_width=21, base_height=11)`` calculates effective viewport dimensions by applying stacking penalties::
|
|
||||||
|
|
||||||
night: -6 width, -2 height
|
|
||||||
dawn/dusk: -2 width
|
|
||||||
thick fog (>=0.7): -8 width, -4 height
|
|
||||||
fog (0.4-0.7): -4 width, -2 height
|
|
||||||
storm: -4 width, -2 height
|
|
||||||
|
|
||||||
minimum viewport is 7x5. effects stack: a stormy night with thick fog applies all three penalties.
|
|
||||||
|
|
||||||
integration
|
|
||||||
===========
|
|
||||||
|
|
||||||
the look command pulls current hour, day, weather, and season. it calculates effective viewport via ``get_visibility()`` and builds the atmosphere line with ``render_atmosphere()``.
|
|
||||||
|
|
||||||
example outputs::
|
|
||||||
|
|
||||||
"the sun hangs high overhead. [day, summer]"
|
|
||||||
|
|
||||||
"the day's light begins to soften. rain patters steadily. [day, autumn]"
|
|
||||||
|
|
||||||
"stars wheel slowly overhead. snow drifts down softly. [night, winter]"
|
|
||||||
|
|
||||||
the atmosphere line appears at the top of the world view, followed by the terrain map clipped to effective visibility.
|
|
||||||
|
|
||||||
code
|
|
||||||
====
|
|
||||||
|
|
||||||
relevant files::
|
|
||||||
|
|
||||||
src/mudlib/gametime.py
|
|
||||||
src/mudlib/timeofday.py
|
|
||||||
src/mudlib/seasons.py
|
|
||||||
src/mudlib/weather.py
|
|
||||||
src/mudlib/visibility.py
|
|
||||||
src/mudlib/commands/look.py
|
|
||||||
|
|
@ -28,14 +28,6 @@ mostly standalone subsystems.
|
||||||
- ``docs/how/persistence.txt`` — SQLite storage, what's persisted vs runtime
|
- ``docs/how/persistence.txt`` — SQLite storage, what's persisted vs runtime
|
||||||
- ``docs/how/prompt-system.txt`` — modal prompts, color markup, per-player customization
|
- ``docs/how/prompt-system.txt`` — modal prompts, color markup, per-player customization
|
||||||
- ``docs/how/protocols.rst`` — GMCP/MSDP negotiation, client detection, guard pattern
|
- ``docs/how/protocols.rst`` — GMCP/MSDP negotiation, client detection, guard pattern
|
||||||
- ``docs/how/things-and-verbs.rst`` — verb registry, @verb decorator, TOML verb handlers, dispatch fallback
|
|
||||||
- ``docs/how/content-loading.rst`` — TOML content pipeline, startup sequence, global registries
|
|
||||||
- ``docs/how/targeting.rst`` — ordinal parsing, priority matching, z-axis filtering
|
|
||||||
- ``docs/how/npc-mobs.rst`` — mob templates, behavior states, AI, schedules, dialogue, spawning
|
|
||||||
- ``docs/how/time-and-weather.rst`` — game time, seasons, weather simulation, visibility
|
|
||||||
- ``docs/how/effects.rst`` — temporary visual overlays on the map (cloud trails, etc)
|
|
||||||
- ``docs/how/loot-and-corpses.rst`` — loot tables, corpse creation, decomposition
|
|
||||||
- ``docs/how/crafting.rst`` — recipe definitions, ingredient matching, item spawning
|
|
||||||
|
|
||||||
combat
|
combat
|
||||||
------
|
------
|
||||||
|
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
Project Health Audit
|
|
||||||
====================
|
|
||||||
|
|
||||||
Feb 2026. Full codebase audit after combat-timing-fields landed.
|
|
||||||
|
|
||||||
The architecture is solid. The problem is drift: things got built ahead of
|
|
||||||
being cared about, so docs/tests/schemas are out of sync with actual code.
|
|
||||||
Nothing is broken, but the project needs a cleanup pass before pushing
|
|
||||||
forward.
|
|
||||||
|
|
||||||
|
|
||||||
Combat: Doc/Schema Mismatch
|
|
||||||
----------------------------
|
|
||||||
|
|
||||||
The most concrete issue. Three things are wrong:
|
|
||||||
|
|
||||||
1. **State machine docs are stale.** combat.rst describes
|
|
||||||
IDLE -> TELEGRAPH -> WINDOW -> RESOLVE (4 states). Code has
|
|
||||||
IDLE -> PENDING -> RESOLVE (3 states). TELEGRAPH and WINDOW got
|
|
||||||
collapsed into PENDING. The docs need to match reality.
|
|
||||||
|
|
||||||
2. **Field names drifted.** Old schema used ``timing_window_ms`` for
|
|
||||||
everything. New schema split it: ``hit_time_ms`` for attacks (time to
|
|
||||||
impact), ``active_ms``/``recovery_ms`` for defenses (active window /
|
|
||||||
lockout). builder-manual.md and combat.rst still reference the old name.
|
|
||||||
|
|
||||||
3. **No schema validation.** ``moves.py`` checks 3 required fields (name,
|
|
||||||
move_type, stamina_cost). You can define an attack with hit_time_ms=0
|
|
||||||
and it silently does nothing. Attacks should require hit_time_ms > 0,
|
|
||||||
defenses should require active_ms > 0.
|
|
||||||
|
|
||||||
Other combat notes:
|
|
||||||
|
|
||||||
- Stamina deduction is asymmetric: attacks deduct inside
|
|
||||||
``encounter.attack()``, defenses deduct in the command handler before
|
|
||||||
calling ``encounter.defend()``. Works but confusing. Consider
|
|
||||||
standardizing.
|
|
||||||
- Defense telegraphs are all empty strings. Intentional (reactive moves
|
|
||||||
don't broadcast intent) but undocumented.
|
|
||||||
- Future features (stuns, combos, lethal, multi-combatant) are NOT
|
|
||||||
half-implemented. No stubs or dead code. Clean.
|
|
||||||
|
|
||||||
Files to touch::
|
|
||||||
|
|
||||||
docs/how/combat.rst - fix state machine, fix field names
|
|
||||||
docs/builder-manual.md - fix TOML schema examples
|
|
||||||
src/mudlib/combat/moves.py - add per-move-type validation
|
|
||||||
|
|
||||||
|
|
||||||
Tests: Mixed Quality
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
~1,448 test functions across 100+ files. The good tests are genuinely good
|
|
||||||
(combat encounter, quetzal roundtrip, content loading). But there's bloat.
|
|
||||||
|
|
||||||
**Trivial constructor tests (~50+).** Things like::
|
|
||||||
|
|
||||||
def test_thing_creation_minimal():
|
|
||||||
t = Thing(name="rock")
|
|
||||||
assert t.name == "rock"
|
|
||||||
|
|
||||||
These verify Python dataclasses work. Delete them.
|
|
||||||
|
|
||||||
**Over-mocking.** Many tests mock the writer then assert "was something
|
|
||||||
written?" but not what. Color bugs, format bugs slip through. Loose
|
|
||||||
assertions like ``assert "\033[" in result`` check some ANSI was emitted
|
|
||||||
but not the right color.
|
|
||||||
|
|
||||||
**Duplicated fixtures.** ``mock_writer`` is defined in 15+ test files
|
|
||||||
instead of sharing from conftest. Same for ``mock_reader`` and zone helpers.
|
|
||||||
|
|
||||||
**File sprawl.** 4 container test files, 3 portal files, 3 help files,
|
|
||||||
4 zone files. These should consolidate into 1-2 per feature area.
|
|
||||||
|
|
||||||
**Stub files (1 test each).** Seven files with a single test: test_corpse,
|
|
||||||
test_npc_behavior, test_npc_integration, test_import, test_mobs,
|
|
||||||
test_game_compatibility, test_two_way_portals. Either flesh out or delete.
|
|
||||||
|
|
||||||
**Missing test categories:**
|
|
||||||
|
|
||||||
- Error paths (bad input, wrong types, missing data)
|
|
||||||
- Edge cases (boundary values, empty inputs, max values)
|
|
||||||
- Concurrency (async code with no race condition tests)
|
|
||||||
|
|
||||||
Plan::
|
|
||||||
|
|
||||||
1. Consolidate mock_writer/mock_reader into conftest
|
|
||||||
2. Delete trivial constructor/property tests
|
|
||||||
3. Merge fragmented test files (containers, portals, help, zones)
|
|
||||||
4. Decide on stub files: flesh out or remove
|
|
||||||
5. Strengthen loose assertions where practical
|
|
||||||
|
|
||||||
|
|
||||||
Documentation: Strong Foundation, Gaps
|
|
||||||
---------------------------------------
|
|
||||||
|
|
||||||
**Current and good:** DREAMBOOK, architecture-plan, object-model,
|
|
||||||
builder-manual, protocols, persistence, terrain-generation, IF docs,
|
|
||||||
all lessons.
|
|
||||||
|
|
||||||
**Stale:**
|
|
||||||
|
|
||||||
- combat.rst (wrong state machine, wrong field names)
|
|
||||||
- builder-manual.md (old timing_window_ms references)
|
|
||||||
- roadmap.rst (phases 1-2 done but not marked)
|
|
||||||
- IF research docs (viola/zvm/mojozork audits predate implementation)
|
|
||||||
|
|
||||||
**Missing docs for existing systems:**
|
|
||||||
|
|
||||||
- NPC/mob system (mobs.py, npc_behavior.py, npc_schedule.py, dialogue.py,
|
|
||||||
conversation.py, mob_ai.py - 6+ files, no implementation doc)
|
|
||||||
- Thing/verb system (thing.py, things.py, verbs.py, verb_handlers.py)
|
|
||||||
- Time/season/weather (gametime.py, seasons.py, weather.py, timeofday.py,
|
|
||||||
visibility.py)
|
|
||||||
- Effects system (effects.py)
|
|
||||||
- Targeting (targeting.py)
|
|
||||||
- Loot/corpse (loot.py, corpse.py)
|
|
||||||
- Crafting implementation (crafting.py - builder-manual has usage but no
|
|
||||||
internals doc)
|
|
||||||
- Content loading pipeline (content.py)
|
|
||||||
- Embedded z-machine (50+ files in zmachine/, no implementation guide)
|
|
||||||
|
|
||||||
Plan: write docs as we touch each system, not all at once. Priority order
|
|
||||||
matches what we're likely to work on next (combat first, then mobs/things).
|
|
||||||
|
|
||||||
|
|
||||||
Architecture: Solid
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
No action needed on architecture itself. Notes for awareness:
|
|
||||||
|
|
||||||
- Module-level globals (players, mobs, active_encounters, command registry)
|
|
||||||
are appropriate for a game server. Tests clear them properly.
|
|
||||||
- server.py is 676 lines and dense (login, shell loop, content loading) but
|
|
||||||
responsibilities are clear. Could split later if it grows.
|
|
||||||
- Player dataclass has 29 fields. Organized but growing. Watch it.
|
|
||||||
- Mode stack is string-based ("normal", "combat", "editor"). Works fine,
|
|
||||||
enum would be safer eventually.
|
|
||||||
- No dead code in core engine. Z-machine has TODOs (expected).
|
|
||||||
- No circular dependency issues. Lazy imports in commands/ handle cycles.
|
|
||||||
|
|
||||||
|
|
||||||
Execution Order
|
|
||||||
---------------
|
|
||||||
|
|
||||||
Phase 1: Combat docs and schema (small, unblocks combat work)
|
|
||||||
|
|
||||||
- Update combat.rst state machine and field names
|
|
||||||
- Update builder-manual.md TOML examples
|
|
||||||
- Add schema validation in moves.py
|
|
||||||
- Tests for validation
|
|
||||||
|
|
||||||
Phase 2: Test cleanup (can be gradual)
|
|
||||||
|
|
||||||
- Consolidate fixtures into conftest
|
|
||||||
- Delete trivial tests
|
|
||||||
- Merge fragmented test files
|
|
||||||
- Strengthen assertions
|
|
||||||
|
|
||||||
Phase 3: Doc gaps (as-needed, per system)
|
|
||||||
|
|
||||||
- Write how/ docs when touching each system
|
|
||||||
- Mark completed roadmap phases
|
|
||||||
- Consider archiving pre-implementation IF research docs
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# parse dbzfe combat log for combat-relevant lines
|
|
||||||
# grabs lines starting with (after timestamp): * or You
|
|
||||||
|
|
||||||
log="${1:-docs/research/dbzfe.log}"
|
|
||||||
|
|
||||||
if [[ ! -f "$log" ]]; then
|
|
||||||
echo "usage: docs/research/dbzfe [logfile]"
|
|
||||||
echo "default: docs/research/dbzfe.log"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
grep -E '^[0-9:.]+[[:space:]]+((\*|You ))' "$log"
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,17 +2,10 @@
|
||||||
#split 0 1
|
#split 0 1
|
||||||
#session dbzfe dbzfe.com 4000
|
#session dbzfe dbzfe.com 4000
|
||||||
|
|
||||||
#NOP log with timestamps, plain text (no ANSI colors)
|
#NOP log with ANSI colors preserved (view later with: less -R dbzfe.log)
|
||||||
#config {log mode} {plain}
|
#config {log mode} {raw}
|
||||||
#NOP %f = microseconds in some strftime implementations, may show literal %f if unsupported
|
|
||||||
#log timestamp {%H:%M:%S.%f }
|
|
||||||
#log append dbzfe.log
|
#log append dbzfe.log
|
||||||
|
|
||||||
#NOP for color logging later: switch to raw mode and drop timestamp
|
|
||||||
#NOP #config {log mode} {raw}
|
|
||||||
#NOP #log timestamp {}
|
|
||||||
#NOP #log append dbzfe-color.log
|
|
||||||
|
|
||||||
#NOP fly aliases: f<direction> = fly 5 in that direction
|
#NOP fly aliases: f<direction> = fly 5 in that direction
|
||||||
#alias {fn} {fly north}
|
#alias {fn} {fly north}
|
||||||
#alias {fs} {fly south}
|
#alias {fs} {fly south}
|
||||||
|
|
@ -25,11 +18,11 @@
|
||||||
|
|
||||||
#NOP combat aliases (pr/pl/dr/dl/f/v are built into the MUD)
|
#NOP combat aliases (pr/pl/dr/dl/f/v are built into the MUD)
|
||||||
#NOP these are extras for single-key convenience
|
#NOP these are extras for single-key convenience
|
||||||
#alias {pr} {punch right %0}
|
#alias {pr} {punch right}
|
||||||
#alias {pl} {punch left %0}
|
#alias {pl} {punch left}
|
||||||
#alias {o} {sweep %0}
|
#alias {o} {sweep}
|
||||||
#alias {r} {roundhouse %0}
|
#alias {r} {roundhouse}
|
||||||
#alias {f} {parry high %0}
|
#alias {f} {parry high}
|
||||||
#alias {v} {parry low %0}
|
#alias {v} {parry low}
|
||||||
#alias {dl} {dodge left %0}
|
#alias {dl} {dodge left}
|
||||||
#alias {dr} {dodge right %0}
|
#alias {dr} {dodge right}
|
||||||
|
|
|
||||||
16
mud.tin
16
mud.tin
|
|
@ -37,11 +37,11 @@
|
||||||
#alias {fsw} {fly southwest}
|
#alias {fsw} {fly southwest}
|
||||||
|
|
||||||
#NOP combat shortcuts
|
#NOP combat shortcuts
|
||||||
#alias {o} {sweep %0}
|
#alias {o} {sweep}
|
||||||
#alias {pl} {punch left %0}
|
#alias {pl} {punch left}
|
||||||
#alias {pr} {punch right %0}
|
#alias {pr} {punch right}
|
||||||
#alias {r} {roundhouse %0}
|
#alias {r} {roundhouse}
|
||||||
#alias {f} {parry high %0}
|
#alias {f} {parry high}
|
||||||
#alias {v} {parry low %0}
|
#alias {v} {parry low}
|
||||||
#alias {dr} {dodge right %0}
|
#alias {dr} {dodge right}
|
||||||
#alias {dl} {dodge left %0}
|
#alias {dl} {dodge left}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Combat command handlers."""
|
"""Combat command handlers."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -101,7 +102,10 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||||
await defender.send(f"{telegraph}\r\n")
|
await defender.send(f"{telegraph}\r\n")
|
||||||
|
|
||||||
# Detect switch before attack() modifies state
|
# Detect switch before attack() modifies state
|
||||||
switching = encounter.state == CombatState.PENDING
|
switching = encounter.state in (
|
||||||
|
CombatState.TELEGRAPH,
|
||||||
|
CombatState.WINDOW,
|
||||||
|
)
|
||||||
|
|
||||||
# Execute the attack (deducts stamina)
|
# Execute the attack (deducts stamina)
|
||||||
encounter.attack(move)
|
encounter.attack(move)
|
||||||
|
|
@ -123,8 +127,8 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||||
async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
|
async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
|
||||||
"""Core defense logic with a resolved move.
|
"""Core defense logic with a resolved move.
|
||||||
|
|
||||||
Works both in and outside combat. The encounter tracks active/recovery
|
Works both in and outside combat. Applies a recovery lock
|
||||||
windows internally.
|
(based on timing_window_ms) so defenses have commitment.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
player: The defending player
|
player: The defending player
|
||||||
|
|
@ -152,7 +156,7 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
|
||||||
# Check stamina cues after defense cost
|
# Check stamina cues after defense cost
|
||||||
await check_stamina_cues(player)
|
await check_stamina_cues(player)
|
||||||
|
|
||||||
# If in combat, queue/activate the defense on the encounter
|
# If in combat, queue the defense on the encounter
|
||||||
encounter = get_encounter(player)
|
encounter = get_encounter(player)
|
||||||
if encounter is not None:
|
if encounter is not None:
|
||||||
encounter.defend(move)
|
encounter.defend(move)
|
||||||
|
|
@ -167,6 +171,9 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
|
||||||
f"{player.name} {move.command}s!\r\n",
|
f"{player.name} {move.command}s!\r\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Commitment: block for the timing window (inputs queue naturally)
|
||||||
|
await asyncio.sleep(move.timing_window_ms / 1000.0)
|
||||||
|
|
||||||
if encounter is not None:
|
if encounter is not None:
|
||||||
await player.send(f"You {move.name}!\r\n")
|
await player.send(f"You {move.name}!\r\n")
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,15 @@ class CombatState(Enum):
|
||||||
"""States of the combat state machine."""
|
"""States of the combat state machine."""
|
||||||
|
|
||||||
IDLE = "idle"
|
IDLE = "idle"
|
||||||
PENDING = "pending"
|
TELEGRAPH = "telegraph"
|
||||||
|
WINDOW = "window"
|
||||||
RESOLVE = "resolve"
|
RESOLVE = "resolve"
|
||||||
|
|
||||||
|
|
||||||
# Seconds since last landed damage before combat fizzles out
|
# Telegraph phase duration in seconds (3 game ticks at 100ms/tick)
|
||||||
|
TELEGRAPH_DURATION = 0.3
|
||||||
|
|
||||||
|
# Seconds of no action before combat fizzles out
|
||||||
IDLE_TIMEOUT = 30.0
|
IDLE_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -40,60 +44,46 @@ class CombatEncounter:
|
||||||
current_move: CombatMove | None = None
|
current_move: CombatMove | None = None
|
||||||
move_started_at: float = 0.0
|
move_started_at: float = 0.0
|
||||||
pending_defense: CombatMove | None = None
|
pending_defense: CombatMove | None = None
|
||||||
defense_activated_at: float | None = None
|
|
||||||
defense_recovery_until: float | None = None
|
|
||||||
queued_defense: CombatMove | None = None
|
|
||||||
# Monotonic timestamp of most recent landed damage in this encounter.
|
|
||||||
last_action_at: float = 0.0
|
last_action_at: float = 0.0
|
||||||
|
|
||||||
def attack(self, move: CombatMove) -> None:
|
def attack(self, move: CombatMove) -> None:
|
||||||
"""Initiate or switch an attack move.
|
"""Initiate or switch an attack move.
|
||||||
|
|
||||||
If called during PENDING, switches to the new move and restarts
|
If called during TELEGRAPH or WINDOW, switches to the new move
|
||||||
the timer. Refunds old move's stamina cost.
|
without resetting the timer. Refunds old move's stamina cost.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
move: The attack move to execute
|
move: The attack move to execute
|
||||||
"""
|
"""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|
||||||
if self.state == CombatState.PENDING and self.current_move:
|
if self.state in (CombatState.TELEGRAPH, CombatState.WINDOW):
|
||||||
# Switching — refund old cost
|
# Switching — refund old cost, keep timer
|
||||||
|
if self.current_move:
|
||||||
self.attacker.stamina = min(
|
self.attacker.stamina = min(
|
||||||
self.attacker.stamina + self.current_move.stamina_cost,
|
self.attacker.stamina + self.current_move.stamina_cost,
|
||||||
self.attacker.max_stamina,
|
self.attacker.max_stamina,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
# Always restart timer
|
# First attack — start timer
|
||||||
self.move_started_at = now
|
self.move_started_at = now
|
||||||
|
|
||||||
self.current_move = move
|
self.current_move = move
|
||||||
self.attacker.stamina -= move.stamina_cost
|
self.attacker.stamina -= move.stamina_cost
|
||||||
|
self.last_action_at = now
|
||||||
if self.state == CombatState.IDLE:
|
if self.state == CombatState.IDLE:
|
||||||
self.state = CombatState.PENDING
|
self.state = CombatState.TELEGRAPH
|
||||||
|
|
||||||
def defend(self, move: CombatMove) -> None:
|
def defend(self, move: CombatMove) -> None:
|
||||||
"""Queue or activate a defense move.
|
"""Queue a defense move on the encounter.
|
||||||
|
|
||||||
If in recovery, queues the defense. Otherwise activates immediately.
|
Stamina cost and lock are handled by the command layer (do_defend).
|
||||||
Stamina cost is handled by the command layer (do_defend).
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
move: The defense move to attempt
|
move: The defense move to attempt
|
||||||
"""
|
"""
|
||||||
now = time.monotonic()
|
|
||||||
|
|
||||||
# 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.pending_defense = move
|
||||||
self.defense_activated_at = now
|
self.last_action_at = time.monotonic()
|
||||||
self.queued_defense = None
|
|
||||||
|
|
||||||
def tick(self, now: float) -> None:
|
def tick(self, now: float) -> None:
|
||||||
"""Advance the state machine based on current time.
|
"""Advance the state machine based on current time.
|
||||||
|
|
@ -101,45 +91,23 @@ class CombatEncounter:
|
||||||
Args:
|
Args:
|
||||||
now: Current time from monotonic clock
|
now: Current time from monotonic clock
|
||||||
"""
|
"""
|
||||||
# Check if queued defense should activate
|
if self.state == CombatState.TELEGRAPH:
|
||||||
if (
|
# Check if telegraph phase is over
|
||||||
self.queued_defense is not None
|
elapsed = now - self.move_started_at
|
||||||
and self.defense_recovery_until is not None
|
if elapsed >= TELEGRAPH_DURATION:
|
||||||
and now >= self.defense_recovery_until
|
self.state = CombatState.WINDOW
|
||||||
):
|
|
||||||
# Activate queued defense
|
|
||||||
self.pending_defense = self.queued_defense
|
|
||||||
self.defense_activated_at = now
|
|
||||||
self.queued_defense = None
|
|
||||||
self.defense_recovery_until = None
|
|
||||||
|
|
||||||
# Check PENDING -> RESOLVE transition
|
elif self.state == CombatState.WINDOW:
|
||||||
if self.state == CombatState.PENDING:
|
# Check if timing window has expired
|
||||||
if self.current_move is None:
|
if self.current_move is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
elapsed = now - self.move_started_at
|
elapsed = now - self.move_started_at
|
||||||
hit_time_seconds = self.current_move.hit_time_ms / 1000.0
|
window_seconds = self.current_move.timing_window_ms / 1000.0
|
||||||
|
total_time = TELEGRAPH_DURATION + window_seconds
|
||||||
|
|
||||||
if elapsed >= hit_time_seconds:
|
if elapsed >= total_time:
|
||||||
self.state = CombatState.RESOLVE
|
self.state = CombatState.RESOLVE
|
||||||
# Don't expire defense here - resolve() will handle it after checking
|
|
||||||
|
|
||||||
# Only expire defense if NOT in RESOLVE state
|
|
||||||
# (resolve() will clear defense after checking for counters)
|
|
||||||
if (
|
|
||||||
self.state != CombatState.RESOLVE
|
|
||||||
and self.defense_activated_at is not None
|
|
||||||
and self.pending_defense is not None
|
|
||||||
):
|
|
||||||
active_duration = now - self.defense_activated_at
|
|
||||||
active_seconds = self.pending_defense.active_ms / 1000.0
|
|
||||||
if active_duration >= active_seconds:
|
|
||||||
# Defense window expired, enter recovery
|
|
||||||
recovery_seconds = self.pending_defense.recovery_ms / 1000.0
|
|
||||||
self.defense_recovery_until = now + recovery_seconds
|
|
||||||
self.defense_activated_at = None
|
|
||||||
self.pending_defense = None
|
|
||||||
|
|
||||||
def resolve(self) -> ResolveResult:
|
def resolve(self) -> ResolveResult:
|
||||||
"""Resolve the combat exchange and return result.
|
"""Resolve the combat exchange and return result.
|
||||||
|
|
@ -173,16 +141,8 @@ class CombatEncounter:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if defense counters attack
|
# Check if defense counters attack
|
||||||
# Defense must be active (in active window) to succeed
|
|
||||||
defense_is_active = False
|
|
||||||
if self.defense_activated_at is not None and self.pending_defense is not None:
|
|
||||||
active_duration = time.monotonic() - self.defense_activated_at
|
|
||||||
active_window = self.pending_defense.active_ms / 1000.0
|
|
||||||
defense_is_active = active_duration < active_window
|
|
||||||
|
|
||||||
defense_succeeds = (
|
defense_succeeds = (
|
||||||
defense_is_active
|
self.pending_defense
|
||||||
and self.pending_defense is not None
|
|
||||||
and self.pending_defense.name in self.current_move.countered_by
|
and self.pending_defense.name in self.current_move.countered_by
|
||||||
)
|
)
|
||||||
if defense_succeeds:
|
if defense_succeeds:
|
||||||
|
|
@ -197,7 +157,7 @@ class CombatEncounter:
|
||||||
elif self.pending_defense:
|
elif self.pending_defense:
|
||||||
# Wrong defense - normal damage
|
# Wrong defense - normal damage
|
||||||
damage = self.attacker.pl * self.current_move.damage_pct
|
damage = self.attacker.pl * self.current_move.damage_pct
|
||||||
self.defender.pl = max(0.0, self.defender.pl - damage)
|
self.defender.pl -= damage
|
||||||
template = (
|
template = (
|
||||||
self.current_move.resolve_hit
|
self.current_move.resolve_hit
|
||||||
if self.current_move.resolve_hit
|
if self.current_move.resolve_hit
|
||||||
|
|
@ -207,7 +167,7 @@ class CombatEncounter:
|
||||||
else:
|
else:
|
||||||
# No defense - increased damage
|
# No defense - increased damage
|
||||||
damage = self.attacker.pl * self.current_move.damage_pct * 1.5
|
damage = self.attacker.pl * self.current_move.damage_pct * 1.5
|
||||||
self.defender.pl = max(0.0, self.defender.pl - damage)
|
self.defender.pl -= damage
|
||||||
template = (
|
template = (
|
||||||
self.current_move.resolve_hit
|
self.current_move.resolve_hit
|
||||||
if self.current_move.resolve_hit
|
if self.current_move.resolve_hit
|
||||||
|
|
@ -215,16 +175,13 @@ class CombatEncounter:
|
||||||
)
|
)
|
||||||
countered = False
|
countered = False
|
||||||
|
|
||||||
if damage > 0:
|
# Check for combat end conditions
|
||||||
self.last_action_at = time.monotonic()
|
combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0
|
||||||
combat_ended = False
|
|
||||||
|
|
||||||
# Reset to IDLE and clear defense state
|
# Reset to IDLE
|
||||||
# Note: defense_recovery_until persists across attacks
|
|
||||||
self.state = CombatState.IDLE
|
self.state = CombatState.IDLE
|
||||||
self.current_move = None
|
self.current_move = None
|
||||||
self.pending_defense = None
|
self.pending_defense = None
|
||||||
self.defense_activated_at = None
|
|
||||||
|
|
||||||
return ResolveResult(
|
return ResolveResult(
|
||||||
resolve_template=template,
|
resolve_template=template,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import time
|
||||||
|
|
||||||
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
|
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
|
||||||
from mudlib.combat.stamina import check_stamina_cues
|
from mudlib.combat.stamina import check_stamina_cues
|
||||||
from mudlib.entity import Entity
|
from mudlib.entity import Entity, Mob
|
||||||
from mudlib.gmcp import send_char_status, send_char_vitals
|
from mudlib.gmcp import send_char_status, send_char_vitals
|
||||||
from mudlib.render.colors import colorize
|
from mudlib.render.colors import colorize
|
||||||
from mudlib.render.pov import render_pov
|
from mudlib.render.pov import render_pov
|
||||||
|
|
@ -89,7 +89,7 @@ async def process_combat() -> None:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|
||||||
for encounter in active_encounters[:]: # Copy list to allow modification
|
for encounter in active_encounters[:]: # Copy list to allow modification
|
||||||
# Check for no-damage timeout.
|
# Check for idle timeout
|
||||||
if now - encounter.last_action_at > IDLE_TIMEOUT:
|
if now - encounter.last_action_at > IDLE_TIMEOUT:
|
||||||
await encounter.attacker.send("Combat has fizzled out.\r\n")
|
await encounter.attacker.send("Combat has fizzled out.\r\n")
|
||||||
await encounter.defender.send("Combat has fizzled out.\r\n")
|
await encounter.defender.send("Combat has fizzled out.\r\n")
|
||||||
|
|
@ -108,10 +108,10 @@ async def process_combat() -> None:
|
||||||
# Tick the state machine
|
# Tick the state machine
|
||||||
encounter.tick(now)
|
encounter.tick(now)
|
||||||
|
|
||||||
# Send announce message on PENDING → RESOLVE transition
|
# Send announce message on TELEGRAPH → WINDOW transition
|
||||||
if (
|
if (
|
||||||
previous_state == CombatState.PENDING
|
previous_state == CombatState.TELEGRAPH
|
||||||
and encounter.state == CombatState.RESOLVE
|
and encounter.state == CombatState.WINDOW
|
||||||
and encounter.current_move
|
and encounter.current_move
|
||||||
and encounter.current_move.announce
|
and encounter.current_move.announce
|
||||||
):
|
):
|
||||||
|
|
@ -156,3 +156,67 @@ async def process_combat() -> None:
|
||||||
# Check stamina cues after damage
|
# Check stamina cues after damage
|
||||||
await check_stamina_cues(encounter.attacker)
|
await check_stamina_cues(encounter.attacker)
|
||||||
await check_stamina_cues(encounter.defender)
|
await check_stamina_cues(encounter.defender)
|
||||||
|
|
||||||
|
if result.combat_ended:
|
||||||
|
# Determine winner/loser
|
||||||
|
if encounter.defender.pl <= 0:
|
||||||
|
loser = encounter.defender
|
||||||
|
winner = encounter.attacker
|
||||||
|
else:
|
||||||
|
loser = encounter.attacker
|
||||||
|
winner = encounter.defender
|
||||||
|
|
||||||
|
# Track kill/death stats
|
||||||
|
if isinstance(winner, Player):
|
||||||
|
winner.kills += 1
|
||||||
|
if isinstance(loser, Mob):
|
||||||
|
winner.mob_kills[loser.name] = (
|
||||||
|
winner.mob_kills.get(loser.name, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for new unlocks
|
||||||
|
from mudlib.combat.commands import combat_moves
|
||||||
|
from mudlib.combat.unlock import check_unlocks
|
||||||
|
|
||||||
|
newly_unlocked = check_unlocks(winner, combat_moves)
|
||||||
|
for move_name in newly_unlocked:
|
||||||
|
await winner.send(f"You have learned {move_name}!\r\n")
|
||||||
|
|
||||||
|
if isinstance(loser, Player):
|
||||||
|
loser.deaths += 1
|
||||||
|
|
||||||
|
# Despawn mob losers, send victory/defeat messages
|
||||||
|
if isinstance(loser, Mob):
|
||||||
|
from mudlib.corpse import create_corpse
|
||||||
|
from mudlib.mobs import mob_templates
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
zone = loser.location
|
||||||
|
if isinstance(zone, Zone):
|
||||||
|
# Look up loot table from mob template
|
||||||
|
template = mob_templates.get(loser.name)
|
||||||
|
loot_table = template.loot if template else None
|
||||||
|
create_corpse(loser, zone, loot_table=loot_table)
|
||||||
|
else:
|
||||||
|
from mudlib.mobs import despawn_mob
|
||||||
|
|
||||||
|
despawn_mob(loser)
|
||||||
|
await winner.send(f"You have defeated the {loser.name}!\r\n")
|
||||||
|
elif isinstance(winner, Mob):
|
||||||
|
await loser.send(
|
||||||
|
f"You have been defeated by the {winner.name}!\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pop combat mode from both entities if they're Players
|
||||||
|
attacker = encounter.attacker
|
||||||
|
if isinstance(attacker, Player) and attacker.mode == "combat":
|
||||||
|
attacker.mode_stack.pop()
|
||||||
|
send_char_status(attacker)
|
||||||
|
|
||||||
|
defender = encounter.defender
|
||||||
|
if isinstance(defender, Player) and defender.mode == "combat":
|
||||||
|
defender.mode_stack.pop()
|
||||||
|
send_char_status(defender)
|
||||||
|
|
||||||
|
# Remove encounter from active list
|
||||||
|
end_encounter(encounter)
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,7 @@ class CombatMove:
|
||||||
name: str
|
name: str
|
||||||
move_type: str # "attack" or "defense"
|
move_type: str # "attack" or "defense"
|
||||||
stamina_cost: float
|
stamina_cost: float
|
||||||
hit_time_ms: int = 0 # for attacks: ms from initiation to impact
|
timing_window_ms: int
|
||||||
active_ms: int = 0 # for defenses: how long defense blocks once activated
|
|
||||||
recovery_ms: int = 0 # for defenses: lockout after active window ends
|
|
||||||
aliases: list[str] = field(default_factory=list)
|
aliases: list[str] = field(default_factory=list)
|
||||||
telegraph: str = ""
|
telegraph: str = ""
|
||||||
damage_pct: float = 0.0
|
damage_pct: float = 0.0
|
||||||
|
|
@ -71,22 +69,12 @@ def load_move(path: Path) -> list[CombatMove]:
|
||||||
data = tomllib.load(f)
|
data = tomllib.load(f)
|
||||||
|
|
||||||
# Required fields
|
# Required fields
|
||||||
required_fields = ["name", "move_type", "stamina_cost"]
|
required_fields = ["name", "move_type", "stamina_cost", "timing_window_ms"]
|
||||||
for field_name in required_fields:
|
for field_name in required_fields:
|
||||||
if field_name not in data:
|
if field_name not in data:
|
||||||
msg = f"missing required field: {field_name}"
|
msg = f"missing required field: {field_name}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
# Move-type-specific timing validation
|
|
||||||
move_type = data["move_type"]
|
|
||||||
name = data["name"]
|
|
||||||
if move_type == "attack" and data.get("hit_time_ms", 0) <= 0:
|
|
||||||
msg = f"attack move '{name}' requires hit_time_ms > 0"
|
|
||||||
raise ValueError(msg)
|
|
||||||
if move_type == "defense" and data.get("active_ms", 0) <= 0:
|
|
||||||
msg = f"defense move '{name}' requires active_ms > 0"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
base_name = data["name"]
|
base_name = data["name"]
|
||||||
variants = data.get("variants")
|
variants = data.get("variants")
|
||||||
|
|
||||||
|
|
@ -109,12 +97,8 @@ def load_move(path: Path) -> list[CombatMove]:
|
||||||
name=qualified_name,
|
name=qualified_name,
|
||||||
move_type=data["move_type"],
|
move_type=data["move_type"],
|
||||||
stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]),
|
stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]),
|
||||||
hit_time_ms=variant_data.get(
|
timing_window_ms=variant_data.get(
|
||||||
"hit_time_ms", data.get("hit_time_ms", 0)
|
"timing_window_ms", data["timing_window_ms"]
|
||||||
),
|
|
||||||
active_ms=variant_data.get("active_ms", data.get("active_ms", 0)),
|
|
||||||
recovery_ms=variant_data.get(
|
|
||||||
"recovery_ms", data.get("recovery_ms", 0)
|
|
||||||
),
|
),
|
||||||
aliases=variant_data.get("aliases", []),
|
aliases=variant_data.get("aliases", []),
|
||||||
telegraph=variant_data.get("telegraph", data.get("telegraph", "")),
|
telegraph=variant_data.get("telegraph", data.get("telegraph", "")),
|
||||||
|
|
@ -155,9 +139,7 @@ def load_move(path: Path) -> list[CombatMove]:
|
||||||
name=base_name,
|
name=base_name,
|
||||||
move_type=data["move_type"],
|
move_type=data["move_type"],
|
||||||
stamina_cost=data["stamina_cost"],
|
stamina_cost=data["stamina_cost"],
|
||||||
hit_time_ms=data.get("hit_time_ms", 0),
|
timing_window_ms=data["timing_window_ms"],
|
||||||
active_ms=data.get("active_ms", 0),
|
|
||||||
recovery_ms=data.get("recovery_ms", 0),
|
|
||||||
aliases=data.get("aliases", []),
|
aliases=data.get("aliases", []),
|
||||||
telegraph=data.get("telegraph", ""),
|
telegraph=data.get("telegraph", ""),
|
||||||
damage_pct=data.get("damage_pct", 0.0),
|
damage_pct=data.get("damage_pct", 0.0),
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ async def cmd_alias(player: Player, args: str) -> None:
|
||||||
|
|
||||||
# Check if this is a single-word lookup or a definition
|
# Check if this is a single-word lookup or a definition
|
||||||
parts = args.split(None, 1)
|
parts = args.split(None, 1)
|
||||||
alias_name = parts[0].lower()
|
alias_name = parts[0]
|
||||||
|
|
||||||
if len(parts) == 1:
|
if len(parts) == 1:
|
||||||
# Show single alias
|
# Show single alias
|
||||||
|
|
@ -39,7 +39,7 @@ async def cmd_alias(player: Player, args: str) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create alias
|
# Create alias
|
||||||
expansion = parts[1].strip()
|
expansion = parts[1]
|
||||||
|
|
||||||
# Cannot alias over built-in commands
|
# Cannot alias over built-in commands
|
||||||
if alias_name in _registry:
|
if alias_name in _registry:
|
||||||
|
|
@ -56,7 +56,7 @@ async def cmd_unalias(player: Player, args: str) -> None:
|
||||||
Usage:
|
Usage:
|
||||||
unalias <name>
|
unalias <name>
|
||||||
"""
|
"""
|
||||||
alias_name = args.strip().lower()
|
alias_name = args.strip()
|
||||||
|
|
||||||
if not alias_name:
|
if not alias_name:
|
||||||
await player.send("Usage: unalias <name>\r\n")
|
await player.send("Usage: unalias <name>\r\n")
|
||||||
|
|
|
||||||
|
|
@ -3,55 +3,50 @@
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.entity import Entity
|
from mudlib.entity import Entity
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
from mudlib.targeting import find_entity_on_tile, find_in_inventory, find_thing_on_tile
|
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
async def examine_target(
|
def _find_object_in_inventory(name: str, player: Player) -> Thing | Entity | None:
|
||||||
player: Player,
|
"""Find an object in player inventory by name or alias."""
|
||||||
target_name: str,
|
name_lower = name.lower()
|
||||||
*,
|
for obj in player.contents:
|
||||||
prefer_inventory: bool = True,
|
# Only examine Things and Entities
|
||||||
) -> None:
|
if not isinstance(obj, (Thing, Entity)):
|
||||||
"""Resolve and describe a target for examine/look style commands."""
|
continue
|
||||||
zone = player.location if isinstance(player.location, Zone) else None
|
|
||||||
|
|
||||||
# look <thing> should prioritize entities/ground; direct examine keeps
|
# Match by name
|
||||||
# historical inventory-first behavior.
|
if obj.name.lower() == name_lower:
|
||||||
ordered_finders = []
|
return obj
|
||||||
if prefer_inventory:
|
|
||||||
ordered_finders.append(lambda: find_in_inventory(target_name, player))
|
|
||||||
ordered_finders.append(lambda: find_entity_on_tile(target_name, player))
|
|
||||||
if zone is not None:
|
|
||||||
ordered_finders.append(
|
|
||||||
lambda: find_thing_on_tile(target_name, zone, player.x, player.y)
|
|
||||||
)
|
|
||||||
if not prefer_inventory:
|
|
||||||
ordered_finders.append(lambda: find_in_inventory(target_name, player))
|
|
||||||
|
|
||||||
found: Thing | Entity | None = None
|
# Match by alias (Things have aliases)
|
||||||
for finder in ordered_finders:
|
if isinstance(obj, Thing) and name_lower in (a.lower() for a in obj.aliases):
|
||||||
found = finder()
|
return obj
|
||||||
if found is not None:
|
|
||||||
break
|
|
||||||
|
|
||||||
if found is None:
|
return None
|
||||||
await player.send("You don't see that here.\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(found, Entity):
|
|
||||||
if getattr(found, "description", ""):
|
|
||||||
await player.send(f"{found.description}\r\n")
|
|
||||||
else:
|
|
||||||
await player.send(f"{found.name} is {found.posture}.\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
desc = getattr(found, "description", "")
|
def _find_object_at_position(name: str, player: Player) -> Thing | Entity | None:
|
||||||
if desc:
|
"""Find an object on the ground at player position by name or alias."""
|
||||||
await player.send(f"{desc}\r\n")
|
zone = player.location
|
||||||
else:
|
if zone is None or not isinstance(zone, Zone):
|
||||||
await player.send("You see nothing special.\r\n")
|
return None
|
||||||
|
|
||||||
|
name_lower = name.lower()
|
||||||
|
for obj in zone.contents_at(player.x, player.y):
|
||||||
|
# Only examine Things and Entities
|
||||||
|
if not isinstance(obj, (Thing, Entity)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Match by name
|
||||||
|
if obj.name.lower() == name_lower:
|
||||||
|
return obj
|
||||||
|
|
||||||
|
# Match by alias (Things have aliases)
|
||||||
|
if isinstance(obj, Thing) and name_lower in (a.lower() for a in obj.aliases):
|
||||||
|
return obj
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def cmd_examine(player: Player, args: str) -> None:
|
async def cmd_examine(player: Player, args: str) -> None:
|
||||||
|
|
@ -60,7 +55,26 @@ async def cmd_examine(player: Player, args: str) -> None:
|
||||||
await player.send("Examine what?\r\n")
|
await player.send("Examine what?\r\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
await examine_target(player, args.strip(), prefer_inventory=True)
|
target_name = args.strip()
|
||||||
|
|
||||||
|
# Search inventory first
|
||||||
|
found = _find_object_in_inventory(target_name, player)
|
||||||
|
|
||||||
|
# Then search ground
|
||||||
|
if not found:
|
||||||
|
found = _find_object_at_position(target_name, player)
|
||||||
|
|
||||||
|
# Not found anywhere
|
||||||
|
if not found:
|
||||||
|
await player.send("You don't see that here.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show description (both Thing and Entity have description)
|
||||||
|
desc = getattr(found, "description", "")
|
||||||
|
if desc:
|
||||||
|
await player.send(f"{desc}\r\n")
|
||||||
|
else:
|
||||||
|
await player.send("You see nothing special.\r\n")
|
||||||
|
|
||||||
|
|
||||||
register(CommandDefinition("examine", cmd_examine, aliases=["ex"], mode="*"))
|
register(CommandDefinition("examine", cmd_examine, aliases=["ex"], mode="*"))
|
||||||
|
|
|
||||||
|
|
@ -123,11 +123,7 @@ async def _show_single_command(
|
||||||
# Combat move specific details
|
# Combat move specific details
|
||||||
if move is not None:
|
if move is not None:
|
||||||
lines.append(f" stamina: {move.stamina_cost}")
|
lines.append(f" stamina: {move.stamina_cost}")
|
||||||
if move.move_type == "attack":
|
lines.append(f" timing window: {move.timing_window_ms}ms")
|
||||||
lines.append(f" hit time: {move.hit_time_ms}ms")
|
|
||||||
else: # defense
|
|
||||||
lines.append(f" active window: {move.active_ms}ms")
|
|
||||||
lines.append(f" recovery: {move.recovery_ms}ms")
|
|
||||||
if move.damage_pct > 0:
|
if move.damage_pct > 0:
|
||||||
damage_pct = int(move.damage_pct * 100)
|
damage_pct = int(move.damage_pct * 100)
|
||||||
lines.append(f" damage: {damage_pct}%")
|
lines.append(f" damage: {damage_pct}%")
|
||||||
|
|
@ -192,11 +188,7 @@ async def _show_variant_overview(
|
||||||
lines.append(f" aliases: {aliases_str}")
|
lines.append(f" aliases: {aliases_str}")
|
||||||
|
|
||||||
lines.append(f" stamina: {move.stamina_cost}")
|
lines.append(f" stamina: {move.stamina_cost}")
|
||||||
if move.move_type == "attack":
|
lines.append(f" timing window: {move.timing_window_ms}ms")
|
||||||
lines.append(f" hit time: {move.hit_time_ms}ms")
|
|
||||||
else: # defense
|
|
||||||
lines.append(f" active window: {move.active_ms}ms")
|
|
||||||
lines.append(f" recovery: {move.recovery_ms}ms")
|
|
||||||
|
|
||||||
if move.damage_pct > 0:
|
if move.damage_pct > 0:
|
||||||
damage_pct = int(move.damage_pct * 100)
|
damage_pct = int(move.damage_pct * 100)
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,52 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
player: The player executing the command
|
player: The player executing the command
|
||||||
args: Command arguments (if provided, use targeting to resolve)
|
args: Command arguments (if provided, use targeting to resolve)
|
||||||
"""
|
"""
|
||||||
# If args provided, route directly to examine behavior.
|
# If args provided, use targeting to resolve
|
||||||
if args.strip():
|
if args.strip():
|
||||||
from mudlib.commands.examine import examine_target
|
from mudlib.targeting import (
|
||||||
|
find_entity_on_tile,
|
||||||
|
find_in_inventory,
|
||||||
|
find_thing_on_tile,
|
||||||
|
)
|
||||||
|
|
||||||
await examine_target(player, args.strip(), prefer_inventory=False)
|
target_name = args.strip()
|
||||||
|
|
||||||
|
# First try to find an entity on the tile
|
||||||
|
entity = find_entity_on_tile(target_name, player)
|
||||||
|
if entity:
|
||||||
|
# Show entity info (name and posture)
|
||||||
|
if hasattr(entity, "description") and entity.description:
|
||||||
|
await player.send(f"{entity.description}\r\n")
|
||||||
|
else:
|
||||||
|
await player.send(f"{entity.name} is {entity.posture}.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Then try to find a thing on the ground
|
||||||
|
zone = player.location
|
||||||
|
if zone is not None and isinstance(zone, Zone):
|
||||||
|
thing = find_thing_on_tile(target_name, zone, player.x, player.y)
|
||||||
|
if thing:
|
||||||
|
# Show thing description
|
||||||
|
desc = getattr(thing, "description", "")
|
||||||
|
if desc:
|
||||||
|
await player.send(f"{desc}\r\n")
|
||||||
|
else:
|
||||||
|
await player.send("You see nothing special.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Finally try inventory
|
||||||
|
thing = find_in_inventory(target_name, player)
|
||||||
|
if thing:
|
||||||
|
# Show thing description
|
||||||
|
desc = getattr(thing, "description", "")
|
||||||
|
if desc:
|
||||||
|
await player.send(f"{desc}\r\n")
|
||||||
|
else:
|
||||||
|
await player.send("You see nothing special.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Nothing found
|
||||||
|
await player.send("You don't see that here.\r\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
zone = player.location
|
zone = player.location
|
||||||
|
|
@ -179,7 +220,7 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
output.append(render_nearby(nearby_entities, player))
|
output.append(render_nearby(nearby_entities, player))
|
||||||
|
|
||||||
# Exits line
|
# Exits line
|
||||||
output.append(render_exits(zone, player.x, player.y, player))
|
output.append(render_exits(zone, player.x, player.y))
|
||||||
|
|
||||||
# Send to player
|
# Send to player
|
||||||
player.writer.write("\r\n".join(output) + "\r\n")
|
player.writer.write("\r\n".join(output) + "\r\n")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from mudlib.combat.engine import end_encounter, get_encounter
|
from mudlib.combat.engine import end_encounter, get_encounter
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player, players
|
||||||
|
|
||||||
DEATH_PL = -100.0
|
DEATH_PL = -100.0
|
||||||
|
|
||||||
|
|
@ -14,32 +14,37 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
|
||||||
player: The player executing the command
|
player: The player executing the command
|
||||||
args: Target name
|
args: Target name
|
||||||
"""
|
"""
|
||||||
|
# Get encounter
|
||||||
|
encounter = get_encounter(player)
|
||||||
|
if encounter is None:
|
||||||
|
await player.send("You're not in combat.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
# Parse target
|
# Parse target
|
||||||
target_name = args.strip()
|
target_name = args.strip()
|
||||||
if not target_name:
|
if not target_name:
|
||||||
await player.send("Snap whose neck?\r\n")
|
await player.send("Snap whose neck?\r\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Must be used during an active encounter.
|
# Find target
|
||||||
encounter = get_encounter(player)
|
target = players.get(target_name)
|
||||||
if encounter is None:
|
if target is None and player.location is not None:
|
||||||
await player.send("You're not in combat.\r\n")
|
from mudlib.mobs import get_nearby_mob
|
||||||
return
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
# Find target on this tile.
|
if isinstance(player.location, Zone):
|
||||||
from mudlib.targeting import find_entity_on_tile
|
target = get_nearby_mob(target_name, player.x, player.y, player.location)
|
||||||
|
|
||||||
target = find_entity_on_tile(target_name, player)
|
|
||||||
if target is None:
|
if target is None:
|
||||||
await player.send(f"You don't see {target_name} here.\r\n")
|
await player.send(f"You don't see {target_name} here.\r\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
if target is player:
|
# Verify target is in the encounter
|
||||||
await player.send("You can't do that to yourself.\r\n")
|
if encounter.attacker is not player and encounter.defender is not player:
|
||||||
|
await player.send("You're not in combat with that target.\r\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Snap neck can only target your current opponent.
|
if encounter.attacker is not target and encounter.defender is not target:
|
||||||
if target not in (encounter.attacker, encounter.defender):
|
|
||||||
await player.send("You're not in combat with that target.\r\n")
|
await player.send("You're not in combat with that target.\r\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -59,38 +64,16 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
|
||||||
from mudlib.entity import Mob
|
from mudlib.entity import Mob
|
||||||
from mudlib.gmcp import send_char_vitals
|
from mudlib.gmcp import send_char_vitals
|
||||||
|
|
||||||
if isinstance(target, Player):
|
if not isinstance(target, Mob):
|
||||||
send_char_vitals(target)
|
send_char_vitals(target)
|
||||||
|
|
||||||
# Award kill/death stats on explicit finishers only.
|
# Handle mob despawn
|
||||||
player.kills += 1
|
|
||||||
if isinstance(target, Player):
|
|
||||||
target.deaths += 1
|
|
||||||
elif isinstance(target, Mob):
|
|
||||||
player.mob_kills[target.name] = player.mob_kills.get(target.name, 0) + 1
|
|
||||||
# Check for newly unlocked moves after a finisher kill.
|
|
||||||
from mudlib.combat.commands import combat_moves
|
|
||||||
from mudlib.combat.unlock import check_unlocks
|
|
||||||
|
|
||||||
newly_unlocked = check_unlocks(player, combat_moves)
|
|
||||||
for move_name in newly_unlocked:
|
|
||||||
await player.send(f"You have learned {move_name}!\r\n")
|
|
||||||
|
|
||||||
# Handle mob corpse/death
|
|
||||||
if isinstance(target, Mob):
|
if isinstance(target, Mob):
|
||||||
from mudlib.corpse import create_corpse
|
from mudlib.mobs import despawn_mob
|
||||||
from mudlib.mobs import despawn_mob, mob_templates
|
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
zone = target.location
|
|
||||||
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)
|
despawn_mob(target)
|
||||||
|
|
||||||
# Pop combat mode from both entities.
|
# Pop combat mode from both entities if they're Players
|
||||||
from mudlib.gmcp import send_char_status
|
from mudlib.gmcp import send_char_status
|
||||||
|
|
||||||
if isinstance(player, Player) and player.mode == "combat":
|
if isinstance(player, Player) and player.mode == "combat":
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,11 @@ async def process_mobs(combat_moves: dict[str, CombatMove]) -> None:
|
||||||
# Determine if mob is attacker or defender in this encounter
|
# Determine if mob is attacker or defender in this encounter
|
||||||
mob_is_defender = encounter.defender is mob
|
mob_is_defender = encounter.defender is mob
|
||||||
|
|
||||||
# Defense AI: react during PENDING when mob is defender
|
# Defense AI: react during TELEGRAPH or WINDOW when mob is defender
|
||||||
if mob_is_defender and encounter.state == CombatState.PENDING:
|
if mob_is_defender and encounter.state in (
|
||||||
|
CombatState.TELEGRAPH,
|
||||||
|
CombatState.WINDOW,
|
||||||
|
):
|
||||||
_try_defend(mob, encounter, combat_moves, now)
|
_try_defend(mob, encounter, combat_moves, now)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ def render_nearby(entities: list, viewer) -> str:
|
||||||
return f"Nearby: ({count}) {names}"
|
return f"Nearby: ({count}) {names}"
|
||||||
|
|
||||||
|
|
||||||
def render_exits(zone, x: int, y: int, viewer=None) -> str:
|
def render_exits(zone, x: int, y: int) -> str:
|
||||||
"""Render available exits from current position.
|
"""Render available exits from current position.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -94,6 +94,7 @@ def render_exits(zone, x: int, y: int, viewer=None) -> str:
|
||||||
exits = []
|
exits = []
|
||||||
|
|
||||||
# Check cardinal directions
|
# Check cardinal directions
|
||||||
|
# NOTE: up/down exits deferred until z-axis movement is implemented
|
||||||
if zone.is_passable(x, y - 1): # north (y decreases going up)
|
if zone.is_passable(x, y - 1): # north (y decreases going up)
|
||||||
exits.append("north")
|
exits.append("north")
|
||||||
if zone.is_passable(x, y + 1): # south
|
if zone.is_passable(x, y + 1): # south
|
||||||
|
|
@ -102,11 +103,6 @@ def render_exits(zone, x: int, y: int, viewer=None) -> str:
|
||||||
exits.append("east")
|
exits.append("east")
|
||||||
if zone.is_passable(x - 1, y): # west
|
if zone.is_passable(x - 1, y): # west
|
||||||
exits.append("west")
|
exits.append("west")
|
||||||
# Vertical exit is based on current altitude state.
|
|
||||||
if viewer is not None and getattr(viewer, "flying", False):
|
|
||||||
exits.append("down")
|
|
||||||
elif viewer is not None:
|
|
||||||
exits.append("up")
|
|
||||||
|
|
||||||
if exits:
|
if exits:
|
||||||
return f"Exits: {' '.join(exits)}"
|
return f"Exits: {' '.join(exits)}"
|
||||||
|
|
@ -115,7 +111,6 @@ def render_exits(zone, x: int, y: int, viewer=None) -> str:
|
||||||
|
|
||||||
_POSTURE_MESSAGES = {
|
_POSTURE_MESSAGES = {
|
||||||
"standing": "is standing here.",
|
"standing": "is standing here.",
|
||||||
"sleeping": "is sleeping here.",
|
|
||||||
"resting": "is resting here.",
|
"resting": "is resting here.",
|
||||||
"flying": "is flying above.",
|
"flying": "is flying above.",
|
||||||
"fighting": "is fighting here.",
|
"fighting": "is fighting here.",
|
||||||
|
|
|
||||||
|
|
@ -502,8 +502,7 @@ def load_aliases(name: str, db_path: str | Path | None = None) -> dict[str, str]
|
||||||
(name,),
|
(name,),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalize keys to lowercase so dispatch can match consistently.
|
result = {alias: expansion for alias, expansion in cursor.fetchall()}
|
||||||
result = {alias.lower(): expansion for alias, expansion in cursor.fetchall()}
|
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,19 @@ def zone():
|
||||||
return z
|
return z
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
def make_player(name, zone, mock_writer, mock_reader, is_admin=False):
|
def make_player(name, zone, mock_writer, mock_reader, is_admin=False):
|
||||||
p = Player(
|
p = Player(
|
||||||
name=name,
|
name=name,
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,11 @@ def test_save_and_load_aliases_roundtrip():
|
||||||
db_path = Path(tmpdir) / "test.db"
|
db_path = Path(tmpdir) / "test.db"
|
||||||
init_db(db_path)
|
init_db(db_path)
|
||||||
|
|
||||||
aliases = {"PR": "punch right", "pl": "punch left", "l": "look"}
|
aliases = {"pr": "punch right", "pl": "punch left", "l": "look"}
|
||||||
save_aliases("goku", aliases, db_path)
|
save_aliases("goku", aliases, db_path)
|
||||||
|
|
||||||
loaded = load_aliases("goku", db_path)
|
loaded = load_aliases("goku", db_path)
|
||||||
assert loaded == {"pr": "punch right", "pl": "punch left", "l": "look"}
|
assert loaded == aliases
|
||||||
|
|
||||||
|
|
||||||
def test_load_aliases_empty():
|
def test_load_aliases_empty():
|
||||||
|
|
@ -76,16 +76,6 @@ async def test_alias_create(player):
|
||||||
player.writer.write.assert_called_with("Alias set: pr -> punch right\r\n")
|
player.writer.write.assert_called_with("Alias set: pr -> punch right\r\n")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_alias_normalizes_name_to_lowercase(player):
|
|
||||||
"""Alias names are normalized so dispatch lookup is consistent."""
|
|
||||||
from mudlib.commands.alias import cmd_alias
|
|
||||||
|
|
||||||
await cmd_alias(player, "PR punch right")
|
|
||||||
assert "pr" in player.aliases
|
|
||||||
assert "PR" not in player.aliases
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_alias_list_with_aliases(player):
|
async def test_alias_list_with_aliases(player):
|
||||||
"""alias with no args lists all aliases."""
|
"""alias with no args lists all aliases."""
|
||||||
|
|
@ -123,16 +113,6 @@ async def test_unalias_removes_alias(player):
|
||||||
player.writer.write.assert_called_with("Alias removed: pr\r\n")
|
player.writer.write.assert_called_with("Alias removed: pr\r\n")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unalias_is_case_insensitive(player):
|
|
||||||
"""unalias should remove aliases regardless of case."""
|
|
||||||
from mudlib.commands.alias import cmd_unalias
|
|
||||||
|
|
||||||
player.aliases = {"pr": "punch right"}
|
|
||||||
await cmd_unalias(player, "PR")
|
|
||||||
assert "pr" not in player.aliases
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unalias_no_such_alias(player):
|
async def test_unalias_no_such_alias(player):
|
||||||
"""unalias on non-existent alias shows error."""
|
"""unalias on non-existent alias shows error."""
|
||||||
|
|
@ -155,18 +135,6 @@ async def test_alias_cannot_override_builtin(player):
|
||||||
assert "look" not in player.aliases
|
assert "look" not in player.aliases
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_alias_builtin_collision_is_case_insensitive(player):
|
|
||||||
"""Built-in collision checks should apply regardless of alias casing."""
|
|
||||||
import mudlib.commands.look # noqa: F401 - needed to register look command
|
|
||||||
from mudlib.commands.alias import cmd_alias
|
|
||||||
|
|
||||||
await cmd_alias(player, "LOOK punch right")
|
|
||||||
player.writer.write.assert_called_with(
|
|
||||||
"Cannot alias over built-in command: look\r\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Dispatch integration tests
|
# Dispatch integration tests
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_alias_expands_in_dispatch(player):
|
async def test_alias_expands_in_dispatch(player):
|
||||||
|
|
@ -183,21 +151,6 @@ async def test_alias_expands_in_dispatch(player):
|
||||||
assert called_with == ["hello"]
|
assert called_with == ["hello"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_alias_expands_in_dispatch_case_insensitive_key(player):
|
|
||||||
"""Stored aliases with uppercase keys still load/use as lowercase."""
|
|
||||||
called_with = []
|
|
||||||
|
|
||||||
async def test_handler(p, args):
|
|
||||||
called_with.append(args)
|
|
||||||
|
|
||||||
register(CommandDefinition("testcmd", test_handler))
|
|
||||||
player.aliases["pr"] = "testcmd"
|
|
||||||
|
|
||||||
await dispatch(player, "PR hello")
|
|
||||||
assert called_with == ["hello"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_alias_with_extra_args(player):
|
async def test_alias_with_extra_args(player):
|
||||||
"""Alias expansion preserves additional arguments."""
|
"""Alias expansion preserves additional arguments."""
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""Tests for builder commands."""
|
"""Tests for builder commands."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
|
|
@ -24,6 +26,19 @@ def zone():
|
||||||
return z
|
return z
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(zone, mock_writer, mock_reader):
|
def player(zone, mock_writer, mock_reader):
|
||||||
p = Player(
|
p = Player(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -37,6 +38,19 @@ def test_zone():
|
||||||
return zone
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
|
@ -331,14 +345,14 @@ async def test_switch_attack_sends_new_telegraph(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_defense_does_not_block(player, dodge_left):
|
async def test_defense_blocks_for_timing_window(player, dodge_left):
|
||||||
"""Test defense no longer blocks (encounter tracks active/recovery internally)."""
|
"""Test defense sleeps for timing_window_ms (commitment via blocking)."""
|
||||||
before = time.monotonic()
|
before = time.monotonic()
|
||||||
await combat_commands.do_defend(player, "", dodge_left)
|
await combat_commands.do_defend(player, "", dodge_left)
|
||||||
elapsed = time.monotonic() - before
|
elapsed = time.monotonic() - before
|
||||||
|
|
||||||
# Should return immediately, not block for active_ms
|
expected = dodge_left.timing_window_ms / 1000.0
|
||||||
assert elapsed < 0.1 # Allow for some overhead
|
assert elapsed >= expected - 0.05
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def punch():
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
hit_time_ms=800,
|
timing_window_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left", "parry high"],
|
countered_by=["dodge left", "parry high"],
|
||||||
)
|
)
|
||||||
|
|
@ -37,8 +37,7 @@ def dodge():
|
||||||
name="dodge left",
|
name="dodge left",
|
||||||
move_type="defense",
|
move_type="defense",
|
||||||
stamina_cost=3.0,
|
stamina_cost=3.0,
|
||||||
active_ms=800,
|
timing_window_ms=800,
|
||||||
recovery_ms=2700,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -48,8 +47,7 @@ def wrong_dodge():
|
||||||
name="dodge right",
|
name="dodge right",
|
||||||
move_type="defense",
|
move_type="defense",
|
||||||
stamina_cost=3.0,
|
stamina_cost=3.0,
|
||||||
active_ms=800,
|
timing_window_ms=800,
|
||||||
recovery_ms=2700,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -59,7 +57,7 @@ def sweep():
|
||||||
name="sweep",
|
name="sweep",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=8.0,
|
stamina_cost=8.0,
|
||||||
hit_time_ms=600,
|
timing_window_ms=600,
|
||||||
damage_pct=0.20,
|
damage_pct=0.20,
|
||||||
countered_by=["jump"],
|
countered_by=["jump"],
|
||||||
)
|
)
|
||||||
|
|
@ -74,12 +72,12 @@ def test_combat_encounter_initial_state(attacker, defender):
|
||||||
assert encounter.move_started_at == 0.0
|
assert encounter.move_started_at == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_attack_transitions_to_pending(attacker, defender, punch):
|
def test_attack_transitions_to_telegraph(attacker, defender, punch):
|
||||||
"""Test attacking transitions to PENDING state."""
|
"""Test attacking transitions to TELEGRAPH state."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
|
|
||||||
assert encounter.state == CombatState.PENDING
|
assert encounter.state == CombatState.TELEGRAPH
|
||||||
assert encounter.current_move is punch
|
assert encounter.current_move is punch
|
||||||
assert encounter.move_started_at > 0.0
|
assert encounter.move_started_at > 0.0
|
||||||
|
|
||||||
|
|
@ -94,13 +92,12 @@ def test_attack_applies_stamina_cost(attacker, defender, punch):
|
||||||
|
|
||||||
|
|
||||||
def test_defend_records_pending_defense(attacker, defender, punch, dodge):
|
def test_defend_records_pending_defense(attacker, defender, punch, dodge):
|
||||||
"""Test defend records the defense move and activates it."""
|
"""Test defend records the defense move."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
encounter.defend(dodge)
|
encounter.defend(dodge)
|
||||||
|
|
||||||
assert encounter.pending_defense is dodge
|
assert encounter.pending_defense is dodge
|
||||||
assert encounter.defense_activated_at is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
|
def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
|
||||||
|
|
@ -114,12 +111,29 @@ def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
|
||||||
assert defender.stamina == initial_stamina
|
assert defender.stamina == initial_stamina
|
||||||
|
|
||||||
|
|
||||||
def test_tick_pending_to_resolve(attacker, defender, punch):
|
def test_tick_telegraph_to_window(attacker, defender, punch):
|
||||||
"""Test tick advances from PENDING to RESOLVE after hit time."""
|
"""Test tick advances from TELEGRAPH to WINDOW after brief delay."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
|
|
||||||
# Wait for hit_time_ms (800ms)
|
# Wait for telegraph phase (300ms)
|
||||||
|
time.sleep(0.31)
|
||||||
|
now = time.monotonic()
|
||||||
|
encounter.tick(now)
|
||||||
|
|
||||||
|
assert encounter.state == CombatState.WINDOW
|
||||||
|
|
||||||
|
|
||||||
|
def test_tick_window_to_resolve(attacker, defender, punch):
|
||||||
|
"""Test tick advances from WINDOW to RESOLVE after timing window."""
|
||||||
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
encounter.attack(punch)
|
||||||
|
|
||||||
|
# Skip to WINDOW state
|
||||||
|
time.sleep(0.31)
|
||||||
|
encounter.tick(time.monotonic())
|
||||||
|
|
||||||
|
# Wait for timing window to expire (800ms)
|
||||||
time.sleep(0.85)
|
time.sleep(0.85)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
encounter.tick(now)
|
encounter.tick(now)
|
||||||
|
|
@ -198,11 +212,16 @@ def test_full_state_machine_cycle(attacker, defender, punch):
|
||||||
"""Test complete state machine cycle from IDLE to IDLE."""
|
"""Test complete state machine cycle from IDLE to IDLE."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
|
||||||
# IDLE → PENDING
|
# IDLE → TELEGRAPH
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
assert encounter.state == CombatState.PENDING
|
assert encounter.state == CombatState.TELEGRAPH
|
||||||
|
|
||||||
# PENDING → RESOLVE (after hit_time_ms)
|
# TELEGRAPH → WINDOW
|
||||||
|
time.sleep(0.31)
|
||||||
|
encounter.tick(time.monotonic())
|
||||||
|
assert encounter.state == CombatState.WINDOW
|
||||||
|
|
||||||
|
# WINDOW → RESOLVE
|
||||||
time.sleep(0.85)
|
time.sleep(0.85)
|
||||||
encounter.tick(time.monotonic())
|
encounter.tick(time.monotonic())
|
||||||
assert encounter.state == CombatState.RESOLVE
|
assert encounter.state == CombatState.RESOLVE
|
||||||
|
|
@ -215,12 +234,13 @@ def test_full_state_machine_cycle(attacker, defender, punch):
|
||||||
def test_combat_state_enum():
|
def test_combat_state_enum():
|
||||||
"""Test CombatState enum values."""
|
"""Test CombatState enum values."""
|
||||||
assert CombatState.IDLE.value == "idle"
|
assert CombatState.IDLE.value == "idle"
|
||||||
assert CombatState.PENDING.value == "pending"
|
assert CombatState.TELEGRAPH.value == "telegraph"
|
||||||
|
assert CombatState.WINDOW.value == "window"
|
||||||
assert CombatState.RESOLVE.value == "resolve"
|
assert CombatState.RESOLVE.value == "resolve"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_knockout_does_not_end_combat(attacker, defender, punch):
|
def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch):
|
||||||
"""KO should not end combat by itself."""
|
"""Test resolve returns combat_ended=True when defender PL <= 0."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
|
||||||
# Set defender to low PL so attack will knock them out
|
# Set defender to low PL so attack will knock them out
|
||||||
|
|
@ -230,12 +250,12 @@ def test_resolve_knockout_does_not_end_combat(attacker, defender, punch):
|
||||||
result = encounter.resolve()
|
result = encounter.resolve()
|
||||||
|
|
||||||
assert defender.pl <= 0
|
assert defender.pl <= 0
|
||||||
assert result.combat_ended is False
|
assert result.combat_ended is True
|
||||||
assert result.damage > 0
|
assert result.damage > 0
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_exhaustion_does_not_end_combat(attacker, defender, punch):
|
def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch):
|
||||||
"""Exhaustion should not end combat by itself."""
|
"""Test resolve returns combat_ended=True when attacker stamina <= 0."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
|
||||||
# Set attacker stamina to exactly the cost so attack depletes it
|
# Set attacker stamina to exactly the cost so attack depletes it
|
||||||
|
|
@ -245,19 +265,7 @@ def test_resolve_exhaustion_does_not_end_combat(attacker, defender, punch):
|
||||||
result = encounter.resolve()
|
result = encounter.resolve()
|
||||||
|
|
||||||
assert attacker.stamina <= 0
|
assert attacker.stamina <= 0
|
||||||
assert result.combat_ended is False
|
assert result.combat_ended is True
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_exhausted_defender_does_not_end_combat(attacker, defender, punch):
|
|
||||||
"""Exhausted defender still does not auto-end encounter."""
|
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
||||||
defender.stamina = 0.0
|
|
||||||
|
|
||||||
encounter.attack(punch)
|
|
||||||
result = encounter.resolve()
|
|
||||||
|
|
||||||
assert defender.stamina <= 0
|
|
||||||
assert result.combat_ended is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
|
def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
|
||||||
|
|
@ -304,22 +312,41 @@ def test_resolve_counter_template_indicates_counter(attacker, defender, punch, d
|
||||||
# --- Attack switching (feint) tests ---
|
# --- Attack switching (feint) tests ---
|
||||||
|
|
||||||
|
|
||||||
def test_switch_attack_during_pending(attacker, defender, punch, sweep):
|
def test_switch_attack_during_telegraph(attacker, defender, punch, sweep):
|
||||||
"""Test attack during PENDING replaces move and restarts timer."""
|
"""Test attack during TELEGRAPH replaces move and keeps timer."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
original_start = encounter.move_started_at
|
original_start = encounter.move_started_at
|
||||||
|
|
||||||
assert encounter.state == CombatState.PENDING
|
assert encounter.state == CombatState.TELEGRAPH
|
||||||
|
|
||||||
# Switch to sweep during pending
|
# Switch to sweep during telegraph
|
||||||
time.sleep(0.1) # Small delay to ensure timer would differ
|
|
||||||
encounter.attack(sweep)
|
encounter.attack(sweep)
|
||||||
|
|
||||||
assert encounter.current_move is sweep
|
assert encounter.current_move is sweep
|
||||||
assert encounter.state == CombatState.PENDING
|
assert encounter.state == CombatState.TELEGRAPH
|
||||||
# Timer should restart on switch
|
# Timer should NOT restart
|
||||||
assert encounter.move_started_at > original_start
|
assert encounter.move_started_at == original_start
|
||||||
|
|
||||||
|
|
||||||
|
def test_switch_attack_during_window(attacker, defender, punch, sweep):
|
||||||
|
"""Test attack during WINDOW replaces move and keeps timer."""
|
||||||
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
encounter.attack(punch)
|
||||||
|
original_start = encounter.move_started_at
|
||||||
|
|
||||||
|
# Advance to WINDOW
|
||||||
|
time.sleep(0.31)
|
||||||
|
encounter.tick(time.monotonic())
|
||||||
|
assert encounter.state == CombatState.WINDOW
|
||||||
|
|
||||||
|
# Switch to sweep during window
|
||||||
|
encounter.attack(sweep)
|
||||||
|
|
||||||
|
assert encounter.current_move is sweep
|
||||||
|
assert encounter.state == CombatState.WINDOW
|
||||||
|
# Timer should NOT restart
|
||||||
|
assert encounter.move_started_at == original_start
|
||||||
|
|
||||||
|
|
||||||
def test_switch_refunds_old_stamina(attacker, defender, punch, sweep):
|
def test_switch_refunds_old_stamina(attacker, defender, punch, sweep):
|
||||||
|
|
@ -366,288 +393,27 @@ def test_resolve_uses_final_move(attacker, defender, punch, sweep):
|
||||||
assert result.resolve_template != ""
|
assert result.resolve_template != ""
|
||||||
|
|
||||||
|
|
||||||
# --- last_action_at (last landed damage) tracking tests ---
|
# --- last_action_at tracking tests ---
|
||||||
|
|
||||||
|
|
||||||
def test_last_action_at_not_updated_on_attack(attacker, defender, punch):
|
def test_last_action_at_updates_on_attack(attacker, defender, punch):
|
||||||
"""Attack startup should not reset timeout until damage lands."""
|
"""Test last_action_at is set when attack() is called."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
assert encounter.last_action_at == 0.0
|
assert encounter.last_action_at == 0.0
|
||||||
|
|
||||||
encounter.attack(punch)
|
|
||||||
|
|
||||||
assert encounter.last_action_at == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_last_action_at_not_updated_on_defend(attacker, defender, punch, dodge):
|
|
||||||
"""Defense input should not reset timeout without landed damage."""
|
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
||||||
encounter.attack(punch)
|
|
||||||
assert encounter.last_action_at == 0.0
|
|
||||||
encounter.defend(dodge)
|
|
||||||
|
|
||||||
assert encounter.last_action_at == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_last_action_at_updates_when_damage_lands(attacker, defender, punch):
|
|
||||||
"""Landed damage should refresh timeout timestamp."""
|
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
||||||
assert encounter.last_action_at == 0.0
|
|
||||||
encounter.attack(punch)
|
|
||||||
before = time.monotonic()
|
before = time.monotonic()
|
||||||
encounter.resolve()
|
encounter.attack(punch)
|
||||||
|
|
||||||
assert encounter.last_action_at >= before
|
assert encounter.last_action_at >= before
|
||||||
|
|
||||||
|
|
||||||
def test_last_action_at_unchanged_when_attack_is_countered(
|
def test_last_action_at_updates_on_defend(attacker, defender, punch, dodge):
|
||||||
attacker, defender, punch, dodge
|
"""Test last_action_at is set when defend() is called."""
|
||||||
):
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
"""No damage (successful counter) should not refresh timeout timestamp."""
|
|
||||||
encounter = CombatEncounter(
|
|
||||||
attacker=attacker, defender=defender, last_action_at=10.0
|
|
||||||
)
|
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
|
first_action = encounter.last_action_at
|
||||||
|
|
||||||
|
time.sleep(0.01)
|
||||||
encounter.defend(dodge)
|
encounter.defend(dodge)
|
||||||
encounter.resolve()
|
|
||||||
assert encounter.last_action_at == 10.0
|
|
||||||
|
|
||||||
|
assert encounter.last_action_at > first_action
|
||||||
# --- Defense active/recovery window tests ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_defense_expired_before_resolve(attacker, defender):
|
|
||||||
"""Defense activates, active_ms passes, attack resolves.
|
|
||||||
|
|
||||||
Defense should NOT counter (hit lands).
|
|
||||||
"""
|
|
||||||
# Create attack with 800ms hit time
|
|
||||||
quick_attack = CombatMove(
|
|
||||||
name="quick punch",
|
|
||||||
move_type="attack",
|
|
||||||
stamina_cost=5.0,
|
|
||||||
hit_time_ms=800,
|
|
||||||
damage_pct=0.15,
|
|
||||||
countered_by=["quick dodge"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create defense with short active window (200ms)
|
|
||||||
quick_dodge = CombatMove(
|
|
||||||
name="quick dodge",
|
|
||||||
move_type="defense",
|
|
||||||
stamina_cost=3.0,
|
|
||||||
active_ms=200,
|
|
||||||
recovery_ms=300,
|
|
||||||
)
|
|
||||||
|
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
||||||
encounter.attack(quick_attack)
|
|
||||||
encounter.defend(quick_dodge)
|
|
||||||
|
|
||||||
# Wait for defense to expire (200ms) but before attack lands (800ms)
|
|
||||||
time.sleep(0.31)
|
|
||||||
encounter.tick(time.monotonic())
|
|
||||||
|
|
||||||
# Now resolve — defense should be expired
|
|
||||||
initial_pl = defender.pl
|
|
||||||
result = encounter.resolve()
|
|
||||||
|
|
||||||
# Defense expired, so attack should hit for full damage
|
|
||||||
expected_damage = attacker.pl * quick_attack.damage_pct * 1.5
|
|
||||||
assert defender.pl == initial_pl - expected_damage
|
|
||||||
assert result.damage == expected_damage
|
|
||||||
assert result.countered is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_defense_active_during_resolve(attacker, defender):
|
|
||||||
"""Defense activates within active_ms of resolve — defense SHOULD counter."""
|
|
||||||
# Create attack with 800ms hit time
|
|
||||||
quick_attack = CombatMove(
|
|
||||||
name="quick punch",
|
|
||||||
move_type="attack",
|
|
||||||
stamina_cost=5.0,
|
|
||||||
hit_time_ms=800,
|
|
||||||
damage_pct=0.15,
|
|
||||||
countered_by=["quick dodge"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create defense with long active window (1000ms)
|
|
||||||
quick_dodge = CombatMove(
|
|
||||||
name="quick dodge",
|
|
||||||
move_type="defense",
|
|
||||||
stamina_cost=3.0,
|
|
||||||
active_ms=1000,
|
|
||||||
recovery_ms=300,
|
|
||||||
)
|
|
||||||
|
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
||||||
encounter.attack(quick_attack)
|
|
||||||
encounter.defend(quick_dodge)
|
|
||||||
|
|
||||||
# Immediately resolve — defense is still active
|
|
||||||
initial_pl = defender.pl
|
|
||||||
result = encounter.resolve()
|
|
||||||
|
|
||||||
# Defense is active, should counter successfully
|
|
||||||
assert defender.pl == initial_pl
|
|
||||||
assert result.damage == 0.0
|
|
||||||
assert result.countered is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_defense_queuing_during_recovery(attacker, defender):
|
|
||||||
"""defend() while in recovery should set queued_defense, not pending_defense."""
|
|
||||||
quick_dodge = CombatMove(
|
|
||||||
name="quick dodge",
|
|
||||||
move_type="defense",
|
|
||||||
stamina_cost=3.0,
|
|
||||||
active_ms=200,
|
|
||||||
recovery_ms=300,
|
|
||||||
)
|
|
||||||
|
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
||||||
|
|
||||||
# First defend — activates immediately
|
|
||||||
encounter.defend(quick_dodge)
|
|
||||||
assert encounter.pending_defense is quick_dodge
|
|
||||||
assert encounter.defense_activated_at is not None
|
|
||||||
|
|
||||||
# Wait for defense to expire and enter recovery
|
|
||||||
time.sleep(0.31)
|
|
||||||
encounter.tick(time.monotonic())
|
|
||||||
|
|
||||||
assert encounter.pending_defense is None
|
|
||||||
assert encounter.defense_recovery_until is not None
|
|
||||||
|
|
||||||
# Try to defend during recovery — should queue
|
|
||||||
second_dodge = CombatMove(
|
|
||||||
name="second dodge",
|
|
||||||
move_type="defense",
|
|
||||||
stamina_cost=3.0,
|
|
||||||
active_ms=200,
|
|
||||||
recovery_ms=300,
|
|
||||||
)
|
|
||||||
encounter.defend(second_dodge)
|
|
||||||
|
|
||||||
# Should be queued, not pending
|
|
||||||
assert encounter.pending_defense is None
|
|
||||||
assert encounter.queued_defense is second_dodge
|
|
||||||
|
|
||||||
|
|
||||||
def test_queued_defense_activates_after_recovery(attacker, defender):
|
|
||||||
"""After recovery_ms passes, tick() should activate the queued defense."""
|
|
||||||
quick_dodge = CombatMove(
|
|
||||||
name="quick dodge",
|
|
||||||
move_type="defense",
|
|
||||||
stamina_cost=3.0,
|
|
||||||
active_ms=200,
|
|
||||||
recovery_ms=300,
|
|
||||||
)
|
|
||||||
|
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
||||||
|
|
||||||
# First defend
|
|
||||||
encounter.defend(quick_dodge)
|
|
||||||
|
|
||||||
# Wait for defense to expire
|
|
||||||
time.sleep(0.31)
|
|
||||||
encounter.tick(time.monotonic())
|
|
||||||
|
|
||||||
# Queue second defense during recovery
|
|
||||||
second_dodge = CombatMove(
|
|
||||||
name="second dodge",
|
|
||||||
move_type="defense",
|
|
||||||
stamina_cost=3.0,
|
|
||||||
active_ms=200,
|
|
||||||
recovery_ms=300,
|
|
||||||
)
|
|
||||||
encounter.defend(second_dodge)
|
|
||||||
|
|
||||||
assert encounter.queued_defense is second_dodge
|
|
||||||
assert encounter.pending_defense is None
|
|
||||||
|
|
||||||
# Wait for recovery to finish
|
|
||||||
time.sleep(0.31)
|
|
||||||
encounter.tick(time.monotonic())
|
|
||||||
|
|
||||||
# Queued defense should now be active
|
|
||||||
assert encounter.pending_defense is second_dodge
|
|
||||||
assert encounter.queued_defense is None
|
|
||||||
assert encounter.defense_activated_at is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_persists_after_resolve(attacker, defender):
|
|
||||||
"""resolve() should NOT clear defense_recovery_until.
|
|
||||||
|
|
||||||
Recovery carries across attacks.
|
|
||||||
"""
|
|
||||||
quick_attack = CombatMove(
|
|
||||||
name="quick punch",
|
|
||||||
move_type="attack",
|
|
||||||
stamina_cost=5.0,
|
|
||||||
hit_time_ms=800,
|
|
||||||
damage_pct=0.15,
|
|
||||||
countered_by=["quick dodge"],
|
|
||||||
)
|
|
||||||
|
|
||||||
quick_dodge = CombatMove(
|
|
||||||
name="quick dodge",
|
|
||||||
move_type="defense",
|
|
||||||
stamina_cost=3.0,
|
|
||||||
active_ms=200,
|
|
||||||
recovery_ms=300,
|
|
||||||
)
|
|
||||||
|
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
||||||
|
|
||||||
# Defend, then wait for defense to expire and enter recovery
|
|
||||||
encounter.defend(quick_dodge)
|
|
||||||
time.sleep(0.31)
|
|
||||||
encounter.tick(time.monotonic())
|
|
||||||
|
|
||||||
recovery_until = encounter.defense_recovery_until
|
|
||||||
assert recovery_until is not None
|
|
||||||
|
|
||||||
# Attack and resolve during recovery period
|
|
||||||
encounter.attack(quick_attack)
|
|
||||||
encounter.resolve()
|
|
||||||
|
|
||||||
# Recovery should persist after resolve
|
|
||||||
assert encounter.defense_recovery_until == recovery_until
|
|
||||||
|
|
||||||
|
|
||||||
def test_attack_switch_restarts_timer(attacker, defender):
|
|
||||||
"""attack(), then attack() again with different move.
|
|
||||||
|
|
||||||
move_started_at should reset.
|
|
||||||
"""
|
|
||||||
first_attack = CombatMove(
|
|
||||||
name="first punch",
|
|
||||||
move_type="attack",
|
|
||||||
stamina_cost=5.0,
|
|
||||||
hit_time_ms=800,
|
|
||||||
damage_pct=0.15,
|
|
||||||
countered_by=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
second_attack = CombatMove(
|
|
||||||
name="second punch",
|
|
||||||
move_type="attack",
|
|
||||||
stamina_cost=5.0,
|
|
||||||
hit_time_ms=800,
|
|
||||||
damage_pct=0.15,
|
|
||||||
countered_by=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
||||||
encounter.attack(first_attack)
|
|
||||||
first_start = encounter.move_started_at
|
|
||||||
|
|
||||||
# Small delay to ensure time difference
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
# Switch to second attack
|
|
||||||
encounter.attack(second_attack)
|
|
||||||
second_start = encounter.move_started_at
|
|
||||||
|
|
||||||
# Timer should have restarted
|
|
||||||
assert second_start > first_start
|
|
||||||
assert encounter.current_move is second_attack
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ def punch():
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
hit_time_ms=800,
|
timing_window_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -107,7 +107,7 @@ async def test_process_combat_advances_encounters(attacker, defender, punch):
|
||||||
time.sleep(0.31)
|
time.sleep(0.31)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
assert encounter.state == CombatState.PENDING
|
assert encounter.state == CombatState.WINDOW
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -127,8 +127,8 @@ async def test_process_combat_handles_multiple_encounters(punch):
|
||||||
time.sleep(0.31)
|
time.sleep(0.31)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
assert enc1.state == CombatState.PENDING
|
assert enc1.state == CombatState.WINDOW
|
||||||
assert enc2.state == CombatState.PENDING
|
assert enc2.state == CombatState.WINDOW
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -141,7 +141,7 @@ async def test_process_combat_auto_resolves_expired_windows(attacker, defender,
|
||||||
# Skip past telegraph and window
|
# Skip past telegraph and window
|
||||||
time.sleep(0.31) # Telegraph
|
time.sleep(0.31) # Telegraph
|
||||||
await process_combat()
|
await process_combat()
|
||||||
assert encounter.state == CombatState.PENDING
|
assert encounter.state == CombatState.WINDOW
|
||||||
|
|
||||||
time.sleep(0.85) # Window
|
time.sleep(0.85) # Window
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
@ -201,8 +201,8 @@ async def test_encounter_cleanup_after_resolution(attacker, defender, punch):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_combat_keeps_encounter_after_knockout(punch):
|
async def test_process_combat_ends_encounter_on_knockout(punch):
|
||||||
"""KO should not end combat; encounter stays active."""
|
"""Test process_combat ends encounter when defender is knocked out."""
|
||||||
w = _mock_writer
|
w = _mock_writer
|
||||||
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
|
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
|
||||||
defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0, writer=w())
|
defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0, writer=w())
|
||||||
|
|
@ -220,16 +220,17 @@ async def test_process_combat_keeps_encounter_after_knockout(punch):
|
||||||
time.sleep(0.85)
|
time.sleep(0.85)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
# Combat should remain active after KO
|
# Combat should have ended and been cleaned up
|
||||||
assert get_encounter(attacker) is encounter
|
assert get_encounter(attacker) is None
|
||||||
assert get_encounter(defender) is encounter
|
assert get_encounter(defender) is None
|
||||||
assert attacker.mode_stack == ["normal", "combat"]
|
# Mode stacks should have combat popped
|
||||||
assert defender.mode_stack == ["normal", "combat"]
|
assert attacker.mode_stack == ["normal"]
|
||||||
|
assert defender.mode_stack == ["normal"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_combat_keeps_encounter_after_exhaustion(punch):
|
async def test_process_combat_ends_encounter_on_exhaustion(punch):
|
||||||
"""Exhaustion should not end combat; encounter stays active."""
|
"""Test process_combat ends encounter when attacker is exhausted."""
|
||||||
w = _mock_writer
|
w = _mock_writer
|
||||||
attacker = Player(
|
attacker = Player(
|
||||||
name="Goku",
|
name="Goku",
|
||||||
|
|
@ -261,68 +262,11 @@ async def test_process_combat_keeps_encounter_after_exhaustion(punch):
|
||||||
time.sleep(0.85)
|
time.sleep(0.85)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
# Combat should remain active
|
# Combat should have ended
|
||||||
assert get_encounter(attacker) is encounter
|
assert get_encounter(attacker) is None
|
||||||
assert get_encounter(defender) is encounter
|
assert get_encounter(defender) is None
|
||||||
assert attacker.mode_stack == ["normal", "combat"]
|
assert attacker.mode_stack == ["normal"]
|
||||||
assert defender.mode_stack == ["normal", "combat"]
|
assert defender.mode_stack == ["normal"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_combat_keeps_encounter_when_defender_already_exhausted(punch):
|
|
||||||
"""Defender exhaustion should not auto-end encounter."""
|
|
||||||
w = _mock_writer
|
|
||||||
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
|
|
||||||
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=0.0, writer=w())
|
|
||||||
|
|
||||||
attacker.mode_stack.append("combat")
|
|
||||||
defender.mode_stack.append("combat")
|
|
||||||
|
|
||||||
encounter = start_encounter(attacker, defender)
|
|
||||||
encounter.attack(punch)
|
|
||||||
|
|
||||||
time.sleep(0.31)
|
|
||||||
await process_combat()
|
|
||||||
time.sleep(0.85)
|
|
||||||
await process_combat()
|
|
||||||
|
|
||||||
assert get_encounter(attacker) is encounter
|
|
||||||
assert get_encounter(defender) is encounter
|
|
||||||
assert attacker.mode_stack == ["normal", "combat"]
|
|
||||||
assert defender.mode_stack == ["normal", "combat"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_combat_keeps_encounter_when_both_unconscious(punch):
|
|
||||||
"""Double unconscious should not auto-end; timeout/finisher decides."""
|
|
||||||
w = _mock_writer
|
|
||||||
attacker = Player(
|
|
||||||
name="Goku",
|
|
||||||
x=0,
|
|
||||||
y=0,
|
|
||||||
pl=100.0,
|
|
||||||
stamina=punch.stamina_cost,
|
|
||||||
writer=w(),
|
|
||||||
)
|
|
||||||
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=0.0, writer=w())
|
|
||||||
|
|
||||||
attacker.mode_stack.append("combat")
|
|
||||||
defender.mode_stack.append("combat")
|
|
||||||
|
|
||||||
encounter = start_encounter(attacker, defender)
|
|
||||||
encounter.attack(punch)
|
|
||||||
|
|
||||||
time.sleep(0.31)
|
|
||||||
await process_combat()
|
|
||||||
time.sleep(0.85)
|
|
||||||
await process_combat()
|
|
||||||
|
|
||||||
assert get_encounter(attacker) is encounter
|
|
||||||
assert get_encounter(defender) is encounter
|
|
||||||
assert attacker.kills == 0
|
|
||||||
assert defender.kills == 0
|
|
||||||
assert attacker.deaths == 0
|
|
||||||
assert defender.deaths == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -391,7 +335,7 @@ async def test_process_combat_sends_messages_on_resolve(punch):
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_idle_timeout_ends_encounter():
|
async def test_idle_timeout_ends_encounter():
|
||||||
"""Encounter times out after 30s without landed damage."""
|
"""Test encounter times out after 30s of no actions."""
|
||||||
w = _mock_writer
|
w = _mock_writer
|
||||||
attacker = Player(name="Goku", x=0, y=0, writer=w())
|
attacker = Player(name="Goku", x=0, y=0, writer=w())
|
||||||
defender = Player(name="Vegeta", x=0, y=0, writer=w())
|
defender = Player(name="Vegeta", x=0, y=0, writer=w())
|
||||||
|
|
@ -449,7 +393,7 @@ async def test_idle_timeout_pops_combat_mode():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_recent_action_prevents_timeout():
|
async def test_recent_action_prevents_timeout():
|
||||||
"""Fresh encounter start prevents immediate timeout."""
|
"""Test recent action prevents idle timeout."""
|
||||||
w = _mock_writer
|
w = _mock_writer
|
||||||
attacker = Player(name="Goku", x=0, y=0, writer=w())
|
attacker = Player(name="Goku", x=0, y=0, writer=w())
|
||||||
defender = Player(name="Vegeta", x=0, y=0, writer=w())
|
defender = Player(name="Vegeta", x=0, y=0, writer=w())
|
||||||
|
|
@ -467,7 +411,7 @@ async def test_recent_action_prevents_timeout():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_encounter_sets_last_action_at():
|
async def test_start_encounter_sets_last_action_at():
|
||||||
"""start_encounter initializes no-damage timeout clock."""
|
"""Test start_encounter initializes last_action_at."""
|
||||||
attacker = Entity(name="Goku", x=0, y=0)
|
attacker = Entity(name="Goku", x=0, y=0)
|
||||||
defender = Entity(name="Vegeta", x=0, y=0)
|
defender = Entity(name="Vegeta", x=0, y=0)
|
||||||
|
|
||||||
|
|
@ -475,25 +419,3 @@ async def test_start_encounter_sets_last_action_at():
|
||||||
encounter = start_encounter(attacker, defender)
|
encounter = start_encounter(attacker, defender)
|
||||||
|
|
||||||
assert encounter.last_action_at >= before
|
assert encounter.last_action_at >= before
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_landed_damage_refreshes_timeout_clock(punch):
|
|
||||||
"""Successful hit should refresh timeout timer."""
|
|
||||||
w = _mock_writer
|
|
||||||
attacker = Player(name="Goku", x=0, y=0, writer=w())
|
|
||||||
defender = Player(name="Vegeta", x=0, y=0, writer=w())
|
|
||||||
attacker.mode_stack.append("combat")
|
|
||||||
defender.mode_stack.append("combat")
|
|
||||||
|
|
||||||
encounter = start_encounter(attacker, defender)
|
|
||||||
# Keep close to timeout, but still allow resolve to land damage first.
|
|
||||||
encounter.last_action_at = time.monotonic() - 28.5
|
|
||||||
encounter.attack(punch)
|
|
||||||
time.sleep(0.31)
|
|
||||||
await process_combat()
|
|
||||||
time.sleep(0.85)
|
|
||||||
await process_combat()
|
|
||||||
|
|
||||||
# A landed hit should keep encounter alive by refreshing the timer.
|
|
||||||
assert get_encounter(attacker) is encounter
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ def test_combat_move_dataclass():
|
||||||
aliases=["pr"],
|
aliases=["pr"],
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
telegraph="{attacker} winds up a right hook!",
|
telegraph="{attacker} winds up a right hook!",
|
||||||
hit_time_ms=800,
|
timing_window_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left", "parry high"],
|
countered_by=["dodge left", "parry high"],
|
||||||
command="punch",
|
command="punch",
|
||||||
|
|
@ -24,7 +24,7 @@ def test_combat_move_dataclass():
|
||||||
assert move.aliases == ["pr"]
|
assert move.aliases == ["pr"]
|
||||||
assert move.stamina_cost == 5.0
|
assert move.stamina_cost == 5.0
|
||||||
assert move.telegraph == "{attacker} winds up a right hook!"
|
assert move.telegraph == "{attacker} winds up a right hook!"
|
||||||
assert move.hit_time_ms == 800
|
assert move.timing_window_ms == 800
|
||||||
assert move.damage_pct == 0.15
|
assert move.damage_pct == 0.15
|
||||||
assert move.countered_by == ["dodge left", "parry high"]
|
assert move.countered_by == ["dodge left", "parry high"]
|
||||||
assert move.handler is None
|
assert move.handler is None
|
||||||
|
|
@ -38,14 +38,14 @@ def test_combat_move_minimal():
|
||||||
name="test move",
|
name="test move",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=10.0,
|
stamina_cost=10.0,
|
||||||
hit_time_ms=500,
|
timing_window_ms=500,
|
||||||
)
|
)
|
||||||
assert move.name == "test move"
|
assert move.name == "test move"
|
||||||
assert move.move_type == "attack"
|
assert move.move_type == "attack"
|
||||||
assert move.aliases == []
|
assert move.aliases == []
|
||||||
assert move.stamina_cost == 10.0
|
assert move.stamina_cost == 10.0
|
||||||
assert move.telegraph == ""
|
assert move.telegraph == ""
|
||||||
assert move.hit_time_ms == 500
|
assert move.timing_window_ms == 500
|
||||||
assert move.damage_pct == 0.0
|
assert move.damage_pct == 0.0
|
||||||
assert move.countered_by == []
|
assert move.countered_by == []
|
||||||
assert move.command == ""
|
assert move.command == ""
|
||||||
|
|
@ -60,7 +60,7 @@ aliases = ["rh"]
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 8.0
|
stamina_cost = 8.0
|
||||||
telegraph = "{attacker} spins into a roundhouse kick!"
|
telegraph = "{attacker} spins into a roundhouse kick!"
|
||||||
hit_time_ms = 600
|
timing_window_ms = 600
|
||||||
damage_pct = 0.25
|
damage_pct = 0.25
|
||||||
countered_by = ["duck", "parry high", "parry low"]
|
countered_by = ["duck", "parry high", "parry low"]
|
||||||
"""
|
"""
|
||||||
|
|
@ -83,7 +83,7 @@ def test_load_variant_move_from_toml(tmp_path):
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 800
|
timing_window_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
[variants.left]
|
[variants.left]
|
||||||
|
|
@ -114,7 +114,7 @@ countered_by = ["dodge left", "parry high"]
|
||||||
assert left.countered_by == ["dodge right", "parry high"]
|
assert left.countered_by == ["dodge right", "parry high"]
|
||||||
# Inherited from parent
|
# Inherited from parent
|
||||||
assert left.stamina_cost == 5.0
|
assert left.stamina_cost == 5.0
|
||||||
assert left.hit_time_ms == 800
|
assert left.timing_window_ms == 800
|
||||||
assert left.damage_pct == 0.15
|
assert left.damage_pct == 0.15
|
||||||
|
|
||||||
right = by_name["punch right"]
|
right = by_name["punch right"]
|
||||||
|
|
@ -130,13 +130,13 @@ def test_variant_inherits_shared_properties(tmp_path):
|
||||||
name = "kick"
|
name = "kick"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 800
|
timing_window_ms = 800
|
||||||
damage_pct = 0.10
|
damage_pct = 0.10
|
||||||
|
|
||||||
[variants.low]
|
[variants.low]
|
||||||
aliases = ["kl"]
|
aliases = ["kl"]
|
||||||
damage_pct = 0.08
|
damage_pct = 0.08
|
||||||
hit_time_ms = 600
|
timing_window_ms = 600
|
||||||
|
|
||||||
[variants.high]
|
[variants.high]
|
||||||
aliases = ["kh"]
|
aliases = ["kh"]
|
||||||
|
|
@ -150,12 +150,12 @@ damage_pct = 0.15
|
||||||
|
|
||||||
low = by_name["kick low"]
|
low = by_name["kick low"]
|
||||||
assert low.damage_pct == 0.08
|
assert low.damage_pct == 0.08
|
||||||
assert low.hit_time_ms == 600 # overridden
|
assert low.timing_window_ms == 600 # overridden
|
||||||
assert low.stamina_cost == 5.0 # inherited
|
assert low.stamina_cost == 5.0 # inherited
|
||||||
|
|
||||||
high = by_name["kick high"]
|
high = by_name["kick high"]
|
||||||
assert high.damage_pct == 0.15
|
assert high.damage_pct == 0.15
|
||||||
assert high.hit_time_ms == 800 # inherited
|
assert high.timing_window_ms == 800 # inherited
|
||||||
assert high.stamina_cost == 5.0 # inherited
|
assert high.stamina_cost == 5.0 # inherited
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -165,8 +165,7 @@ def test_load_move_with_defaults(tmp_path):
|
||||||
name = "basic move"
|
name = "basic move"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
active_ms = 600
|
timing_window_ms = 600
|
||||||
recovery_ms = 2700
|
|
||||||
"""
|
"""
|
||||||
toml_file = tmp_path / "basic.toml"
|
toml_file = tmp_path / "basic.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
@ -186,7 +185,7 @@ def test_load_move_missing_name(tmp_path):
|
||||||
toml_content = """
|
toml_content = """
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 800
|
timing_window_ms = 800
|
||||||
"""
|
"""
|
||||||
toml_file = tmp_path / "bad.toml"
|
toml_file = tmp_path / "bad.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
@ -214,7 +213,7 @@ def test_load_move_missing_stamina_cost(tmp_path):
|
||||||
toml_content = """
|
toml_content = """
|
||||||
name = "test"
|
name = "test"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
hit_time_ms = 800
|
timing_window_ms = 800
|
||||||
"""
|
"""
|
||||||
toml_file = tmp_path / "bad.toml"
|
toml_file = tmp_path / "bad.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
@ -223,8 +222,8 @@ hit_time_ms = 800
|
||||||
load_move(toml_file)
|
load_move(toml_file)
|
||||||
|
|
||||||
|
|
||||||
def test_load_attack_missing_hit_time_raises(tmp_path):
|
def test_load_move_missing_timing_window(tmp_path):
|
||||||
"""Test loading attack without hit_time_ms raises error."""
|
"""Test loading move without timing_window_ms raises error."""
|
||||||
toml_content = """
|
toml_content = """
|
||||||
name = "test"
|
name = "test"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
|
|
@ -233,89 +232,10 @@ stamina_cost = 5.0
|
||||||
toml_file = tmp_path / "bad.toml"
|
toml_file = tmp_path / "bad.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="hit_time_ms"):
|
with pytest.raises(ValueError, match="missing required field.*timing_window_ms"):
|
||||||
load_move(toml_file)
|
load_move(toml_file)
|
||||||
|
|
||||||
|
|
||||||
def test_load_attack_zero_hit_time_raises(tmp_path):
|
|
||||||
"""Test loading attack with hit_time_ms = 0 raises error."""
|
|
||||||
toml_content = """
|
|
||||||
name = "test"
|
|
||||||
move_type = "attack"
|
|
||||||
stamina_cost = 5.0
|
|
||||||
hit_time_ms = 0
|
|
||||||
"""
|
|
||||||
toml_file = tmp_path / "bad.toml"
|
|
||||||
toml_file.write_text(toml_content)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="hit_time_ms"):
|
|
||||||
load_move(toml_file)
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_defense_missing_active_ms_raises(tmp_path):
|
|
||||||
"""Test loading defense without active_ms raises error."""
|
|
||||||
toml_content = """
|
|
||||||
name = "test"
|
|
||||||
move_type = "defense"
|
|
||||||
stamina_cost = 3.0
|
|
||||||
recovery_ms = 2000
|
|
||||||
"""
|
|
||||||
toml_file = tmp_path / "bad.toml"
|
|
||||||
toml_file.write_text(toml_content)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="active_ms"):
|
|
||||||
load_move(toml_file)
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_defense_zero_active_ms_raises(tmp_path):
|
|
||||||
"""Test loading defense with active_ms = 0 raises error."""
|
|
||||||
toml_content = """
|
|
||||||
name = "test"
|
|
||||||
move_type = "defense"
|
|
||||||
stamina_cost = 3.0
|
|
||||||
active_ms = 0
|
|
||||||
recovery_ms = 2000
|
|
||||||
"""
|
|
||||||
toml_file = tmp_path / "bad.toml"
|
|
||||||
toml_file.write_text(toml_content)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="active_ms"):
|
|
||||||
load_move(toml_file)
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_attack_valid_passes(tmp_path):
|
|
||||||
"""Test loading attack with valid hit_time_ms passes."""
|
|
||||||
toml_content = """
|
|
||||||
name = "test"
|
|
||||||
move_type = "attack"
|
|
||||||
stamina_cost = 5.0
|
|
||||||
hit_time_ms = 500
|
|
||||||
"""
|
|
||||||
toml_file = tmp_path / "valid.toml"
|
|
||||||
toml_file.write_text(toml_content)
|
|
||||||
|
|
||||||
moves = load_move(toml_file)
|
|
||||||
assert len(moves) == 1
|
|
||||||
assert moves[0].hit_time_ms == 500
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_defense_valid_passes(tmp_path):
|
|
||||||
"""Test loading defense with valid active_ms passes."""
|
|
||||||
toml_content = """
|
|
||||||
name = "test"
|
|
||||||
move_type = "defense"
|
|
||||||
stamina_cost = 3.0
|
|
||||||
active_ms = 500
|
|
||||||
recovery_ms = 2000
|
|
||||||
"""
|
|
||||||
toml_file = tmp_path / "valid.toml"
|
|
||||||
toml_file.write_text(toml_content)
|
|
||||||
|
|
||||||
moves = load_move(toml_file)
|
|
||||||
assert len(moves) == 1
|
|
||||||
assert moves[0].active_ms == 500
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_moves_from_directory(tmp_path):
|
def test_load_moves_from_directory(tmp_path):
|
||||||
"""Test loading all moves from a directory."""
|
"""Test loading all moves from a directory."""
|
||||||
# Create a variant move
|
# Create a variant move
|
||||||
|
|
@ -325,7 +245,7 @@ def test_load_moves_from_directory(tmp_path):
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 800
|
timing_window_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
[variants.right]
|
[variants.right]
|
||||||
|
|
@ -342,8 +262,7 @@ countered_by = ["dodge left"]
|
||||||
name = "duck"
|
name = "duck"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
active_ms = 500
|
timing_window_ms = 500
|
||||||
recovery_ms = 2700
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -384,7 +303,7 @@ name = "move one"
|
||||||
aliases = ["m"]
|
aliases = ["m"]
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 800
|
timing_window_ms = 800
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -395,8 +314,7 @@ name = "move two"
|
||||||
aliases = ["m"]
|
aliases = ["m"]
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
active_ms = 500
|
timing_window_ms = 500
|
||||||
recovery_ms = 2700
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -412,7 +330,7 @@ def test_load_moves_name_collision(tmp_path):
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 800
|
timing_window_ms = 800
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -422,7 +340,7 @@ hit_time_ms = 800
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 800
|
timing_window_ms = 800
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -440,7 +358,7 @@ def test_load_moves_validates_countered_by_refs(tmp_path, caplog):
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 800
|
timing_window_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
[variants.right]
|
[variants.right]
|
||||||
|
|
@ -454,8 +372,7 @@ countered_by = ["dodge left", "nonexistent move"]
|
||||||
name = "dodge"
|
name = "dodge"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
active_ms = 500
|
timing_window_ms = 500
|
||||||
recovery_ms = 2700
|
|
||||||
|
|
||||||
[variants.left]
|
[variants.left]
|
||||||
aliases = ["dl"]
|
aliases = ["dl"]
|
||||||
|
|
@ -484,7 +401,7 @@ def test_load_moves_valid_countered_by_refs_no_warning(tmp_path, caplog):
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
hit_time_ms = 800
|
timing_window_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
[variants.right]
|
[variants.right]
|
||||||
|
|
@ -498,8 +415,7 @@ countered_by = ["dodge left", "parry high"]
|
||||||
name = "dodge"
|
name = "dodge"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
active_ms = 500
|
timing_window_ms = 500
|
||||||
recovery_ms = 2700
|
|
||||||
|
|
||||||
[variants.left]
|
[variants.left]
|
||||||
aliases = ["dl"]
|
aliases = ["dl"]
|
||||||
|
|
@ -512,8 +428,7 @@ aliases = ["dl"]
|
||||||
name = "parry"
|
name = "parry"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
active_ms = 500
|
timing_window_ms = 500
|
||||||
recovery_ms = 2700
|
|
||||||
|
|
||||||
[variants.high]
|
[variants.high]
|
||||||
aliases = ["f"]
|
aliases = ["f"]
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ def attack_move():
|
||||||
variant="left",
|
variant="left",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5,
|
stamina_cost=5,
|
||||||
hit_time_ms=850,
|
timing_window_ms=850,
|
||||||
telegraph="telegraphs a left punch at {defender}",
|
telegraph="telegraphs a left punch at {defender}",
|
||||||
telegraph_color="yellow",
|
telegraph_color="yellow",
|
||||||
aliases=[],
|
aliases=[],
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ async def test_flying_during_window_causes_miss(player, target, punch_right):
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
|
|
||||||
# Advance to WINDOW phase
|
# Advance to WINDOW phase
|
||||||
encounter.state = CombatState.PENDING
|
encounter.state = CombatState.WINDOW
|
||||||
|
|
||||||
# Defender flies during window
|
# Defender flies during window
|
||||||
target.flying = True
|
target.flying = True
|
||||||
|
|
@ -159,7 +159,7 @@ async def test_both_flying_at_resolve_attack_lands(player, target, punch_right):
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
|
|
||||||
# Advance to WINDOW phase (no altitude change)
|
# Advance to WINDOW phase (no altitude change)
|
||||||
encounter.state = CombatState.PENDING
|
encounter.state = CombatState.WINDOW
|
||||||
|
|
||||||
# Resolve
|
# Resolve
|
||||||
result = encounter.resolve()
|
result = encounter.resolve()
|
||||||
|
|
@ -180,7 +180,7 @@ async def test_attacker_flies_during_window_causes_miss(player, target, punch_ri
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
|
|
||||||
# Advance to WINDOW phase
|
# Advance to WINDOW phase
|
||||||
encounter.state = CombatState.PENDING
|
encounter.state = CombatState.WINDOW
|
||||||
|
|
||||||
# Attacker flies during window
|
# Attacker flies during window
|
||||||
player.flying = True
|
player.flying = True
|
||||||
|
|
@ -205,7 +205,7 @@ async def test_flying_dodge_messages_correct_grammar(player, target, punch_right
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
|
|
||||||
# Advance to WINDOW phase
|
# Advance to WINDOW phase
|
||||||
encounter.state = CombatState.PENDING
|
encounter.state = CombatState.WINDOW
|
||||||
|
|
||||||
# Defender flies during window
|
# Defender flies during window
|
||||||
target.flying = True
|
target.flying = True
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,19 @@ def _clean_test_commands():
|
||||||
commands._registry.update(snapshot)
|
commands._registry.update(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def test_zone():
|
||||||
# Create a 100x100 zone filled with passable terrain
|
# Create a 100x100 zone filled with passable terrain
|
||||||
|
|
@ -84,6 +97,7 @@ async def test_dispatch_routes_to_handler(player):
|
||||||
commands.register(CommandDefinition("testcmd", test_handler))
|
commands.register(CommandDefinition("testcmd", test_handler))
|
||||||
await commands.dispatch(player, "testcmd arg1 arg2")
|
await commands.dispatch(player, "testcmd arg1 arg2")
|
||||||
|
|
||||||
|
assert called
|
||||||
assert received_args == "arg1 arg2"
|
assert received_args == "arg1 arg2"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -346,7 +360,7 @@ async def test_dispatch_allows_wildcard_mode(player):
|
||||||
commands.register(CommandDefinition("universal", any_handler, mode="*"))
|
commands.register(CommandDefinition("universal", any_handler, mode="*"))
|
||||||
await commands.dispatch(player, "universal")
|
await commands.dispatch(player, "universal")
|
||||||
|
|
||||||
assert called is True
|
assert called
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -362,7 +376,7 @@ async def test_dispatch_allows_matching_mode(player):
|
||||||
player.mode_stack.append("combat")
|
player.mode_stack.append("combat")
|
||||||
await commands.dispatch(player, "strike")
|
await commands.dispatch(player, "strike")
|
||||||
|
|
||||||
assert called is True
|
assert called
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Tests for the commands listing command."""
|
"""Tests for the commands listing command."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -17,6 +18,19 @@ from mudlib.commands import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer):
|
def player(mock_reader, mock_writer):
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
@ -161,7 +175,7 @@ async def test_commands_detail_simple_combat_move(player, combat_moves):
|
||||||
assert "roundhouse" in output
|
assert "roundhouse" in output
|
||||||
assert "type: attack" in output
|
assert "type: attack" in output
|
||||||
assert "stamina: 8.0" in output
|
assert "stamina: 8.0" in output
|
||||||
assert "hit time: 3000ms" in output
|
assert "timing window: 2000ms" in output
|
||||||
assert "damage: 25%" in output
|
assert "damage: 25%" in output
|
||||||
assert "{attacker} shifts {his} weight back..." in output
|
assert "{attacker} shifts {his} weight back..." in output
|
||||||
assert "countered by: duck, parry high, parry low" in output
|
assert "countered by: duck, parry high, parry low" in output
|
||||||
|
|
@ -187,7 +201,7 @@ async def test_commands_detail_variant_base(player, combat_moves):
|
||||||
|
|
||||||
# Should show shared properties in each variant
|
# Should show shared properties in each variant
|
||||||
assert "stamina: 5.0" in output
|
assert "stamina: 5.0" in output
|
||||||
assert "hit time: 3000ms" in output
|
assert "timing window: 1800ms" in output
|
||||||
assert "damage: 15%" in output
|
assert "damage: 15%" in output
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -200,7 +214,7 @@ async def test_commands_detail_specific_variant(player, combat_moves):
|
||||||
assert "punch left" in output
|
assert "punch left" in output
|
||||||
assert "type: attack" in output
|
assert "type: attack" in output
|
||||||
assert "stamina: 5.0" in output
|
assert "stamina: 5.0" in output
|
||||||
assert "hit time: 3000ms" in output
|
assert "timing window: 1800ms" in output
|
||||||
assert "damage: 15%" in output
|
assert "damage: 15%" in output
|
||||||
assert "{attacker} retracts {his} left arm..." in output
|
assert "{attacker} retracts {his} left arm..." in output
|
||||||
assert "countered by: dodge right, parry high" in output
|
assert "countered by: dodge right, parry high" in output
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,60 @@
|
||||||
"""Tests for the Container class."""
|
"""Tests for the Container class."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mudlib.container import Container
|
from mudlib.container import Container
|
||||||
from mudlib.object import Object
|
from mudlib.object import Object
|
||||||
from mudlib.player import Player
|
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
# --- fixtures ---
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def test_zone():
|
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
|
||||||
return Zone(
|
|
||||||
name="testzone",
|
|
||||||
width=10,
|
|
||||||
height=10,
|
|
||||||
toroidal=True,
|
|
||||||
terrain=terrain,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
|
||||||
p = Player(
|
|
||||||
name="TestPlayer",
|
|
||||||
x=5,
|
|
||||||
y=5,
|
|
||||||
reader=mock_reader,
|
|
||||||
writer=mock_writer,
|
|
||||||
location=test_zone,
|
|
||||||
)
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
# --- construction ---
|
# --- construction ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_creation_minimal():
|
||||||
|
"""Container can be created with just a name."""
|
||||||
|
c = Container(name="chest")
|
||||||
|
assert c.name == "chest"
|
||||||
|
assert c.capacity == 10
|
||||||
|
assert c.closed is False
|
||||||
|
assert c.locked is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_creation_with_custom_capacity():
|
||||||
|
"""Container can have a custom capacity."""
|
||||||
|
c = Container(name="pouch", capacity=5)
|
||||||
|
assert c.capacity == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_creation_closed():
|
||||||
|
"""Container can be created in closed state."""
|
||||||
|
c = Container(name="chest", closed=True)
|
||||||
|
assert c.closed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_creation_locked():
|
||||||
|
"""Container can be created in locked state."""
|
||||||
|
c = Container(name="chest", locked=True)
|
||||||
|
assert c.locked is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_is_thing_subclass():
|
||||||
|
"""Container is a Thing subclass."""
|
||||||
|
c = Container(name="chest")
|
||||||
|
assert isinstance(c, Thing)
|
||||||
|
assert isinstance(c, Object)
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_inherits_thing_properties():
|
||||||
|
"""Container has all Thing properties."""
|
||||||
|
c = Container(
|
||||||
|
name="ornate chest",
|
||||||
|
description="a beautifully carved wooden chest",
|
||||||
|
portable=False,
|
||||||
|
aliases=["chest", "box"],
|
||||||
|
)
|
||||||
|
assert c.description == "a beautifully carved wooden chest"
|
||||||
|
assert c.portable is False
|
||||||
|
assert c.aliases == ["chest", "box"]
|
||||||
|
|
||||||
|
|
||||||
# --- can_accept ---
|
# --- can_accept ---
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -116,105 +132,3 @@ def test_container_with_contents():
|
||||||
assert sword in chest.contents
|
assert sword in chest.contents
|
||||||
assert gem in chest.contents
|
assert gem in chest.contents
|
||||||
assert len(chest.contents) == 2
|
assert len(chest.contents) == 2
|
||||||
|
|
||||||
|
|
||||||
# --- look command container display ---
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_look_shows_closed_container(player, test_zone, mock_writer):
|
|
||||||
"""look shows closed containers with (closed) suffix."""
|
|
||||||
from mudlib.commands.look import cmd_look
|
|
||||||
|
|
||||||
Container(name="chest", location=test_zone, x=5, y=5, closed=True)
|
|
||||||
await cmd_look(player, "")
|
|
||||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
|
||||||
assert "chest (closed)" in output
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_look_shows_open_empty_container(player, test_zone, mock_writer):
|
|
||||||
"""look shows open empty containers with (open, empty) suffix."""
|
|
||||||
from mudlib.commands.look import cmd_look
|
|
||||||
|
|
||||||
Container(name="chest", location=test_zone, x=5, y=5, closed=False)
|
|
||||||
await cmd_look(player, "")
|
|
||||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
|
||||||
assert "chest (open, empty)" in output
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_look_shows_open_container_with_contents(player, test_zone, mock_writer):
|
|
||||||
"""look shows open containers with their contents."""
|
|
||||||
from mudlib.commands.look import cmd_look
|
|
||||||
|
|
||||||
chest = Container(name="chest", location=test_zone, x=5, y=5, closed=False)
|
|
||||||
Thing(name="rock", location=chest)
|
|
||||||
Thing(name="coin", location=chest)
|
|
||||||
await cmd_look(player, "")
|
|
||||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
|
||||||
assert "chest (open, containing: rock, coin)" in output
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_look_shows_regular_things_unchanged(player, test_zone, mock_writer):
|
|
||||||
"""look shows regular Things without container suffixes."""
|
|
||||||
from mudlib.commands.look import cmd_look
|
|
||||||
|
|
||||||
Thing(name="rock", location=test_zone, x=5, y=5)
|
|
||||||
await cmd_look(player, "")
|
|
||||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
|
||||||
assert "On the ground: rock" in output
|
|
||||||
assert "(closed)" not in output
|
|
||||||
assert "(open" not in output
|
|
||||||
|
|
||||||
|
|
||||||
# --- inventory command container display ---
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inventory_shows_closed_container(player, mock_writer):
|
|
||||||
"""inventory shows closed containers with (closed) suffix."""
|
|
||||||
from mudlib.commands.things import cmd_inventory
|
|
||||||
|
|
||||||
Container(name="sack", location=player, closed=True)
|
|
||||||
await cmd_inventory(player, "")
|
|
||||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
|
||||||
assert "sack (closed)" in output
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inventory_shows_open_empty_container(player, mock_writer):
|
|
||||||
"""inventory shows open empty containers with (open, empty) suffix."""
|
|
||||||
from mudlib.commands.things import cmd_inventory
|
|
||||||
|
|
||||||
Container(name="sack", location=player, closed=False)
|
|
||||||
await cmd_inventory(player, "")
|
|
||||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
|
||||||
assert "sack (open, empty)" in output
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inventory_shows_container_with_contents(player, mock_writer):
|
|
||||||
"""inventory shows open containers with their contents."""
|
|
||||||
from mudlib.commands.things import cmd_inventory
|
|
||||||
|
|
||||||
sack = Container(name="sack", location=player, closed=False)
|
|
||||||
Thing(name="rock", location=sack)
|
|
||||||
Thing(name="gem", location=sack)
|
|
||||||
await cmd_inventory(player, "")
|
|
||||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
|
||||||
assert "sack (open, containing: rock, gem)" in output
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inventory_shows_regular_things_unchanged(player, mock_writer):
|
|
||||||
"""inventory shows regular Things without container suffixes."""
|
|
||||||
from mudlib.commands.things import cmd_inventory
|
|
||||||
|
|
||||||
Thing(name="rock", location=player)
|
|
||||||
await cmd_inventory(player, "")
|
|
||||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
|
||||||
assert " rock\r\n" in output
|
|
||||||
assert "(closed)" not in output
|
|
||||||
assert "(open" not in output
|
|
||||||
|
|
|
||||||
150
tests/test_container_display.py
Normal file
150
tests/test_container_display.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
"""Tests for container state display in look and inventory commands."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.container import Container
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_zone():
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
return Zone(
|
||||||
|
name="testzone",
|
||||||
|
width=10,
|
||||||
|
height=10,
|
||||||
|
toroidal=True,
|
||||||
|
terrain=terrain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
|
p = Player(
|
||||||
|
name="TestPlayer",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
location=test_zone,
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# --- look command container display ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_closed_container(player, test_zone, mock_writer):
|
||||||
|
"""look shows closed containers with (closed) suffix."""
|
||||||
|
from mudlib.commands.look import cmd_look
|
||||||
|
|
||||||
|
Container(name="chest", location=test_zone, x=5, y=5, closed=True)
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||||
|
assert "chest (closed)" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_open_empty_container(player, test_zone, mock_writer):
|
||||||
|
"""look shows open empty containers with (open, empty) suffix."""
|
||||||
|
from mudlib.commands.look import cmd_look
|
||||||
|
|
||||||
|
Container(name="chest", location=test_zone, x=5, y=5, closed=False)
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||||
|
assert "chest (open, empty)" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_open_container_with_contents(player, test_zone, mock_writer):
|
||||||
|
"""look shows open containers with their contents."""
|
||||||
|
from mudlib.commands.look import cmd_look
|
||||||
|
|
||||||
|
chest = Container(name="chest", location=test_zone, x=5, y=5, closed=False)
|
||||||
|
Thing(name="rock", location=chest)
|
||||||
|
Thing(name="coin", location=chest)
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||||
|
assert "chest (open, containing: rock, coin)" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_regular_things_unchanged(player, test_zone, mock_writer):
|
||||||
|
"""look shows regular Things without container suffixes."""
|
||||||
|
from mudlib.commands.look import cmd_look
|
||||||
|
|
||||||
|
Thing(name="rock", location=test_zone, x=5, y=5)
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||||
|
assert "On the ground: rock" in output
|
||||||
|
assert "(closed)" not in output
|
||||||
|
assert "(open" not in output
|
||||||
|
|
||||||
|
|
||||||
|
# --- inventory command container display ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inventory_shows_closed_container(player, mock_writer):
|
||||||
|
"""inventory shows closed containers with (closed) suffix."""
|
||||||
|
from mudlib.commands.things import cmd_inventory
|
||||||
|
|
||||||
|
Container(name="sack", location=player, closed=True)
|
||||||
|
await cmd_inventory(player, "")
|
||||||
|
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||||
|
assert "sack (closed)" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inventory_shows_open_empty_container(player, mock_writer):
|
||||||
|
"""inventory shows open empty containers with (open, empty) suffix."""
|
||||||
|
from mudlib.commands.things import cmd_inventory
|
||||||
|
|
||||||
|
Container(name="sack", location=player, closed=False)
|
||||||
|
await cmd_inventory(player, "")
|
||||||
|
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||||
|
assert "sack (open, empty)" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inventory_shows_container_with_contents(player, mock_writer):
|
||||||
|
"""inventory shows open containers with their contents."""
|
||||||
|
from mudlib.commands.things import cmd_inventory
|
||||||
|
|
||||||
|
sack = Container(name="sack", location=player, closed=False)
|
||||||
|
Thing(name="rock", location=sack)
|
||||||
|
Thing(name="gem", location=sack)
|
||||||
|
await cmd_inventory(player, "")
|
||||||
|
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||||
|
assert "sack (open, containing: rock, gem)" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inventory_shows_regular_things_unchanged(player, mock_writer):
|
||||||
|
"""inventory shows regular Things without container suffixes."""
|
||||||
|
from mudlib.commands.things import cmd_inventory
|
||||||
|
|
||||||
|
Thing(name="rock", location=player)
|
||||||
|
await cmd_inventory(player, "")
|
||||||
|
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||||
|
assert " rock\r\n" in output
|
||||||
|
assert "(closed)" not in output
|
||||||
|
assert "(open" not in output
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -10,6 +11,19 @@ from mudlib.content import load_command, load_commands
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer):
|
def player(mock_reader, mock_writer):
|
||||||
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.container import Container
|
||||||
from mudlib.corpse import Corpse, create_corpse
|
from mudlib.corpse import Corpse, create_corpse
|
||||||
from mudlib.entity import Mob
|
from mudlib.entity import Mob
|
||||||
from mudlib.mobs import mobs
|
from mudlib.mobs import mobs
|
||||||
|
|
@ -62,6 +63,36 @@ def potion():
|
||||||
return Thing(name="potion", description="a health potion", portable=True)
|
return Thing(name="potion", description="a health potion", portable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCorpseClass:
|
||||||
|
def test_corpse_is_container_subclass(self):
|
||||||
|
"""Corpse is a subclass of Container."""
|
||||||
|
assert issubclass(Corpse, Container)
|
||||||
|
|
||||||
|
def test_corpse_not_portable(self, test_zone):
|
||||||
|
"""Corpse is not portable (can't pick up a corpse)."""
|
||||||
|
corpse = Corpse(name="test corpse", location=test_zone, x=0, y=0)
|
||||||
|
assert corpse.portable is False
|
||||||
|
|
||||||
|
def test_corpse_always_open(self, test_zone):
|
||||||
|
"""Corpse is always open (closed=False)."""
|
||||||
|
corpse = Corpse(name="test corpse", location=test_zone, x=0, y=0)
|
||||||
|
assert corpse.closed is False
|
||||||
|
|
||||||
|
def test_corpse_has_decompose_at_field(self, test_zone):
|
||||||
|
"""Corpse has decompose_at field (float, monotonic time)."""
|
||||||
|
decompose_time = time.monotonic() + 300
|
||||||
|
corpse = Corpse(
|
||||||
|
name="test corpse",
|
||||||
|
location=test_zone,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
decompose_at=decompose_time,
|
||||||
|
)
|
||||||
|
assert hasattr(corpse, "decompose_at")
|
||||||
|
assert isinstance(corpse.decompose_at, float)
|
||||||
|
assert corpse.decompose_at == decompose_time
|
||||||
|
|
||||||
|
|
||||||
class TestCreateCorpseFactory:
|
class TestCreateCorpseFactory:
|
||||||
def test_creates_corpse_at_mob_position(self, goblin_mob, test_zone):
|
def test_creates_corpse_at_mob_position(self, goblin_mob, test_zone):
|
||||||
"""create_corpse creates a corpse at mob's x, y in the zone."""
|
"""create_corpse creates a corpse at mob's x, y in the zone."""
|
||||||
|
|
@ -188,7 +219,7 @@ class TestCorpseAsContainer:
|
||||||
|
|
||||||
|
|
||||||
class TestCombatDeathCorpse:
|
class TestCombatDeathCorpse:
|
||||||
"""Knockouts do not create corpses until a finisher is used."""
|
"""Tests for corpse spawning when a mob dies in combat."""
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_corpses(self):
|
def clear_corpses(self):
|
||||||
|
|
@ -199,8 +230,8 @@ class TestCombatDeathCorpse:
|
||||||
active_corpses.clear()
|
active_corpses.clear()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_knockout_in_combat_does_not_spawn_corpse(self, test_zone):
|
async def test_mob_death_in_combat_spawns_corpse(self, test_zone):
|
||||||
"""KO in combat should not create a corpse by itself."""
|
"""Mob death in combat spawns a corpse at mob's position."""
|
||||||
from mudlib.combat.encounter import CombatState
|
from mudlib.combat.encounter import CombatState
|
||||||
from mudlib.combat.engine import (
|
from mudlib.combat.engine import (
|
||||||
active_encounters,
|
active_encounters,
|
||||||
|
|
@ -242,7 +273,7 @@ class TestCombatDeathCorpse:
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
hit_time_ms=800,
|
timing_window_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -252,17 +283,16 @@ class TestCombatDeathCorpse:
|
||||||
# Process combat to trigger resolve
|
# Process combat to trigger resolve
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
# Check no corpse spawned yet
|
# Check for corpse at mob's position
|
||||||
corpses = [
|
corpses = [
|
||||||
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
|
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
|
||||||
]
|
]
|
||||||
assert len(corpses) == 0
|
assert len(corpses) == 1
|
||||||
assert mob in mobs
|
assert corpses[0].name == "goblin's corpse"
|
||||||
assert mob.alive is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_knockout_keeps_inventory_on_mob(self, test_zone, sword):
|
async def test_mob_death_transfers_inventory_to_corpse(self, test_zone, sword):
|
||||||
"""KO should not transfer inventory to a corpse until finished."""
|
"""Mob death transfers inventory to corpse."""
|
||||||
from mudlib.combat.encounter import CombatState
|
from mudlib.combat.encounter import CombatState
|
||||||
from mudlib.combat.engine import (
|
from mudlib.combat.engine import (
|
||||||
active_encounters,
|
active_encounters,
|
||||||
|
|
@ -303,7 +333,7 @@ class TestCombatDeathCorpse:
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
hit_time_ms=800,
|
timing_window_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -313,17 +343,20 @@ class TestCombatDeathCorpse:
|
||||||
# Process combat
|
# Process combat
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
# No corpse yet
|
# Find corpse
|
||||||
corpses = [
|
corpses = [
|
||||||
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
|
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
|
||||||
]
|
]
|
||||||
assert len(corpses) == 0
|
assert len(corpses) == 1
|
||||||
assert sword in mob._contents
|
corpse = corpses[0]
|
||||||
assert sword.location is mob
|
|
||||||
|
# Verify sword is in corpse
|
||||||
|
assert sword in corpse._contents
|
||||||
|
assert sword.location is corpse
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_corpse_in_zone_contents_after_ko(self, test_zone):
|
async def test_corpse_appears_in_zone_contents(self, test_zone):
|
||||||
"""Zone should not contain corpse from a plain KO."""
|
"""Corpse appears in zone.contents_at after mob death."""
|
||||||
from mudlib.combat.encounter import CombatState
|
from mudlib.combat.encounter import CombatState
|
||||||
from mudlib.combat.engine import (
|
from mudlib.combat.engine import (
|
||||||
active_encounters,
|
active_encounters,
|
||||||
|
|
@ -363,7 +396,7 @@ class TestCombatDeathCorpse:
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
hit_time_ms=800,
|
timing_window_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -373,50 +406,14 @@ class TestCombatDeathCorpse:
|
||||||
# Process combat
|
# Process combat
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
# Verify no corpse in zone contents
|
# Verify corpse is in zone contents
|
||||||
contents = list(test_zone.contents_at(5, 10))
|
contents = list(test_zone.contents_at(5, 10))
|
||||||
corpse_count = sum(1 for obj in contents if isinstance(obj, Corpse))
|
corpse_count = sum(1 for obj in contents if isinstance(obj, Corpse))
|
||||||
assert corpse_count == 0
|
assert corpse_count == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
# Verify it's the goblin's corpse
|
||||||
async def test_snapneck_finisher_spawns_corpse(self, test_zone):
|
corpse = next(obj for obj in contents if isinstance(obj, Corpse))
|
||||||
"""Explicit finisher kill should create a corpse."""
|
assert corpse.name == "goblin's corpse"
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
from mudlib.commands.snapneck import cmd_snap_neck
|
|
||||||
from mudlib.player import Player, players
|
|
||||||
|
|
||||||
writer = MagicMock()
|
|
||||||
writer.write = MagicMock()
|
|
||||||
writer.drain = AsyncMock()
|
|
||||||
reader = MagicMock()
|
|
||||||
attacker = Player(name="hero", x=5, y=10, reader=reader, writer=writer)
|
|
||||||
attacker.location = test_zone
|
|
||||||
test_zone._contents.append(attacker)
|
|
||||||
players[attacker.name] = attacker
|
|
||||||
|
|
||||||
mob = Mob(
|
|
||||||
name="goblin",
|
|
||||||
x=5,
|
|
||||||
y=10,
|
|
||||||
location=test_zone,
|
|
||||||
pl=0.0,
|
|
||||||
stamina=0.0,
|
|
||||||
)
|
|
||||||
mobs.append(mob)
|
|
||||||
|
|
||||||
from mudlib.combat.engine import start_encounter
|
|
||||||
|
|
||||||
start_encounter(attacker, mob)
|
|
||||||
attacker.mode_stack.append("combat")
|
|
||||||
await cmd_snap_neck(attacker, "goblin")
|
|
||||||
|
|
||||||
corpses = [
|
|
||||||
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
|
|
||||||
]
|
|
||||||
assert len(corpses) == 1
|
|
||||||
assert corpses[0].name == "goblin's corpse"
|
|
||||||
players.clear()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCorpseDisplay:
|
class TestCorpseDisplay:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Tests for editor integration with the shell and command system."""
|
"""Tests for editor integration with the shell and command system."""
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -10,6 +10,19 @@ from mudlib.editor import Editor
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer):
|
def player(mock_reader, mock_writer):
|
||||||
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||||
|
|
@ -34,6 +47,7 @@ async def test_edit_command_sends_welcome_message(player, mock_writer):
|
||||||
"""Test that edit command sends welcome message."""
|
"""Test that edit command sends welcome message."""
|
||||||
await cmd_edit(player, "")
|
await cmd_edit(player, "")
|
||||||
|
|
||||||
|
assert mock_writer.write.called
|
||||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||||
assert "editor" in output.lower()
|
assert "editor" in output.lower()
|
||||||
assert ":h" in output
|
assert ":h" in output
|
||||||
|
|
@ -91,6 +105,7 @@ async def test_editor_save_callback_sends_message(player, mock_writer):
|
||||||
|
|
||||||
assert response.saved is True
|
assert response.saved is True
|
||||||
# Save callback should have sent a message
|
# Save callback should have sent a message
|
||||||
|
assert mock_writer.write.called
|
||||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||||
assert "saved" in output.lower()
|
assert "saved" in output.lower()
|
||||||
|
|
||||||
|
|
@ -172,7 +187,7 @@ async def test_edit_combat_move_opens_toml(player, tmp_path):
|
||||||
aliases = ["rh"]
|
aliases = ["rh"]
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 8.0
|
stamina_cost = 8.0
|
||||||
hit_time_ms = 2000
|
timing_window_ms = 2000
|
||||||
"""
|
"""
|
||||||
toml_file = tmp_path / "roundhouse.toml"
|
toml_file = tmp_path / "roundhouse.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
@ -222,6 +237,7 @@ move_type = "attack"
|
||||||
# Check that file was written
|
# Check that file was written
|
||||||
saved_content = toml_file.read_text()
|
saved_content = toml_file.read_text()
|
||||||
assert "stamina_cost = 9.0" in saved_content
|
assert "stamina_cost = 9.0" in saved_content
|
||||||
|
assert mock_writer.write.called
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -264,6 +280,7 @@ async def test_edit_unknown_content_shows_error(player, mock_writer, tmp_path):
|
||||||
|
|
||||||
assert player.editor is None
|
assert player.editor is None
|
||||||
assert player.mode == "normal"
|
assert player.mode == "normal"
|
||||||
|
assert mock_writer.write.called
|
||||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||||
assert "unknown" in output.lower()
|
assert "unknown" in output.lower()
|
||||||
assert "nonexistent" in output.lower()
|
assert "nonexistent" in output.lower()
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ def test_mud_filesystem_save_restore(tmp_path):
|
||||||
|
|
||||||
test_data = b"\x01\x02\x03\x04\x05"
|
test_data = b"\x01\x02\x03\x04\x05"
|
||||||
success = filesystem.save_game(test_data)
|
success = filesystem.save_game(test_data)
|
||||||
assert success is True
|
assert success
|
||||||
assert save_path.exists()
|
assert save_path.exists()
|
||||||
|
|
||||||
restored = filesystem.restore_game()
|
restored = filesystem.restore_game()
|
||||||
|
|
@ -163,6 +163,7 @@ async def test_embedded_session_handle_input():
|
||||||
|
|
||||||
response = await session.handle_input("look")
|
response = await session.handle_input("look")
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
assert response.done is False
|
assert response.done is False
|
||||||
assert len(response.output) > 0
|
assert len(response.output) > 0
|
||||||
# Looking should describe the starting location
|
# Looking should describe the starting location
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,19 @@ from mudlib.zone import Zone
|
||||||
from mudlib.zones import register_zone, zone_registry
|
from mudlib.zones import register_zone, zone_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def test_zone():
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
|
|
||||||
87
tests/test_entity.py
Normal file
87
tests/test_entity.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Tests for entity combat stats."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.entity import Entity, Mob
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_has_combat_stats():
|
||||||
|
"""Test that Entity has PL and stamina stats."""
|
||||||
|
entity = Entity(name="Test", x=0, y=0)
|
||||||
|
assert entity.pl == 100.0
|
||||||
|
assert entity.stamina == 100.0
|
||||||
|
assert entity.max_stamina == 100.0
|
||||||
|
assert entity.defense_locked_until == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_combat_stats_can_be_customized():
|
||||||
|
"""Test that combat stats can be set on initialization."""
|
||||||
|
entity = Entity(name="Weak", x=0, y=0, pl=50.0, stamina=30.0, max_stamina=30.0)
|
||||||
|
assert entity.pl == 50.0
|
||||||
|
assert entity.stamina == 30.0
|
||||||
|
assert entity.max_stamina == 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mob_inherits_combat_stats():
|
||||||
|
"""Test that Mob inherits combat stats from Entity."""
|
||||||
|
mob = Mob(name="Goku", x=10, y=10, description="A powerful fighter")
|
||||||
|
assert mob.pl == 100.0
|
||||||
|
assert mob.stamina == 100.0
|
||||||
|
assert mob.max_stamina == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mob_combat_stats_can_be_customized():
|
||||||
|
"""Test that Mob can have custom combat stats."""
|
||||||
|
mob = Mob(
|
||||||
|
name="Boss",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
description="Strong",
|
||||||
|
pl=200.0,
|
||||||
|
max_stamina=150.0,
|
||||||
|
stamina=150.0,
|
||||||
|
)
|
||||||
|
assert mob.pl == 200.0
|
||||||
|
assert mob.stamina == 150.0
|
||||||
|
assert mob.max_stamina == 150.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_inherits_combat_stats(mock_reader, mock_writer):
|
||||||
|
"""Test that Player inherits combat stats from Entity."""
|
||||||
|
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
assert player.pl == 100.0
|
||||||
|
assert player.stamina == 100.0
|
||||||
|
assert player.max_stamina == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_combat_stats_can_be_customized(mock_reader, mock_writer):
|
||||||
|
"""Test that Player can have custom combat stats."""
|
||||||
|
player = Player(
|
||||||
|
name="Veteran",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
pl=150.0,
|
||||||
|
stamina=120.0,
|
||||||
|
max_stamina=120.0,
|
||||||
|
)
|
||||||
|
assert player.pl == 150.0
|
||||||
|
assert player.stamina == 120.0
|
||||||
|
assert player.max_stamina == 120.0
|
||||||
|
|
@ -1,9 +1,26 @@
|
||||||
"""Tests for Entity.posture property."""
|
"""Tests for Entity.posture property."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from mudlib.entity import Entity, Mob
|
from mudlib.entity import Entity, Mob
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
def test_entity_default_posture():
|
def test_entity_default_posture():
|
||||||
"""Entity with no special state should be 'standing'."""
|
"""Entity with no special state should be 'standing'."""
|
||||||
entity = Entity(name="Test", x=0, y=0)
|
entity = Entity(name="Test", x=0, y=0)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.commands.examine import cmd_examine
|
from mudlib.commands.examine import cmd_examine
|
||||||
|
|
@ -8,6 +10,19 @@ from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def test_zone():
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,19 @@ from mudlib.player import Player, players
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def test_zone():
|
||||||
terrain = [["." for _ in range(100)] for _ in range(100)]
|
terrain = [["." for _ in range(100)] for _ in range(100)]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""Tests for get and drop commands."""
|
"""Tests for get and drop commands."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.commands import _registry
|
from mudlib.commands import _registry
|
||||||
|
|
@ -9,6 +11,19 @@ from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def test_zone():
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,7 @@ async def test_char_vitals_sent_on_combat_resolve():
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
hit_time_ms=800,
|
timing_window_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -413,9 +413,11 @@ async def test_char_vitals_sent_on_combat_resolve():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_char_status_sent_on_combat_end():
|
async def test_char_status_sent_on_combat_end():
|
||||||
"""Test Char.Status is sent when combat ends (timeout)."""
|
"""Test Char.Status is sent when combat ends (victory/defeat)."""
|
||||||
|
import time
|
||||||
|
|
||||||
from mudlib.combat.engine import active_encounters, process_combat, start_encounter
|
from mudlib.combat.engine import active_encounters, process_combat, start_encounter
|
||||||
|
from mudlib.combat.moves import CombatMove
|
||||||
|
|
||||||
# Clear encounters
|
# Clear encounters
|
||||||
active_encounters.clear()
|
active_encounters.clear()
|
||||||
|
|
@ -441,9 +443,22 @@ async def test_char_status_sent_on_combat_end():
|
||||||
attacker.mode_stack.append("combat")
|
attacker.mode_stack.append("combat")
|
||||||
defender.mode_stack.append("combat")
|
defender.mode_stack.append("combat")
|
||||||
|
|
||||||
# Create encounter and force timeout end.
|
# Create encounter and attack (will kill defender)
|
||||||
encounter = start_encounter(attacker, defender)
|
encounter = start_encounter(attacker, defender)
|
||||||
encounter.last_action_at -= 31.0
|
punch = CombatMove(
|
||||||
|
name="punch right",
|
||||||
|
move_type="attack",
|
||||||
|
stamina_cost=5.0,
|
||||||
|
timing_window_ms=800,
|
||||||
|
damage_pct=0.15,
|
||||||
|
countered_by=["dodge left"],
|
||||||
|
)
|
||||||
|
encounter.attack(punch)
|
||||||
|
|
||||||
|
# Advance past telegraph and window to trigger resolution
|
||||||
|
time.sleep(0.31)
|
||||||
|
await process_combat()
|
||||||
|
time.sleep(0.85)
|
||||||
|
|
||||||
# Reset mocks before the resolution call
|
# Reset mocks before the resolution call
|
||||||
mock_writer_1.send_gmcp.reset_mock()
|
mock_writer_1.send_gmcp.reset_mock()
|
||||||
|
|
|
||||||
137
tests/test_help_command.py
Normal file
137
tests/test_help_command.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""Tests for the standalone help command."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib import commands
|
||||||
|
|
||||||
|
# Import command modules to register their commands
|
||||||
|
from mudlib.commands import (
|
||||||
|
help, # noqa: F401
|
||||||
|
look, # noqa: F401
|
||||||
|
movement, # noqa: F401
|
||||||
|
)
|
||||||
|
from mudlib.commands.help import _help_topics
|
||||||
|
from mudlib.content import load_help_topics
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(mock_reader, mock_writer):
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_player(mock_reader, mock_writer):
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
p = Player(name="AdminPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||||
|
p.is_admin = True
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _load_zones_topic():
|
||||||
|
"""Load the zones help topic for tests that need it."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
help_dir = Path(__file__).resolve().parents[1] / "content" / "help"
|
||||||
|
if help_dir.exists():
|
||||||
|
loaded = load_help_topics(help_dir)
|
||||||
|
_help_topics.update(loaded)
|
||||||
|
yield
|
||||||
|
_help_topics.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_command_is_registered():
|
||||||
|
"""The help command should be registered in the command registry."""
|
||||||
|
assert "help" in commands._registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_has_wildcard_mode():
|
||||||
|
"""Help should work from any mode."""
|
||||||
|
cmd_def = commands._registry["help"]
|
||||||
|
assert cmd_def.mode == "*"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_no_args_shows_usage(player):
|
||||||
|
"""help with no args shows usage hint."""
|
||||||
|
await commands.dispatch(player, "help")
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "help <command>" in output
|
||||||
|
assert "commands" in output
|
||||||
|
assert "skills" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_known_command_shows_detail(player):
|
||||||
|
"""help <known command> shows detail view."""
|
||||||
|
await commands.dispatch(player, "help look")
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "look" in output.lower()
|
||||||
|
assert "mode:" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_unknown_command_shows_error(player):
|
||||||
|
"""help <unknown> shows error message."""
|
||||||
|
await commands.dispatch(player, "help nonexistent")
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "nonexistent" in output.lower()
|
||||||
|
assert "unknown" in output.lower() or "not found" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_and_commands_both_exist():
|
||||||
|
"""Both help and commands should be registered independently."""
|
||||||
|
assert "help" in commands._registry
|
||||||
|
assert "commands" in commands._registry
|
||||||
|
# They should be different functions
|
||||||
|
assert commands._registry["help"].handler != commands._registry["commands"].handler
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_zones_shows_guide(admin_player):
|
||||||
|
"""help zones shows zone guide text with command references."""
|
||||||
|
await commands.dispatch(admin_player, "help zones")
|
||||||
|
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
|
||||||
|
assert "zones" in output
|
||||||
|
assert "@zones" in output
|
||||||
|
assert "@goto" in output
|
||||||
|
assert "@dig" in output
|
||||||
|
assert "@paint" in output
|
||||||
|
assert "@save" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_zones_shows_see_also(admin_player):
|
||||||
|
"""help zones output contains see also cross-references."""
|
||||||
|
await commands.dispatch(admin_player, "help zones")
|
||||||
|
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
|
||||||
|
assert "see:" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_zones_requires_admin(player):
|
||||||
|
"""Non-admin players cannot see admin help topics."""
|
||||||
|
await commands.dispatch(player, "help zones")
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "unknown" in output.lower()
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
"""Tests for TOML help topic loading."""
|
"""Tests for TOML help topic loading."""
|
||||||
|
|
||||||
import textwrap
|
import textwrap
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib import commands
|
from mudlib import commands
|
||||||
from mudlib.commands import help as help_mod # noqa: F401
|
from mudlib.commands import help as help_mod # noqa: F401
|
||||||
from mudlib.commands import (
|
from mudlib.commands import helpadmin # noqa: F401
|
||||||
helpadmin, # noqa: F401
|
|
||||||
look, # noqa: F401
|
|
||||||
movement, # noqa: F401
|
|
||||||
)
|
|
||||||
from mudlib.commands.help import _help_topics
|
from mudlib.commands.help import _help_topics
|
||||||
from mudlib.content import HelpTopic, load_help_topics
|
from mudlib.content import HelpTopic, load_help_topics
|
||||||
|
|
||||||
|
|
@ -89,31 +85,32 @@ def test_load_help_topics_skips_bad_files(tmp_path):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer):
|
def mock_writer():
|
||||||
from mudlib.player import Player
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
return Player(name="Tester", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def admin_player(mock_reader, mock_writer):
|
def player(mock_writer):
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
||||||
p = Player(name="Admin", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
return Player(name="Tester", x=0, y=0, reader=MagicMock(), writer=mock_writer)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_player(mock_writer):
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
p = Player(name="Admin", x=0, y=0, reader=MagicMock(), writer=mock_writer)
|
||||||
p.is_admin = True
|
p.is_admin = True
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _clear_and_load_topics():
|
def _clear_topics():
|
||||||
"""Clear help topics, load content topics, then clear again after tests."""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_help_topics.clear()
|
_help_topics.clear()
|
||||||
help_dir = Path(__file__).resolve().parents[1] / "content" / "help"
|
|
||||||
if help_dir.exists():
|
|
||||||
loaded = load_help_topics(help_dir)
|
|
||||||
_help_topics.update(loaded)
|
|
||||||
yield
|
yield
|
||||||
_help_topics.clear()
|
_help_topics.clear()
|
||||||
|
|
||||||
|
|
@ -264,82 +261,3 @@ def test_at_help_toml_loads_from_content():
|
||||||
topics = load_help_topics(help_dir)
|
topics = load_help_topics(help_dir)
|
||||||
assert "@help" in topics
|
assert "@help" in topics
|
||||||
assert topics["@help"].admin is True
|
assert topics["@help"].admin is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_help_command_is_registered():
|
|
||||||
"""The help command should be registered in the command registry."""
|
|
||||||
assert "help" in commands._registry
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_help_has_wildcard_mode():
|
|
||||||
"""Help should work from any mode."""
|
|
||||||
cmd_def = commands._registry["help"]
|
|
||||||
assert cmd_def.mode == "*"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_help_no_args_shows_usage(player):
|
|
||||||
"""help with no args shows usage hint."""
|
|
||||||
await commands.dispatch(player, "help")
|
|
||||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
||||||
assert "help <command>" in output
|
|
||||||
assert "commands" in output
|
|
||||||
assert "skills" in output
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_help_known_command_shows_detail(player):
|
|
||||||
"""help <known command> shows detail view."""
|
|
||||||
await commands.dispatch(player, "help look")
|
|
||||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
||||||
assert "look" in output.lower()
|
|
||||||
assert "mode:" in output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_help_unknown_command_shows_error(player):
|
|
||||||
"""help <unknown> shows error message."""
|
|
||||||
await commands.dispatch(player, "help nonexistent")
|
|
||||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
||||||
assert "nonexistent" in output.lower()
|
|
||||||
assert "unknown" in output.lower() or "not found" in output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_help_and_commands_both_exist():
|
|
||||||
"""Both help and commands should be registered independently."""
|
|
||||||
assert "help" in commands._registry
|
|
||||||
assert "commands" in commands._registry
|
|
||||||
# They should be different functions
|
|
||||||
assert commands._registry["help"].handler != commands._registry["commands"].handler
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_help_zones_shows_guide(admin_player):
|
|
||||||
"""help zones shows zone guide text with command references."""
|
|
||||||
await commands.dispatch(admin_player, "help zones")
|
|
||||||
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
|
|
||||||
assert "zones" in output
|
|
||||||
assert "@zones" in output
|
|
||||||
assert "@goto" in output
|
|
||||||
assert "@dig" in output
|
|
||||||
assert "@paint" in output
|
|
||||||
assert "@save" in output
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_help_zones_shows_see_also(admin_player):
|
|
||||||
"""help zones output contains see also cross-references."""
|
|
||||||
await commands.dispatch(admin_player, "help zones")
|
|
||||||
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
|
|
||||||
assert "see:" in output
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_help_zones_requires_admin(player):
|
|
||||||
"""Non-admin players cannot see admin help topics."""
|
|
||||||
await commands.dispatch(player, "help zones")
|
|
||||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
||||||
assert "unknown" in output.lower()
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Tests for help command showing unlock status for combat moves."""
|
"""Tests for help command showing unlock status for combat moves."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -17,6 +17,14 @@ def clear_state():
|
||||||
players.clear()
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_writer):
|
def player(mock_writer):
|
||||||
return Player(name="Test", writer=mock_writer)
|
return Player(name="Test", writer=mock_writer)
|
||||||
|
|
@ -29,7 +37,7 @@ def mock_move_kill_count():
|
||||||
name="roundhouse",
|
name="roundhouse",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=30.0,
|
stamina_cost=30.0,
|
||||||
hit_time_ms=850,
|
timing_window_ms=850,
|
||||||
aliases=["rh"],
|
aliases=["rh"],
|
||||||
description="A powerful spinning kick",
|
description="A powerful spinning kick",
|
||||||
damage_pct=0.35,
|
damage_pct=0.35,
|
||||||
|
|
@ -44,7 +52,7 @@ def mock_move_mob_kills():
|
||||||
name="goblin slayer",
|
name="goblin slayer",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=25.0,
|
stamina_cost=25.0,
|
||||||
hit_time_ms=800,
|
timing_window_ms=800,
|
||||||
description="Specialized technique against goblins",
|
description="Specialized technique against goblins",
|
||||||
damage_pct=0.40,
|
damage_pct=0.40,
|
||||||
unlock_condition=UnlockCondition(
|
unlock_condition=UnlockCondition(
|
||||||
|
|
@ -60,7 +68,7 @@ def mock_move_no_unlock():
|
||||||
name="jab",
|
name="jab",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=10.0,
|
stamina_cost=10.0,
|
||||||
hit_time_ms=600,
|
timing_window_ms=600,
|
||||||
description="A quick straight punch",
|
description="A quick straight punch",
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,19 @@ from mudlib.if_session import IFResponse, IFSession
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer):
|
def player(mock_reader, mock_writer):
|
||||||
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,33 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.if_session import IFSession
|
from mudlib.if_session import IFResponse, IFSession
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_if_response_dataclass():
|
||||||
|
"""IFResponse dataclass can be created."""
|
||||||
|
response = IFResponse(output="test output", done=False)
|
||||||
|
assert response.output == "test output"
|
||||||
|
assert response.done is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_if_response_done():
|
||||||
|
"""IFResponse can signal completion."""
|
||||||
|
response = IFResponse(output="", done=True)
|
||||||
|
assert response.done is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_if_session_init():
|
||||||
|
"""IFSession can be initialized."""
|
||||||
|
player = MagicMock()
|
||||||
|
session = IFSession(player, "/path/to/story.z5", "story")
|
||||||
|
assert session.player == player
|
||||||
|
assert session.story_path == "/path/to/story.z5"
|
||||||
|
assert session.game_name == "story"
|
||||||
|
assert session.process is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,19 @@ from mudlib.player import Player, players
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_players():
|
def clear_players():
|
||||||
"""Clear players registry before and after each test."""
|
"""Clear players registry before and after each test."""
|
||||||
|
|
|
||||||
5
tests/test_import.py
Normal file
5
tests/test_import.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from mudlib import __version__
|
||||||
|
|
||||||
|
|
||||||
|
def test_version():
|
||||||
|
assert __version__ == "0.1.0"
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""Tests for inventory command."""
|
"""Tests for inventory command."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib import commands
|
from mudlib import commands
|
||||||
|
|
@ -21,6 +23,18 @@ def test_zone():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
"""Create a mock writer."""
|
||||||
|
return MagicMock(write=MagicMock(), drain=AsyncMock())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
"""Create a mock reader."""
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
"""Create a test player."""
|
"""Create a test player."""
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.container import Container
|
from mudlib.container import Container
|
||||||
|
|
@ -6,6 +8,19 @@ from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def test_zone():
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
|
|
||||||
|
|
@ -4,25 +4,54 @@ import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.combat.engine import start_encounter
|
from mudlib.combat.engine import (
|
||||||
from mudlib.commands.snapneck import cmd_snap_neck
|
process_combat,
|
||||||
|
start_encounter,
|
||||||
|
)
|
||||||
|
from mudlib.combat.moves import CombatMove
|
||||||
from mudlib.entity import Mob
|
from mudlib.entity import Mob
|
||||||
from mudlib.player import accumulate_play_time
|
from mudlib.player import accumulate_play_time
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def punch_move():
|
||||||
|
"""Create a basic punch move for testing."""
|
||||||
|
return CombatMove(
|
||||||
|
name="punch right",
|
||||||
|
move_type="attack",
|
||||||
|
stamina_cost=5.0,
|
||||||
|
timing_window_ms=800,
|
||||||
|
damage_pct=0.15,
|
||||||
|
countered_by=[],
|
||||||
|
resolve_hit="{attacker} hits {defender}!",
|
||||||
|
resolve_miss="{defender} dodges!",
|
||||||
|
announce="{attacker} punches!",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_player_finishes_mob_increments_stats(player, test_zone):
|
async def test_player_kills_mob_increments_stats(player, test_zone, punch_move):
|
||||||
"""Snap-neck kill increments kills and mob_kills."""
|
"""Player kills mob -> kills incremented, mob_kills tracked."""
|
||||||
# Create a goblin mob
|
# Create a goblin mob
|
||||||
goblin = Mob(name="goblin", x=0, y=0)
|
goblin = Mob(name="goblin", x=0, y=0)
|
||||||
goblin.location = test_zone
|
goblin.location = test_zone
|
||||||
test_zone._contents.append(goblin)
|
test_zone._contents.append(goblin)
|
||||||
|
|
||||||
# Start encounter and make target unconscious
|
# Start encounter
|
||||||
start_encounter(player, goblin)
|
encounter = start_encounter(player, goblin)
|
||||||
player.mode_stack.append("combat")
|
|
||||||
goblin.pl = 0.0
|
# Execute attack
|
||||||
await cmd_snap_neck(player, "goblin")
|
encounter.attack(punch_move)
|
||||||
|
|
||||||
|
# Advance past telegraph (0.3s) + window (0.8s)
|
||||||
|
encounter.tick(time.monotonic() + 0.31) # -> WINDOW
|
||||||
|
encounter.tick(time.monotonic() + 1.2) # -> RESOLVE
|
||||||
|
|
||||||
|
# Set defender to very low pl so damage kills them
|
||||||
|
goblin.pl = 1.0
|
||||||
|
|
||||||
|
# Process combat (this will resolve and end encounter)
|
||||||
|
await process_combat()
|
||||||
|
|
||||||
# Verify stats
|
# Verify stats
|
||||||
assert player.kills == 1
|
assert player.kills == 1
|
||||||
|
|
@ -30,32 +59,50 @@ async def test_player_finishes_mob_increments_stats(player, test_zone):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_player_finished_by_mob_increments_deaths(player, nearby_player):
|
async def test_player_killed_by_mob_increments_deaths(player, test_zone, punch_move):
|
||||||
"""Snap-neck finisher from opponent increments deaths."""
|
"""Player killed by mob -> deaths incremented."""
|
||||||
start_encounter(nearby_player, player)
|
# Create a goblin mob
|
||||||
nearby_player.mode_stack.append("combat")
|
goblin = Mob(name="goblin", x=0, y=0)
|
||||||
player.mode_stack.append("combat")
|
goblin.location = test_zone
|
||||||
player.pl = 0.0
|
test_zone._contents.append(goblin)
|
||||||
await cmd_snap_neck(nearby_player, "Goku")
|
|
||||||
|
# Start encounter with mob as attacker
|
||||||
|
encounter = start_encounter(goblin, player)
|
||||||
|
|
||||||
|
# Execute attack
|
||||||
|
encounter.attack(punch_move)
|
||||||
|
|
||||||
|
# Advance to RESOLVE
|
||||||
|
encounter.tick(time.monotonic() + 0.31)
|
||||||
|
encounter.tick(time.monotonic() + 1.2)
|
||||||
|
|
||||||
|
# Set player to low pl so they die
|
||||||
|
player.pl = 1.0
|
||||||
|
|
||||||
|
# Process combat
|
||||||
|
await process_combat()
|
||||||
|
|
||||||
# Verify deaths incremented
|
# Verify deaths incremented
|
||||||
assert player.deaths == 1
|
assert player.deaths == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_multiple_finisher_kills_accumulate(player, test_zone):
|
async def test_multiple_kills_accumulate(player, test_zone, punch_move):
|
||||||
"""After 3 finishers, kill counters accumulate correctly."""
|
"""After killing 3 goblins, player.kills == 3, player.mob_kills["goblin"] == 3."""
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
# Create goblin
|
# Create goblin
|
||||||
goblin = Mob(name="goblin", x=0, y=0)
|
goblin = Mob(name="goblin", x=0, y=0)
|
||||||
goblin.location = test_zone
|
goblin.location = test_zone
|
||||||
test_zone._contents.append(goblin)
|
test_zone._contents.append(goblin)
|
||||||
|
|
||||||
# Create encounter and finish
|
# Create and resolve encounter
|
||||||
start_encounter(player, goblin)
|
encounter = start_encounter(player, goblin)
|
||||||
player.mode_stack.append("combat")
|
encounter.attack(punch_move)
|
||||||
goblin.pl = 0.0
|
encounter.tick(time.monotonic() + 0.31)
|
||||||
await cmd_snap_neck(player, "goblin")
|
encounter.tick(time.monotonic() + 1.2)
|
||||||
|
goblin.pl = 1.0
|
||||||
|
|
||||||
|
await process_combat()
|
||||||
|
|
||||||
# Verify accumulated kills
|
# Verify accumulated kills
|
||||||
assert player.kills == 3
|
assert player.kills == 3
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,19 @@ def _reset_globals():
|
||||||
mudlib.weather._current_weather = None
|
mudlib.weather._current_weather = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def test_zone():
|
||||||
"""Create a test zone with simple terrain."""
|
"""Create a test zone with simple terrain."""
|
||||||
|
|
@ -85,7 +98,7 @@ async def test_look_includes_exits_line(player):
|
||||||
"""Look output should include 'Exits:' line."""
|
"""Look output should include 'Exits:' line."""
|
||||||
await cmd_look(player, "")
|
await cmd_look(player, "")
|
||||||
output = get_output(player)
|
output = get_output(player)
|
||||||
assert "Exits: north south east west up" in output
|
assert "Exits: north south east west" in output
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -156,36 +169,14 @@ async def test_look_shows_portals(player, test_zone):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_look_with_args_routes_to_examine(player, test_zone):
|
async def test_look_with_args_routes_to_examine(player, test_zone):
|
||||||
"""look <thing> should route to examine command logic."""
|
"""look <thing> should route to examine command logic."""
|
||||||
called = {}
|
# Add an item to examine (location param auto-adds to zone)
|
||||||
|
Thing(name="sword", x=25, y=25, description="A sharp blade.", location=test_zone)
|
||||||
|
|
||||||
async def fake_examine(p, args, *, prefer_inventory=True):
|
|
||||||
called["args"] = args
|
|
||||||
called["prefer_inventory"] = prefer_inventory
|
|
||||||
await p.send("examined\r\n")
|
|
||||||
|
|
||||||
import mudlib.commands.examine
|
|
||||||
|
|
||||||
original = mudlib.commands.examine.examine_target
|
|
||||||
mudlib.commands.examine.examine_target = fake_examine # type: ignore[invalid-assignment]
|
|
||||||
try:
|
|
||||||
await cmd_look(player, "sword")
|
await cmd_look(player, "sword")
|
||||||
finally:
|
|
||||||
mudlib.commands.examine.examine_target = original
|
|
||||||
|
|
||||||
output = get_output(player)
|
output = get_output(player)
|
||||||
assert called["args"] == "sword"
|
|
||||||
assert called["prefer_inventory"] is False
|
|
||||||
assert "examined" in output
|
|
||||||
|
|
||||||
|
# Should see the item's description (examine behavior)
|
||||||
@pytest.mark.asyncio
|
assert "A sharp blade." in output
|
||||||
async def test_look_flying_shows_down_exit(player):
|
|
||||||
"""Flying players should see down as a vertical exit."""
|
|
||||||
player.flying = True
|
|
||||||
|
|
||||||
await cmd_look(player, "")
|
|
||||||
output = get_output(player)
|
|
||||||
assert "Exits: north south east west down" in output
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -50,6 +51,19 @@ def test_zone():
|
||||||
return zone
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
|
@ -118,7 +132,7 @@ class TestMobAttackAI:
|
||||||
await process_mobs(moves)
|
await process_mobs(moves)
|
||||||
|
|
||||||
# Mob should have attacked — encounter state should be TELEGRAPH
|
# Mob should have attacked — encounter state should be TELEGRAPH
|
||||||
assert encounter.state == CombatState.PENDING
|
assert encounter.state == CombatState.TELEGRAPH
|
||||||
assert encounter.current_move is not None
|
assert encounter.current_move is not None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -272,7 +286,7 @@ class TestMobDefenseAI:
|
||||||
|
|
||||||
# Player attacks, putting encounter in TELEGRAPH
|
# Player attacks, putting encounter in TELEGRAPH
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
assert encounter.state == CombatState.PENDING
|
assert encounter.state == CombatState.TELEGRAPH
|
||||||
|
|
||||||
await process_mobs(moves)
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Tests for mob AI integration with behavior states."""
|
"""Tests for mob AI integration with behavior states."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -42,6 +43,19 @@ def test_zone():
|
||||||
return zone
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
p = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Tests for mob templates, registry, spawn/despawn, and combat integration."""
|
"""Tests for mob templates, registry, spawn/despawn, and combat integration."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -182,6 +182,19 @@ class TestGetNearbyMob:
|
||||||
# --- Phase 2: target resolution tests ---
|
# --- Phase 2: target resolution tests ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
|
@ -375,8 +388,8 @@ class TestMobDefeat:
|
||||||
return spawn_mob(template, 0, 0, test_zone)
|
return spawn_mob(template, 0, 0, test_zone)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_not_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
|
async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
|
||||||
"""KO does not despawn mob without an explicit finisher."""
|
"""Mob with PL <= 0 gets despawned after combat resolves."""
|
||||||
from mudlib.combat.engine import process_combat, start_encounter
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
encounter = start_encounter(player, goblin_mob)
|
encounter = start_encounter(player, goblin_mob)
|
||||||
|
|
@ -391,14 +404,12 @@ class TestMobDefeat:
|
||||||
|
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
assert goblin_mob in mobs
|
assert goblin_mob not in mobs
|
||||||
assert goblin_mob.alive is True
|
assert goblin_mob.alive is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_player_gets_no_victory_message_on_ko(
|
async def test_player_gets_victory_message(self, player, goblin_mob, punch_right):
|
||||||
self, player, goblin_mob, punch_right
|
"""Player receives a victory message when mob is defeated."""
|
||||||
):
|
|
||||||
"""KO should not be treated as a defeat/kill message."""
|
|
||||||
from mudlib.combat.engine import process_combat, start_encounter
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
encounter = start_encounter(player, goblin_mob)
|
encounter = start_encounter(player, goblin_mob)
|
||||||
|
|
@ -411,30 +422,29 @@ class TestMobDefeat:
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
assert not any("defeated" in msg.lower() for msg in messages)
|
assert any("defeated" in msg.lower() for msg in messages)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_exhaustion_does_not_end_encounter(
|
async def test_mob_stamina_depleted_despawns(self, player, goblin_mob, punch_right):
|
||||||
self, player, goblin_mob, punch_right
|
"""Mob is despawned when attacker stamina depleted (combat end)."""
|
||||||
):
|
|
||||||
"""Attacker exhaustion does not auto-end combat."""
|
|
||||||
from mudlib.combat.engine import process_combat, start_encounter
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
encounter = start_encounter(player, goblin_mob)
|
encounter = start_encounter(player, goblin_mob)
|
||||||
player.mode_stack.append("combat")
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
# Drain player stamina before resolve
|
# Drain player stamina so combat ends on exhaustion
|
||||||
player.stamina = 0.0
|
player.stamina = 0.0
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
encounter.state = CombatState.RESOLVE
|
encounter.state = CombatState.RESOLVE
|
||||||
|
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
assert get_encounter(player) is encounter
|
# Encounter should have ended
|
||||||
|
assert get_encounter(player) is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_player_ko_not_despawned(self, player, goblin_mob, punch_right):
|
async def test_player_defeat_not_despawned(self, player, goblin_mob, punch_right):
|
||||||
"""When player is KO'd, player remains present."""
|
"""When player loses, player is not despawned."""
|
||||||
from mudlib.combat.engine import process_combat, start_encounter
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
# Mob attacks player — mob is attacker, player is defender
|
# Mob attacks player — mob is attacker, player is defender
|
||||||
|
|
@ -447,8 +457,10 @@ class TestMobDefeat:
|
||||||
|
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
|
# Player should get defeat message, not be despawned
|
||||||
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
assert len(messages) > 0
|
assert any(
|
||||||
assert not any("defeated" in msg.lower() for msg in messages)
|
"defeated" in msg.lower() or "damage" in msg.lower() for msg in messages
|
||||||
|
)
|
||||||
# Player is still in players dict (not removed)
|
# Player is still in players dict (not removed)
|
||||||
assert player.name in players
|
assert player.name in players
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""End-to-end integration tests for NPC system (behavior + dialogue + schedule)."""
|
"""End-to-end integration tests for NPC system (behavior + dialogue + schedule)."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.commands.talk import cmd_reply, cmd_talk, dialogue_trees
|
from mudlib.commands.talk import cmd_reply, cmd_talk, dialogue_trees
|
||||||
|
|
@ -42,6 +44,19 @@ def test_zone():
|
||||||
return zone
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
p = Player(name="Hero", x=10, y=10, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Hero", x=10, y=10, reader=mock_reader, writer=mock_writer)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""Tests for open and close commands."""
|
"""Tests for open and close commands."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.commands import _registry
|
from mudlib.commands import _registry
|
||||||
|
|
@ -9,6 +11,19 @@ from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def test_zone():
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -24,6 +25,21 @@ def temp_db():
|
||||||
os.unlink(db_path)
|
os.unlink(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
writer.close = MagicMock()
|
||||||
|
writer.is_closing = MagicMock(return_value=False)
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_quit_saves_player_state(temp_db, mock_reader, mock_writer):
|
async def test_quit_saves_player_state(temp_db, mock_reader, mock_writer):
|
||||||
"""Quit command saves player state before disconnecting."""
|
"""Quit command saves player state before disconnecting."""
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
"""Tests for the play command."""
|
"""Tests for the play command."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def test_zone():
|
||||||
"""Create a test zone for spatial queries."""
|
"""Create a test zone for spatial queries."""
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,25 @@
|
||||||
"""Tests for player description and home_zone fields."""
|
"""Tests for player description and home_zone fields."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
def test_player_default_description(mock_reader, mock_writer):
|
def test_player_default_description(mock_reader, mock_writer):
|
||||||
"""Test that Player has default empty description."""
|
"""Test that Player has default empty description."""
|
||||||
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,49 @@
|
||||||
"""Tests for the Portal class."""
|
"""Tests for the Portal class."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mudlib.object import Object
|
from mudlib.object import Object
|
||||||
from mudlib.player import Player
|
|
||||||
from mudlib.portal import Portal
|
from mudlib.portal import Portal
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
from mudlib.zones import register_zone, zone_registry
|
|
||||||
|
|
||||||
# --- fixtures ---
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def test_zone():
|
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
|
||||||
return Zone(
|
|
||||||
name="testzone",
|
|
||||||
width=10,
|
|
||||||
height=10,
|
|
||||||
toroidal=True,
|
|
||||||
terrain=terrain,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def zone_a():
|
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
|
||||||
return Zone(
|
|
||||||
name="zone_a",
|
|
||||||
width=10,
|
|
||||||
height=10,
|
|
||||||
toroidal=True,
|
|
||||||
terrain=terrain,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def zone_b():
|
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
|
||||||
return Zone(
|
|
||||||
name="zone_b",
|
|
||||||
width=10,
|
|
||||||
height=10,
|
|
||||||
toroidal=True,
|
|
||||||
terrain=terrain,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
|
||||||
p = Player(
|
|
||||||
name="TestPlayer",
|
|
||||||
x=5,
|
|
||||||
y=5,
|
|
||||||
reader=mock_reader,
|
|
||||||
writer=mock_writer,
|
|
||||||
location=test_zone,
|
|
||||||
)
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def clear_zones():
|
|
||||||
"""Clear zone registry before and after each test."""
|
|
||||||
zone_registry.clear()
|
|
||||||
yield
|
|
||||||
zone_registry.clear()
|
|
||||||
|
|
||||||
|
|
||||||
# --- construction ---
|
# --- construction ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_creation_minimal():
|
||||||
|
"""Portal can be created with just a name."""
|
||||||
|
p = Portal(name="portal")
|
||||||
|
assert p.name == "portal"
|
||||||
|
assert p.location is None
|
||||||
|
assert p.target_zone == ""
|
||||||
|
assert p.target_x == 0
|
||||||
|
assert p.target_y == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_creation_with_target():
|
||||||
|
"""Portal can be created with target zone and coordinates."""
|
||||||
|
p = Portal(name="gateway", target_zone="dungeon", target_x=5, target_y=10)
|
||||||
|
assert p.target_zone == "dungeon"
|
||||||
|
assert p.target_x == 5
|
||||||
|
assert p.target_y == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_is_thing_subclass():
|
||||||
|
"""Portal inherits from Thing."""
|
||||||
|
p = Portal(name="portal")
|
||||||
|
assert isinstance(p, Thing)
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_is_object_subclass():
|
||||||
|
"""Portal inherits from Object (via Thing)."""
|
||||||
|
p = Portal(name="portal")
|
||||||
|
assert isinstance(p, Object)
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_always_non_portable():
|
||||||
|
"""Portal is always non-portable (cannot be picked up)."""
|
||||||
|
p = Portal(name="portal")
|
||||||
|
assert p.portable is False
|
||||||
|
|
||||||
|
|
||||||
def test_portal_forced_non_portable():
|
def test_portal_forced_non_portable():
|
||||||
"""Portal forces portable=False even if explicitly set True."""
|
"""Portal forces portable=False even if explicitly set True."""
|
||||||
# Even if we try to make it portable, it should be forced to False
|
# Even if we try to make it portable, it should be forced to False
|
||||||
|
|
@ -79,6 +51,18 @@ def test_portal_forced_non_portable():
|
||||||
assert p.portable is False
|
assert p.portable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_inherits_description():
|
||||||
|
"""Portal can have a description (from Thing)."""
|
||||||
|
p = Portal(name="gateway", description="a shimmering portal")
|
||||||
|
assert p.description == "a shimmering portal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_inherits_aliases():
|
||||||
|
"""Portal can have aliases (from Thing)."""
|
||||||
|
p = Portal(name="gateway", aliases=["portal", "gate"])
|
||||||
|
assert p.aliases == ["portal", "gate"]
|
||||||
|
|
||||||
|
|
||||||
def test_portal_in_zone():
|
def test_portal_in_zone():
|
||||||
"""Portal can exist in a zone with coordinates."""
|
"""Portal can exist in a zone with coordinates."""
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
|
@ -110,140 +94,3 @@ def test_portal_rejects_things():
|
||||||
p = Portal(name="portal")
|
p = Portal(name="portal")
|
||||||
thing = Thing(name="sword")
|
thing = Thing(name="sword")
|
||||||
assert p.can_accept(thing) is False
|
assert p.can_accept(thing) is False
|
||||||
|
|
||||||
|
|
||||||
# --- portal display ---
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_look_shows_portal_at_position(player, test_zone, mock_writer):
|
|
||||||
"""look command shows portals at player position."""
|
|
||||||
from mudlib.commands.look import cmd_look
|
|
||||||
|
|
||||||
Portal(
|
|
||||||
name="shimmering doorway",
|
|
||||||
location=test_zone,
|
|
||||||
x=5,
|
|
||||||
y=5,
|
|
||||||
target_zone="elsewhere",
|
|
||||||
target_x=0,
|
|
||||||
target_y=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
await cmd_look(player, "")
|
|
||||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
|
||||||
# New format: "You see {portal.name}."
|
|
||||||
assert "you see shimmering doorway." in output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_look_shows_multiple_portals(player, test_zone, mock_writer):
|
|
||||||
"""look command shows multiple portals at player position."""
|
|
||||||
from mudlib.commands.look import cmd_look
|
|
||||||
|
|
||||||
Portal(
|
|
||||||
name="red portal",
|
|
||||||
location=test_zone,
|
|
||||||
x=5,
|
|
||||||
y=5,
|
|
||||||
target_zone="redzone",
|
|
||||||
target_x=0,
|
|
||||||
target_y=0,
|
|
||||||
)
|
|
||||||
Portal(
|
|
||||||
name="blue portal",
|
|
||||||
location=test_zone,
|
|
||||||
x=5,
|
|
||||||
y=5,
|
|
||||||
target_zone="bluezone",
|
|
||||||
target_x=0,
|
|
||||||
target_y=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
await cmd_look(player, "")
|
|
||||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
|
||||||
assert "red portal" in output.lower()
|
|
||||||
assert "blue portal" in output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_look_no_portals_at_position(player, test_zone, mock_writer):
|
|
||||||
"""look command doesn't show portals when none at position."""
|
|
||||||
from mudlib.commands.look import cmd_look
|
|
||||||
|
|
||||||
Portal(
|
|
||||||
name="distant portal",
|
|
||||||
location=test_zone,
|
|
||||||
x=8,
|
|
||||||
y=8,
|
|
||||||
target_zone="elsewhere",
|
|
||||||
target_x=0,
|
|
||||||
target_y=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
await cmd_look(player, "")
|
|
||||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
|
||||||
# Should not mention portals when none are at player position
|
|
||||||
assert "portal" not in output.lower() or "distant portal" not in output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
# --- two-way portal transitions ---
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_two_way_portal_transitions(mock_reader, mock_writer, zone_a, zone_b):
|
|
||||||
"""Portals work bidirectionally between zones."""
|
|
||||||
from mudlib.commands.portals import cmd_enter
|
|
||||||
|
|
||||||
# Create player in zone_a at (2, 2)
|
|
||||||
player = Player(
|
|
||||||
name="TestPlayer",
|
|
||||||
x=2,
|
|
||||||
y=2,
|
|
||||||
reader=mock_reader,
|
|
||||||
writer=mock_writer,
|
|
||||||
location=zone_a,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register zones
|
|
||||||
register_zone("zone_a", zone_a)
|
|
||||||
register_zone("zone_b", zone_b)
|
|
||||||
|
|
||||||
# Create portal in zone A pointing to zone B
|
|
||||||
Portal(
|
|
||||||
name="doorway to B",
|
|
||||||
location=zone_a,
|
|
||||||
x=2,
|
|
||||||
y=2,
|
|
||||||
target_zone="zone_b",
|
|
||||||
target_x=7,
|
|
||||||
target_y=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create portal in zone B pointing to zone A
|
|
||||||
Portal(
|
|
||||||
name="doorway to A",
|
|
||||||
location=zone_b,
|
|
||||||
x=7,
|
|
||||||
y=7,
|
|
||||||
target_zone="zone_a",
|
|
||||||
target_x=2,
|
|
||||||
target_y=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Player starts in zone A at (2, 2)
|
|
||||||
assert player.location is zone_a
|
|
||||||
assert player.x == 2
|
|
||||||
assert player.y == 2
|
|
||||||
|
|
||||||
# Enter portal to zone B
|
|
||||||
await cmd_enter(player, "doorway to B")
|
|
||||||
assert player.location is zone_b
|
|
||||||
assert player.x == 7
|
|
||||||
assert player.y == 7
|
|
||||||
|
|
||||||
# Enter portal back to zone A
|
|
||||||
await cmd_enter(player, "doorway to A")
|
|
||||||
assert player.location is zone_a
|
|
||||||
assert player.x == 2
|
|
||||||
assert player.y == 2
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""Tests for auto-triggering portals on movement."""
|
"""Tests for auto-triggering portals on movement."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
@ -8,6 +10,19 @@ from mudlib.zone import Zone
|
||||||
from mudlib.zones import register_zone, zone_registry
|
from mudlib.zones import register_zone, zone_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def test_zone():
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
|
|
||||||
119
tests/test_portal_display.py
Normal file
119
tests/test_portal_display.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""Tests for portal display in look command."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.portal import Portal
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_zone():
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
return Zone(
|
||||||
|
name="testzone",
|
||||||
|
width=10,
|
||||||
|
height=10,
|
||||||
|
toroidal=True,
|
||||||
|
terrain=terrain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
|
p = Player(
|
||||||
|
name="TestPlayer",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
location=test_zone,
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_portal_at_position(player, test_zone, mock_writer):
|
||||||
|
"""look command shows portals at player position."""
|
||||||
|
from mudlib.commands.look import cmd_look
|
||||||
|
|
||||||
|
Portal(
|
||||||
|
name="shimmering doorway",
|
||||||
|
location=test_zone,
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
target_zone="elsewhere",
|
||||||
|
target_x=0,
|
||||||
|
target_y=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||||
|
# New format: "You see {portal.name}."
|
||||||
|
assert "you see shimmering doorway." in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_multiple_portals(player, test_zone, mock_writer):
|
||||||
|
"""look command shows multiple portals at player position."""
|
||||||
|
from mudlib.commands.look import cmd_look
|
||||||
|
|
||||||
|
Portal(
|
||||||
|
name="red portal",
|
||||||
|
location=test_zone,
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
target_zone="redzone",
|
||||||
|
target_x=0,
|
||||||
|
target_y=0,
|
||||||
|
)
|
||||||
|
Portal(
|
||||||
|
name="blue portal",
|
||||||
|
location=test_zone,
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
target_zone="bluezone",
|
||||||
|
target_x=0,
|
||||||
|
target_y=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||||
|
assert "red portal" in output.lower()
|
||||||
|
assert "blue portal" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_no_portals_at_position(player, test_zone, mock_writer):
|
||||||
|
"""look command doesn't show portals when none at position."""
|
||||||
|
from mudlib.commands.look import cmd_look
|
||||||
|
|
||||||
|
Portal(
|
||||||
|
name="distant portal",
|
||||||
|
location=test_zone,
|
||||||
|
x=8,
|
||||||
|
y=8,
|
||||||
|
target_zone="elsewhere",
|
||||||
|
target_x=0,
|
||||||
|
target_y=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||||
|
# Should not mention portals when none are at player position
|
||||||
|
assert "portal" not in output.lower() or "distant portal" not in output.lower()
|
||||||
|
|
@ -1,11 +1,26 @@
|
||||||
"""Tests for prefix matching in command dispatch."""
|
"""Tests for prefix matching in command dispatch."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib import commands
|
from mudlib import commands
|
||||||
from mudlib.commands import CommandDefinition
|
from mudlib.commands import CommandDefinition
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer):
|
def player(mock_reader, mock_writer):
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,7 @@ def test_move_shows_name_when_in_combat_with_active_move():
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
hit_time_ms=800,
|
timing_window_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -463,7 +463,7 @@ def test_combat_state_shows_state_when_in_combat():
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
hit_time_ms=800,
|
timing_window_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -473,7 +473,7 @@ def test_combat_state_shows_state_when_in_combat():
|
||||||
active_encounters.append(encounter)
|
active_encounters.append(encounter)
|
||||||
|
|
||||||
result = render_prompt(player)
|
result = render_prompt(player)
|
||||||
assert result == "[pending] > "
|
assert result == "[telegraph] > "
|
||||||
|
|
||||||
|
|
||||||
def test_terrain_variable_grass():
|
def test_terrain_variable_grass():
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,26 @@
|
||||||
"""Tests for the prompt command."""
|
"""Tests for the prompt command."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.commands.prompt import cmd_prompt
|
from mudlib.commands.prompt import cmd_prompt
|
||||||
from mudlib.prompt import render_prompt
|
from mudlib.prompt import render_prompt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer):
|
def player(mock_reader, mock_writer):
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""Tests for put and take-from commands."""
|
"""Tests for put and take-from commands."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.commands import _registry
|
from mudlib.commands import _registry
|
||||||
|
|
@ -9,6 +11,19 @@ from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def test_zone():
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue