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"
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
active_ms = 800
|
||||
recovery_ms = 2700
|
||||
timing_window_ms = 800
|
||||
|
||||
[variants.left]
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ description = "crouch down to avoid high attacks, leaving you vulnerable to low
|
|||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
telegraph = ""
|
||||
active_ms = 600
|
||||
recovery_ms = 2900
|
||||
timing_window_ms = 700
|
||||
damage_pct = 0.0
|
||||
countered_by = []
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ description = "leap upward to evade low attacks, exposing you to high strikes"
|
|||
move_type = "defense"
|
||||
stamina_cost = 4.0
|
||||
telegraph = ""
|
||||
active_ms = 600
|
||||
recovery_ms = 2900
|
||||
timing_window_ms = 700
|
||||
damage_pct = 0.0
|
||||
countered_by = []
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ name = "parry"
|
|||
description = "deflect an attack with precise timing, redirecting force rather than absorbing it"
|
||||
move_type = "defense"
|
||||
stamina_cost = 4.0
|
||||
active_ms = 400
|
||||
recovery_ms = 3100
|
||||
timing_window_ms = 1200
|
||||
|
||||
[variants.high]
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ name = "punch"
|
|||
description = "a close-range strike with the fist, quick but predictable"
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 3000
|
||||
timing_window_ms = 1800
|
||||
damage_pct = 0.15
|
||||
|
||||
[variants.left]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ telegraph = "{attacker} shifts {his} weight back..."
|
|||
announce = "{attacker} launch{es} a roundhouse kick at {defender}!"
|
||||
resolve_hit = "{attacker}'s roundhouse slams into {defender}!"
|
||||
resolve_miss = "{defender} counter{s} {attacker}'s roundhouse!"
|
||||
hit_time_ms = 3000
|
||||
timing_window_ms = 2000
|
||||
damage_pct = 0.25
|
||||
countered_by = ["duck", "parry high", "parry low"]
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ telegraph = "{attacker} drops low..."
|
|||
announce = "{attacker} sweep{s} at {defender}'s legs!"
|
||||
resolve_hit = "{attacker}'s sweep catches {defender}'s legs!"
|
||||
resolve_miss = "{defender} jump{s} over {attacker}'s sweep!"
|
||||
hit_time_ms = 3000
|
||||
timing_window_ms = 1800
|
||||
damage_pct = 0.18
|
||||
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"
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 1800
|
||||
timing_window_ms = 1800
|
||||
damage_pct = 0.15
|
||||
|
||||
[variants.left]
|
||||
|
|
@ -266,8 +266,7 @@ name = "dodge"
|
|||
description = "a quick sidestep to evade incoming attacks"
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
active_ms = 800
|
||||
recovery_ms = 2700
|
||||
timing_window_ms = 800
|
||||
|
||||
[variants.left]
|
||||
|
||||
|
|
@ -280,9 +279,7 @@ recovery_ms = 2700
|
|||
- `description` - shown in help/skills
|
||||
- `move_type` - "attack" or "defense"
|
||||
- `stamina_cost` - stamina consumed per use
|
||||
- `hit_time_ms` - (attacks) time in ms from initiation to impact
|
||||
- `active_ms` - (defenses) how long defense blocks once activated, in ms
|
||||
- `recovery_ms` - (defenses) lockout after active window ends, in ms
|
||||
- `timing_window_ms` - how long the window is open (attacks: time to defend, defenses: commitment time)
|
||||
- `damage_pct` - fraction of attacker's PL dealt as damage (attacks only)
|
||||
- `[variants.X]` - each variant becomes a separate command: "punch left", "punch right"
|
||||
- POV templates: `{attacker}`, `{defender}`, `{s}` (third person s), `{es}`, `{his}`, `{him}`, `{y|ies}` (irregular conjugation)
|
||||
|
|
|
|||
|
|
@ -50,21 +50,29 @@ the state machine
|
|||
|
||||
::
|
||||
|
||||
IDLE → PENDING → RESOLVE → IDLE
|
||||
IDLE → TELEGRAPH → WINDOW → RESOLVE → IDLE
|
||||
|
||||
IDLE:
|
||||
no active move. attacker can initiate attack. defender can do nothing
|
||||
combat-specific.
|
||||
|
||||
PENDING:
|
||||
attacker has declared a move. telegraph message sent to defender.
|
||||
the move is now in flight — defender can queue a defense any time
|
||||
during this phase. duration is the attack's hit_time_ms.
|
||||
TELEGRAPH:
|
||||
attacker has declared a move. defender sees the telegraph message.
|
||||
"goku winds up a right hook!"
|
||||
defender can queue a defense during this phase.
|
||||
duration: brief (implementation decides, not move-defined).
|
||||
|
||||
WINDOW:
|
||||
the timing window opens. defender can still queue defense.
|
||||
if defender queued correct counter during TELEGRAPH or WINDOW, they succeed.
|
||||
duration: move.timing_window_ms (defined in TOML).
|
||||
|
||||
RESOLVE:
|
||||
hit_time_ms elapsed. check if defender's active defense counters the
|
||||
attack. calculate damage, apply stamina costs, check for knockouts
|
||||
or exhaustion. return to IDLE.
|
||||
timing window closes. check if defense counters attack.
|
||||
calculate damage based on attacker PL, damage_pct, and defense success.
|
||||
apply stamina costs.
|
||||
check for knockouts (PL = 0) or exhaustion (stamina = 0).
|
||||
return to IDLE.
|
||||
|
||||
|
||||
entity stats
|
||||
|
|
@ -197,7 +205,7 @@ moves live in content/combat/. two formats: simple and variant.
|
|||
move_type = "attack"
|
||||
stamina_cost = 8.0
|
||||
telegraph = "{attacker} spins into a roundhouse kick!"
|
||||
hit_time_ms = 600
|
||||
timing_window_ms = 600
|
||||
damage_pct = 0.25
|
||||
countered_by = ["duck", "parry high", "parry low"]
|
||||
|
||||
|
|
@ -207,7 +215,7 @@ moves live in content/combat/. two formats: simple and variant.
|
|||
name = "punch"
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 800
|
||||
timing_window_ms = 800
|
||||
damage_pct = 0.15
|
||||
|
||||
[variants.left]
|
||||
|
|
@ -220,7 +228,7 @@ moves live in content/combat/. two formats: simple and variant.
|
|||
telegraph = "{attacker} winds up a right hook!"
|
||||
countered_by = ["dodge left", "parry high"]
|
||||
|
||||
shared properties (stamina_cost, 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
|
||||
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 key ("left"). simple moves have command=name and variant="".
|
||||
|
||||
**defense move** (directional)::
|
||||
|
||||
# content/combat/dodge.toml
|
||||
name = "dodge"
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
active_ms = 800
|
||||
recovery_ms = 2700
|
||||
|
||||
[variants.left]
|
||||
|
||||
[variants.right]
|
||||
|
||||
TOML field reference:
|
||||
|
||||
- name: the command name (simple) or base command (variant)
|
||||
- move_type: "attack" or "defense"
|
||||
- stamina_cost: stamina consumed when using this move
|
||||
- hit_time_ms: (attacks) time in ms from initiation to impact
|
||||
- active_ms: (defenses) how long defense blocks once activated, in ms
|
||||
- recovery_ms: (defenses) lockout after active window ends, in ms
|
||||
- telegraph: shown to defender during PENDING phase. {attacker} replaced
|
||||
- timing_window_ms: how long defender has to respond
|
||||
- telegraph: shown to defender during TELEGRAPH phase. {attacker} replaced
|
||||
- damage_pct: fraction of attacker's PL dealt as damage
|
||||
- countered_by: list of qualified move names that counter this move
|
||||
- aliases: short command aliases registered as standalone commands
|
||||
|
|
|
|||
|
|
@ -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/prompt-system.txt`` — modal prompts, color markup, per-player customization
|
||||
- ``docs/how/protocols.rst`` — GMCP/MSDP negotiation, client detection, guard pattern
|
||||
- ``docs/how/things-and-verbs.rst`` — verb registry, @verb decorator, TOML verb handlers, dispatch fallback
|
||||
- ``docs/how/content-loading.rst`` — TOML content pipeline, startup sequence, global registries
|
||||
- ``docs/how/targeting.rst`` — ordinal parsing, priority matching, z-axis filtering
|
||||
- ``docs/how/npc-mobs.rst`` — mob templates, behavior states, AI, schedules, dialogue, spawning
|
||||
- ``docs/how/time-and-weather.rst`` — game time, seasons, weather simulation, visibility
|
||||
- ``docs/how/effects.rst`` — temporary visual overlays on the map (cloud trails, etc)
|
||||
- ``docs/how/loot-and-corpses.rst`` — loot tables, corpse creation, decomposition
|
||||
- ``docs/how/crafting.rst`` — recipe definitions, ingredient matching, item spawning
|
||||
|
||||
combat
|
||||
------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
#session dbzfe dbzfe.com 4000
|
||||
|
||||
#NOP log with timestamps, plain text (no ANSI colors)
|
||||
#config {log mode} {plain}
|
||||
#NOP %f = microseconds in some strftime implementations, may show literal %f if unsupported
|
||||
#log timestamp {%H:%M:%S.%f }
|
||||
#NOP log with ANSI colors preserved (view later with: less -R dbzfe.log)
|
||||
#config {log mode} {raw}
|
||||
#log append dbzfe.log
|
||||
|
||||
#NOP for color logging later: switch to raw mode and drop timestamp
|
||||
#NOP #config {log mode} {raw}
|
||||
#NOP #log timestamp {}
|
||||
#NOP #log append dbzfe-color.log
|
||||
|
||||
#NOP fly aliases: f<direction> = fly 5 in that direction
|
||||
#alias {fn} {fly north}
|
||||
#alias {fs} {fly south}
|
||||
|
|
@ -25,11 +18,11 @@
|
|||
|
||||
#NOP combat aliases (pr/pl/dr/dl/f/v are built into the MUD)
|
||||
#NOP these are extras for single-key convenience
|
||||
#alias {pr} {punch right %0}
|
||||
#alias {pl} {punch left %0}
|
||||
#alias {o} {sweep %0}
|
||||
#alias {r} {roundhouse %0}
|
||||
#alias {f} {parry high %0}
|
||||
#alias {v} {parry low %0}
|
||||
#alias {dl} {dodge left %0}
|
||||
#alias {dr} {dodge right %0}
|
||||
#alias {pr} {punch right}
|
||||
#alias {pl} {punch left}
|
||||
#alias {o} {sweep}
|
||||
#alias {r} {roundhouse}
|
||||
#alias {f} {parry high}
|
||||
#alias {v} {parry low}
|
||||
#alias {dl} {dodge left}
|
||||
#alias {dr} {dodge right}
|
||||
|
|
|
|||
16
mud.tin
16
mud.tin
|
|
@ -37,11 +37,11 @@
|
|||
#alias {fsw} {fly southwest}
|
||||
|
||||
#NOP combat shortcuts
|
||||
#alias {o} {sweep %0}
|
||||
#alias {pl} {punch left %0}
|
||||
#alias {pr} {punch right %0}
|
||||
#alias {r} {roundhouse %0}
|
||||
#alias {f} {parry high %0}
|
||||
#alias {v} {parry low %0}
|
||||
#alias {dr} {dodge right %0}
|
||||
#alias {dl} {dodge left %0}
|
||||
#alias {o} {sweep}
|
||||
#alias {pl} {punch left}
|
||||
#alias {pr} {punch right}
|
||||
#alias {r} {roundhouse}
|
||||
#alias {f} {parry high}
|
||||
#alias {v} {parry low}
|
||||
#alias {dr} {dodge right}
|
||||
#alias {dl} {dodge left}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Combat command handlers."""
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
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")
|
||||
|
||||
# Detect switch before attack() modifies state
|
||||
switching = encounter.state == CombatState.PENDING
|
||||
switching = encounter.state in (
|
||||
CombatState.TELEGRAPH,
|
||||
CombatState.WINDOW,
|
||||
)
|
||||
|
||||
# Execute the attack (deducts stamina)
|
||||
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:
|
||||
"""Core defense logic with a resolved move.
|
||||
|
||||
Works both in and outside combat. The encounter tracks active/recovery
|
||||
windows internally.
|
||||
Works both in and outside combat. Applies a recovery lock
|
||||
(based on timing_window_ms) so defenses have commitment.
|
||||
|
||||
Args:
|
||||
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
|
||||
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)
|
||||
if encounter is not None:
|
||||
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",
|
||||
)
|
||||
|
||||
# Commitment: block for the timing window (inputs queue naturally)
|
||||
await asyncio.sleep(move.timing_window_ms / 1000.0)
|
||||
|
||||
if encounter is not None:
|
||||
await player.send(f"You {move.name}!\r\n")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -12,11 +12,15 @@ class CombatState(Enum):
|
|||
"""States of the combat state machine."""
|
||||
|
||||
IDLE = "idle"
|
||||
PENDING = "pending"
|
||||
TELEGRAPH = "telegraph"
|
||||
WINDOW = "window"
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -40,60 +44,46 @@ class CombatEncounter:
|
|||
current_move: CombatMove | None = None
|
||||
move_started_at: float = 0.0
|
||||
pending_defense: CombatMove | None = None
|
||||
defense_activated_at: float | None = None
|
||||
defense_recovery_until: float | None = None
|
||||
queued_defense: CombatMove | None = None
|
||||
# Monotonic timestamp of most recent landed damage in this encounter.
|
||||
last_action_at: float = 0.0
|
||||
|
||||
def attack(self, move: CombatMove) -> None:
|
||||
"""Initiate or switch an attack move.
|
||||
|
||||
If called during PENDING, switches to the new move and restarts
|
||||
the timer. Refunds old move's stamina cost.
|
||||
If called during TELEGRAPH or WINDOW, switches to the new move
|
||||
without resetting the timer. Refunds old move's stamina cost.
|
||||
|
||||
Args:
|
||||
move: The attack move to execute
|
||||
"""
|
||||
now = time.monotonic()
|
||||
|
||||
if self.state == CombatState.PENDING and self.current_move:
|
||||
# Switching — refund old cost
|
||||
self.attacker.stamina = min(
|
||||
self.attacker.stamina + self.current_move.stamina_cost,
|
||||
self.attacker.max_stamina,
|
||||
)
|
||||
if self.state in (CombatState.TELEGRAPH, CombatState.WINDOW):
|
||||
# Switching — refund old cost, keep timer
|
||||
if self.current_move:
|
||||
self.attacker.stamina = min(
|
||||
self.attacker.stamina + self.current_move.stamina_cost,
|
||||
self.attacker.max_stamina,
|
||||
)
|
||||
else:
|
||||
# First attack — start timer
|
||||
self.move_started_at = now
|
||||
|
||||
# Always restart timer
|
||||
self.move_started_at = now
|
||||
self.current_move = move
|
||||
self.attacker.stamina -= move.stamina_cost
|
||||
self.last_action_at = now
|
||||
if self.state == CombatState.IDLE:
|
||||
self.state = CombatState.PENDING
|
||||
self.state = CombatState.TELEGRAPH
|
||||
|
||||
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 is handled by the command layer (do_defend).
|
||||
Stamina cost and lock are handled by the command layer (do_defend).
|
||||
|
||||
Args:
|
||||
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.defense_activated_at = now
|
||||
self.queued_defense = None
|
||||
self.pending_defense = move
|
||||
self.last_action_at = time.monotonic()
|
||||
|
||||
def tick(self, now: float) -> None:
|
||||
"""Advance the state machine based on current time.
|
||||
|
|
@ -101,45 +91,23 @@ class CombatEncounter:
|
|||
Args:
|
||||
now: Current time from monotonic clock
|
||||
"""
|
||||
# Check if queued defense should activate
|
||||
if (
|
||||
self.queued_defense is not None
|
||||
and self.defense_recovery_until is not None
|
||||
and now >= self.defense_recovery_until
|
||||
):
|
||||
# Activate queued defense
|
||||
self.pending_defense = self.queued_defense
|
||||
self.defense_activated_at = now
|
||||
self.queued_defense = None
|
||||
self.defense_recovery_until = None
|
||||
if self.state == CombatState.TELEGRAPH:
|
||||
# Check if telegraph phase is over
|
||||
elapsed = now - self.move_started_at
|
||||
if elapsed >= TELEGRAPH_DURATION:
|
||||
self.state = CombatState.WINDOW
|
||||
|
||||
# Check PENDING -> RESOLVE transition
|
||||
if self.state == CombatState.PENDING:
|
||||
elif self.state == CombatState.WINDOW:
|
||||
# Check if timing window has expired
|
||||
if self.current_move is None:
|
||||
return
|
||||
|
||||
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
|
||||
# Don't expire defense here - resolve() will handle it after checking
|
||||
|
||||
# Only expire defense if NOT in RESOLVE state
|
||||
# (resolve() will clear defense after checking for counters)
|
||||
if (
|
||||
self.state != CombatState.RESOLVE
|
||||
and self.defense_activated_at is not None
|
||||
and self.pending_defense is not None
|
||||
):
|
||||
active_duration = now - self.defense_activated_at
|
||||
active_seconds = self.pending_defense.active_ms / 1000.0
|
||||
if active_duration >= active_seconds:
|
||||
# Defense window expired, enter recovery
|
||||
recovery_seconds = self.pending_defense.recovery_ms / 1000.0
|
||||
self.defense_recovery_until = now + recovery_seconds
|
||||
self.defense_activated_at = None
|
||||
self.pending_defense = None
|
||||
|
||||
def resolve(self) -> ResolveResult:
|
||||
"""Resolve the combat exchange and return result.
|
||||
|
|
@ -173,16 +141,8 @@ class CombatEncounter:
|
|||
)
|
||||
|
||||
# Check if defense counters attack
|
||||
# Defense must be active (in active window) to succeed
|
||||
defense_is_active = False
|
||||
if self.defense_activated_at is not None and self.pending_defense is not None:
|
||||
active_duration = time.monotonic() - self.defense_activated_at
|
||||
active_window = self.pending_defense.active_ms / 1000.0
|
||||
defense_is_active = active_duration < active_window
|
||||
|
||||
defense_succeeds = (
|
||||
defense_is_active
|
||||
and self.pending_defense is not None
|
||||
self.pending_defense
|
||||
and self.pending_defense.name in self.current_move.countered_by
|
||||
)
|
||||
if defense_succeeds:
|
||||
|
|
@ -197,7 +157,7 @@ class CombatEncounter:
|
|||
elif self.pending_defense:
|
||||
# Wrong defense - normal damage
|
||||
damage = self.attacker.pl * self.current_move.damage_pct
|
||||
self.defender.pl = max(0.0, self.defender.pl - damage)
|
||||
self.defender.pl -= damage
|
||||
template = (
|
||||
self.current_move.resolve_hit
|
||||
if self.current_move.resolve_hit
|
||||
|
|
@ -207,7 +167,7 @@ class CombatEncounter:
|
|||
else:
|
||||
# No defense - increased damage
|
||||
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 = (
|
||||
self.current_move.resolve_hit
|
||||
if self.current_move.resolve_hit
|
||||
|
|
@ -215,16 +175,13 @@ class CombatEncounter:
|
|||
)
|
||||
countered = False
|
||||
|
||||
if damage > 0:
|
||||
self.last_action_at = time.monotonic()
|
||||
combat_ended = False
|
||||
# Check for combat end conditions
|
||||
combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0
|
||||
|
||||
# Reset to IDLE and clear defense state
|
||||
# Note: defense_recovery_until persists across attacks
|
||||
# Reset to IDLE
|
||||
self.state = CombatState.IDLE
|
||||
self.current_move = None
|
||||
self.pending_defense = None
|
||||
self.defense_activated_at = None
|
||||
|
||||
return ResolveResult(
|
||||
resolve_template=template,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import time
|
|||
|
||||
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
|
||||
from mudlib.combat.stamina import check_stamina_cues
|
||||
from mudlib.entity import Entity
|
||||
from mudlib.entity import Entity, Mob
|
||||
from mudlib.gmcp import send_char_status, send_char_vitals
|
||||
from mudlib.render.colors import colorize
|
||||
from mudlib.render.pov import render_pov
|
||||
|
|
@ -89,7 +89,7 @@ async def process_combat() -> None:
|
|||
now = time.monotonic()
|
||||
|
||||
for encounter in active_encounters[:]: # Copy list to allow modification
|
||||
# Check for no-damage timeout.
|
||||
# Check for idle timeout
|
||||
if now - encounter.last_action_at > IDLE_TIMEOUT:
|
||||
await encounter.attacker.send("Combat has fizzled out.\r\n")
|
||||
await encounter.defender.send("Combat has fizzled out.\r\n")
|
||||
|
|
@ -108,10 +108,10 @@ async def process_combat() -> None:
|
|||
# Tick the state machine
|
||||
encounter.tick(now)
|
||||
|
||||
# Send announce message on PENDING → RESOLVE transition
|
||||
# Send announce message on TELEGRAPH → WINDOW transition
|
||||
if (
|
||||
previous_state == CombatState.PENDING
|
||||
and encounter.state == CombatState.RESOLVE
|
||||
previous_state == CombatState.TELEGRAPH
|
||||
and encounter.state == CombatState.WINDOW
|
||||
and encounter.current_move
|
||||
and encounter.current_move.announce
|
||||
):
|
||||
|
|
@ -156,3 +156,67 @@ async def process_combat() -> None:
|
|||
# Check stamina cues after damage
|
||||
await check_stamina_cues(encounter.attacker)
|
||||
await check_stamina_cues(encounter.defender)
|
||||
|
||||
if result.combat_ended:
|
||||
# Determine winner/loser
|
||||
if encounter.defender.pl <= 0:
|
||||
loser = encounter.defender
|
||||
winner = encounter.attacker
|
||||
else:
|
||||
loser = encounter.attacker
|
||||
winner = encounter.defender
|
||||
|
||||
# Track kill/death stats
|
||||
if isinstance(winner, Player):
|
||||
winner.kills += 1
|
||||
if isinstance(loser, Mob):
|
||||
winner.mob_kills[loser.name] = (
|
||||
winner.mob_kills.get(loser.name, 0) + 1
|
||||
)
|
||||
|
||||
# Check for new unlocks
|
||||
from mudlib.combat.commands import combat_moves
|
||||
from mudlib.combat.unlock import check_unlocks
|
||||
|
||||
newly_unlocked = check_unlocks(winner, combat_moves)
|
||||
for move_name in newly_unlocked:
|
||||
await winner.send(f"You have learned {move_name}!\r\n")
|
||||
|
||||
if isinstance(loser, Player):
|
||||
loser.deaths += 1
|
||||
|
||||
# Despawn mob losers, send victory/defeat messages
|
||||
if isinstance(loser, Mob):
|
||||
from mudlib.corpse import create_corpse
|
||||
from mudlib.mobs import mob_templates
|
||||
from mudlib.zone import Zone
|
||||
|
||||
zone = loser.location
|
||||
if isinstance(zone, Zone):
|
||||
# Look up loot table from mob template
|
||||
template = mob_templates.get(loser.name)
|
||||
loot_table = template.loot if template else None
|
||||
create_corpse(loser, zone, loot_table=loot_table)
|
||||
else:
|
||||
from mudlib.mobs import despawn_mob
|
||||
|
||||
despawn_mob(loser)
|
||||
await winner.send(f"You have defeated the {loser.name}!\r\n")
|
||||
elif isinstance(winner, Mob):
|
||||
await loser.send(
|
||||
f"You have been defeated by the {winner.name}!\r\n"
|
||||
)
|
||||
|
||||
# Pop combat mode from both entities if they're Players
|
||||
attacker = encounter.attacker
|
||||
if isinstance(attacker, Player) and attacker.mode == "combat":
|
||||
attacker.mode_stack.pop()
|
||||
send_char_status(attacker)
|
||||
|
||||
defender = encounter.defender
|
||||
if isinstance(defender, Player) and defender.mode == "combat":
|
||||
defender.mode_stack.pop()
|
||||
send_char_status(defender)
|
||||
|
||||
# Remove encounter from active list
|
||||
end_encounter(encounter)
|
||||
|
|
|
|||
|
|
@ -28,9 +28,7 @@ class CombatMove:
|
|||
name: str
|
||||
move_type: str # "attack" or "defense"
|
||||
stamina_cost: float
|
||||
hit_time_ms: int = 0 # for attacks: ms from initiation to impact
|
||||
active_ms: int = 0 # for defenses: how long defense blocks once activated
|
||||
recovery_ms: int = 0 # for defenses: lockout after active window ends
|
||||
timing_window_ms: int
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
telegraph: str = ""
|
||||
damage_pct: float = 0.0
|
||||
|
|
@ -71,22 +69,12 @@ def load_move(path: Path) -> list[CombatMove]:
|
|||
data = tomllib.load(f)
|
||||
|
||||
# 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:
|
||||
if field_name not in data:
|
||||
msg = f"missing required field: {field_name}"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Move-type-specific timing validation
|
||||
move_type = data["move_type"]
|
||||
name = data["name"]
|
||||
if move_type == "attack" and data.get("hit_time_ms", 0) <= 0:
|
||||
msg = f"attack move '{name}' requires hit_time_ms > 0"
|
||||
raise ValueError(msg)
|
||||
if move_type == "defense" and data.get("active_ms", 0) <= 0:
|
||||
msg = f"defense move '{name}' requires active_ms > 0"
|
||||
raise ValueError(msg)
|
||||
|
||||
base_name = data["name"]
|
||||
variants = data.get("variants")
|
||||
|
||||
|
|
@ -109,12 +97,8 @@ def load_move(path: Path) -> list[CombatMove]:
|
|||
name=qualified_name,
|
||||
move_type=data["move_type"],
|
||||
stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]),
|
||||
hit_time_ms=variant_data.get(
|
||||
"hit_time_ms", data.get("hit_time_ms", 0)
|
||||
),
|
||||
active_ms=variant_data.get("active_ms", data.get("active_ms", 0)),
|
||||
recovery_ms=variant_data.get(
|
||||
"recovery_ms", data.get("recovery_ms", 0)
|
||||
timing_window_ms=variant_data.get(
|
||||
"timing_window_ms", data["timing_window_ms"]
|
||||
),
|
||||
aliases=variant_data.get("aliases", []),
|
||||
telegraph=variant_data.get("telegraph", data.get("telegraph", "")),
|
||||
|
|
@ -155,9 +139,7 @@ def load_move(path: Path) -> list[CombatMove]:
|
|||
name=base_name,
|
||||
move_type=data["move_type"],
|
||||
stamina_cost=data["stamina_cost"],
|
||||
hit_time_ms=data.get("hit_time_ms", 0),
|
||||
active_ms=data.get("active_ms", 0),
|
||||
recovery_ms=data.get("recovery_ms", 0),
|
||||
timing_window_ms=data["timing_window_ms"],
|
||||
aliases=data.get("aliases", []),
|
||||
telegraph=data.get("telegraph", ""),
|
||||
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
|
||||
parts = args.split(None, 1)
|
||||
alias_name = parts[0].lower()
|
||||
alias_name = parts[0]
|
||||
|
||||
if len(parts) == 1:
|
||||
# Show single alias
|
||||
|
|
@ -39,7 +39,7 @@ async def cmd_alias(player: Player, args: str) -> None:
|
|||
return
|
||||
|
||||
# Create alias
|
||||
expansion = parts[1].strip()
|
||||
expansion = parts[1]
|
||||
|
||||
# Cannot alias over built-in commands
|
||||
if alias_name in _registry:
|
||||
|
|
@ -56,7 +56,7 @@ async def cmd_unalias(player: Player, args: str) -> None:
|
|||
Usage:
|
||||
unalias <name>
|
||||
"""
|
||||
alias_name = args.strip().lower()
|
||||
alias_name = args.strip()
|
||||
|
||||
if not alias_name:
|
||||
await player.send("Usage: unalias <name>\r\n")
|
||||
|
|
|
|||
|
|
@ -3,55 +3,50 @@
|
|||
from mudlib.commands import CommandDefinition, register
|
||||
from mudlib.entity import Entity
|
||||
from mudlib.player import Player
|
||||
from mudlib.targeting import find_entity_on_tile, find_in_inventory, find_thing_on_tile
|
||||
from mudlib.thing import Thing
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
async def examine_target(
|
||||
player: Player,
|
||||
target_name: str,
|
||||
*,
|
||||
prefer_inventory: bool = True,
|
||||
) -> None:
|
||||
"""Resolve and describe a target for examine/look style commands."""
|
||||
zone = player.location if isinstance(player.location, Zone) else None
|
||||
def _find_object_in_inventory(name: str, player: Player) -> Thing | Entity | None:
|
||||
"""Find an object in player inventory by name or alias."""
|
||||
name_lower = name.lower()
|
||||
for obj in player.contents:
|
||||
# Only examine Things and Entities
|
||||
if not isinstance(obj, (Thing, Entity)):
|
||||
continue
|
||||
|
||||
# look <thing> should prioritize entities/ground; direct examine keeps
|
||||
# historical inventory-first behavior.
|
||||
ordered_finders = []
|
||||
if prefer_inventory:
|
||||
ordered_finders.append(lambda: find_in_inventory(target_name, player))
|
||||
ordered_finders.append(lambda: find_entity_on_tile(target_name, player))
|
||||
if zone is not None:
|
||||
ordered_finders.append(
|
||||
lambda: find_thing_on_tile(target_name, zone, player.x, player.y)
|
||||
)
|
||||
if not prefer_inventory:
|
||||
ordered_finders.append(lambda: find_in_inventory(target_name, player))
|
||||
# Match by name
|
||||
if obj.name.lower() == name_lower:
|
||||
return obj
|
||||
|
||||
found: Thing | Entity | None = None
|
||||
for finder in ordered_finders:
|
||||
found = finder()
|
||||
if found is not None:
|
||||
break
|
||||
# Match by alias (Things have aliases)
|
||||
if isinstance(obj, Thing) and name_lower in (a.lower() for a in obj.aliases):
|
||||
return obj
|
||||
|
||||
if found is None:
|
||||
await player.send("You don't see that here.\r\n")
|
||||
return
|
||||
return None
|
||||
|
||||
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", "")
|
||||
if desc:
|
||||
await player.send(f"{desc}\r\n")
|
||||
else:
|
||||
await player.send("You see nothing special.\r\n")
|
||||
def _find_object_at_position(name: str, player: Player) -> Thing | Entity | None:
|
||||
"""Find an object on the ground at player position by name or alias."""
|
||||
zone = player.location
|
||||
if zone is None or not isinstance(zone, Zone):
|
||||
return None
|
||||
|
||||
name_lower = name.lower()
|
||||
for obj in zone.contents_at(player.x, player.y):
|
||||
# Only examine Things and Entities
|
||||
if not isinstance(obj, (Thing, Entity)):
|
||||
continue
|
||||
|
||||
# Match by name
|
||||
if obj.name.lower() == name_lower:
|
||||
return obj
|
||||
|
||||
# Match by alias (Things have aliases)
|
||||
if isinstance(obj, Thing) and name_lower in (a.lower() for a in obj.aliases):
|
||||
return obj
|
||||
|
||||
return None
|
||||
|
||||
|
||||
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")
|
||||
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="*"))
|
||||
|
|
|
|||
|
|
@ -123,11 +123,7 @@ async def _show_single_command(
|
|||
# Combat move specific details
|
||||
if move is not None:
|
||||
lines.append(f" stamina: {move.stamina_cost}")
|
||||
if move.move_type == "attack":
|
||||
lines.append(f" hit time: {move.hit_time_ms}ms")
|
||||
else: # defense
|
||||
lines.append(f" active window: {move.active_ms}ms")
|
||||
lines.append(f" recovery: {move.recovery_ms}ms")
|
||||
lines.append(f" timing window: {move.timing_window_ms}ms")
|
||||
if move.damage_pct > 0:
|
||||
damage_pct = int(move.damage_pct * 100)
|
||||
lines.append(f" damage: {damage_pct}%")
|
||||
|
|
@ -192,11 +188,7 @@ async def _show_variant_overview(
|
|||
lines.append(f" aliases: {aliases_str}")
|
||||
|
||||
lines.append(f" stamina: {move.stamina_cost}")
|
||||
if move.move_type == "attack":
|
||||
lines.append(f" hit time: {move.hit_time_ms}ms")
|
||||
else: # defense
|
||||
lines.append(f" active window: {move.active_ms}ms")
|
||||
lines.append(f" recovery: {move.recovery_ms}ms")
|
||||
lines.append(f" timing window: {move.timing_window_ms}ms")
|
||||
|
||||
if move.damage_pct > 0:
|
||||
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
|
||||
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():
|
||||
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
|
||||
|
||||
zone = player.location
|
||||
|
|
@ -179,7 +220,7 @@ async def cmd_look(player: Player, args: str) -> None:
|
|||
output.append(render_nearby(nearby_entities, player))
|
||||
|
||||
# Exits line
|
||||
output.append(render_exits(zone, player.x, player.y, player))
|
||||
output.append(render_exits(zone, player.x, player.y))
|
||||
|
||||
# Send to player
|
||||
player.writer.write("\r\n".join(output) + "\r\n")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from mudlib.combat.engine import end_encounter, get_encounter
|
||||
from mudlib.commands import CommandDefinition, register
|
||||
from mudlib.player import Player
|
||||
from mudlib.player import Player, players
|
||||
|
||||
DEATH_PL = -100.0
|
||||
|
||||
|
|
@ -14,32 +14,37 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
|
|||
player: The player executing the command
|
||||
args: Target name
|
||||
"""
|
||||
# Get encounter
|
||||
encounter = get_encounter(player)
|
||||
if encounter is None:
|
||||
await player.send("You're not in combat.\r\n")
|
||||
return
|
||||
|
||||
# Parse target
|
||||
target_name = args.strip()
|
||||
if not target_name:
|
||||
await player.send("Snap whose neck?\r\n")
|
||||
return
|
||||
|
||||
# Must be used during an active encounter.
|
||||
encounter = get_encounter(player)
|
||||
if encounter is None:
|
||||
await player.send("You're not in combat.\r\n")
|
||||
return
|
||||
# Find target
|
||||
target = players.get(target_name)
|
||||
if target is None and player.location is not None:
|
||||
from mudlib.mobs import get_nearby_mob
|
||||
from mudlib.zone import Zone
|
||||
|
||||
# Find target on this tile.
|
||||
from mudlib.targeting import find_entity_on_tile
|
||||
if isinstance(player.location, Zone):
|
||||
target = get_nearby_mob(target_name, player.x, player.y, player.location)
|
||||
|
||||
target = find_entity_on_tile(target_name, player)
|
||||
if target is None:
|
||||
await player.send(f"You don't see {target_name} here.\r\n")
|
||||
return
|
||||
|
||||
if target is player:
|
||||
await player.send("You can't do that to yourself.\r\n")
|
||||
# Verify target is in the encounter
|
||||
if encounter.attacker is not player and encounter.defender is not player:
|
||||
await player.send("You're not in combat with that target.\r\n")
|
||||
return
|
||||
|
||||
# Snap neck can only target your current opponent.
|
||||
if target not in (encounter.attacker, encounter.defender):
|
||||
if encounter.attacker is not target and encounter.defender is not target:
|
||||
await player.send("You're not in combat with that target.\r\n")
|
||||
return
|
||||
|
||||
|
|
@ -59,38 +64,16 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
|
|||
from mudlib.entity import Mob
|
||||
from mudlib.gmcp import send_char_vitals
|
||||
|
||||
if isinstance(target, Player):
|
||||
if not isinstance(target, Mob):
|
||||
send_char_vitals(target)
|
||||
|
||||
# Award kill/death stats on explicit finishers only.
|
||||
player.kills += 1
|
||||
if isinstance(target, Player):
|
||||
target.deaths += 1
|
||||
elif isinstance(target, Mob):
|
||||
player.mob_kills[target.name] = player.mob_kills.get(target.name, 0) + 1
|
||||
# Check for newly unlocked moves after a finisher kill.
|
||||
from mudlib.combat.commands import combat_moves
|
||||
from mudlib.combat.unlock import check_unlocks
|
||||
|
||||
newly_unlocked = check_unlocks(player, combat_moves)
|
||||
for move_name in newly_unlocked:
|
||||
await player.send(f"You have learned {move_name}!\r\n")
|
||||
|
||||
# Handle mob corpse/death
|
||||
# Handle mob despawn
|
||||
if isinstance(target, Mob):
|
||||
from mudlib.corpse import create_corpse
|
||||
from mudlib.mobs import despawn_mob, mob_templates
|
||||
from mudlib.zone import Zone
|
||||
from mudlib.mobs import despawn_mob
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
mob_is_defender = encounter.defender is mob
|
||||
|
||||
# Defense AI: react during PENDING when mob is defender
|
||||
if mob_is_defender and encounter.state == CombatState.PENDING:
|
||||
# Defense AI: react during TELEGRAPH or WINDOW when mob is defender
|
||||
if mob_is_defender and encounter.state in (
|
||||
CombatState.TELEGRAPH,
|
||||
CombatState.WINDOW,
|
||||
):
|
||||
_try_defend(mob, encounter, combat_moves, now)
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ def render_nearby(entities: list, viewer) -> str:
|
|||
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.
|
||||
|
||||
Args:
|
||||
|
|
@ -94,6 +94,7 @@ def render_exits(zone, x: int, y: int, viewer=None) -> str:
|
|||
exits = []
|
||||
|
||||
# Check cardinal directions
|
||||
# NOTE: up/down exits deferred until z-axis movement is implemented
|
||||
if zone.is_passable(x, y - 1): # north (y decreases going up)
|
||||
exits.append("north")
|
||||
if zone.is_passable(x, y + 1): # south
|
||||
|
|
@ -102,11 +103,6 @@ def render_exits(zone, x: int, y: int, viewer=None) -> str:
|
|||
exits.append("east")
|
||||
if zone.is_passable(x - 1, y): # west
|
||||
exits.append("west")
|
||||
# Vertical exit is based on current altitude state.
|
||||
if viewer is not None and getattr(viewer, "flying", False):
|
||||
exits.append("down")
|
||||
elif viewer is not None:
|
||||
exits.append("up")
|
||||
|
||||
if exits:
|
||||
return f"Exits: {' '.join(exits)}"
|
||||
|
|
@ -115,7 +111,6 @@ def render_exits(zone, x: int, y: int, viewer=None) -> str:
|
|||
|
||||
_POSTURE_MESSAGES = {
|
||||
"standing": "is standing here.",
|
||||
"sleeping": "is sleeping here.",
|
||||
"resting": "is resting here.",
|
||||
"flying": "is flying above.",
|
||||
"fighting": "is fighting here.",
|
||||
|
|
|
|||
|
|
@ -502,8 +502,7 @@ def load_aliases(name: str, db_path: str | Path | None = None) -> dict[str, str]
|
|||
(name,),
|
||||
)
|
||||
|
||||
# Normalize keys to lowercase so dispatch can match consistently.
|
||||
result = {alias.lower(): expansion for alias, expansion in cursor.fetchall()}
|
||||
result = {alias: expansion for alias, expansion in cursor.fetchall()}
|
||||
|
||||
conn.close()
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -38,6 +38,19 @@ def zone():
|
|||
return z
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
def make_player(name, zone, mock_writer, mock_reader, is_admin=False):
|
||||
p = Player(
|
||||
name=name,
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@ def test_save_and_load_aliases_roundtrip():
|
|||
db_path = Path(tmpdir) / "test.db"
|
||||
init_db(db_path)
|
||||
|
||||
aliases = {"PR": "punch right", "pl": "punch left", "l": "look"}
|
||||
aliases = {"pr": "punch right", "pl": "punch left", "l": "look"}
|
||||
save_aliases("goku", aliases, db_path)
|
||||
|
||||
loaded = load_aliases("goku", db_path)
|
||||
assert loaded == {"pr": "punch right", "pl": "punch left", "l": "look"}
|
||||
assert loaded == aliases
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_normalizes_name_to_lowercase(player):
|
||||
"""Alias names are normalized so dispatch lookup is consistent."""
|
||||
from mudlib.commands.alias import cmd_alias
|
||||
|
||||
await cmd_alias(player, "PR punch right")
|
||||
assert "pr" in player.aliases
|
||||
assert "PR" not in player.aliases
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_list_with_aliases(player):
|
||||
"""alias with no args lists all aliases."""
|
||||
|
|
@ -123,16 +113,6 @@ async def test_unalias_removes_alias(player):
|
|||
player.writer.write.assert_called_with("Alias removed: pr\r\n")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unalias_is_case_insensitive(player):
|
||||
"""unalias should remove aliases regardless of case."""
|
||||
from mudlib.commands.alias import cmd_unalias
|
||||
|
||||
player.aliases = {"pr": "punch right"}
|
||||
await cmd_unalias(player, "PR")
|
||||
assert "pr" not in player.aliases
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unalias_no_such_alias(player):
|
||||
"""unalias on non-existent alias shows error."""
|
||||
|
|
@ -155,18 +135,6 @@ async def test_alias_cannot_override_builtin(player):
|
|||
assert "look" not in player.aliases
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_builtin_collision_is_case_insensitive(player):
|
||||
"""Built-in collision checks should apply regardless of alias casing."""
|
||||
import mudlib.commands.look # noqa: F401 - needed to register look command
|
||||
from mudlib.commands.alias import cmd_alias
|
||||
|
||||
await cmd_alias(player, "LOOK punch right")
|
||||
player.writer.write.assert_called_with(
|
||||
"Cannot alias over built-in command: look\r\n"
|
||||
)
|
||||
|
||||
|
||||
# Dispatch integration tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_expands_in_dispatch(player):
|
||||
|
|
@ -183,21 +151,6 @@ async def test_alias_expands_in_dispatch(player):
|
|||
assert called_with == ["hello"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_expands_in_dispatch_case_insensitive_key(player):
|
||||
"""Stored aliases with uppercase keys still load/use as lowercase."""
|
||||
called_with = []
|
||||
|
||||
async def test_handler(p, args):
|
||||
called_with.append(args)
|
||||
|
||||
register(CommandDefinition("testcmd", test_handler))
|
||||
player.aliases["pr"] = "testcmd"
|
||||
|
||||
await dispatch(player, "PR hello")
|
||||
assert called_with == ["hello"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_with_extra_args(player):
|
||||
"""Alias expansion preserves additional arguments."""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""Tests for builder commands."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.player import Player, players
|
||||
|
|
@ -24,6 +26,19 @@ def zone():
|
|||
return z
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(zone, mock_writer, mock_reader):
|
||||
p = Player(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -37,6 +38,19 @@ def test_zone():
|
|||
return zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer, test_zone):
|
||||
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||
|
|
@ -331,14 +345,14 @@ async def test_switch_attack_sends_new_telegraph(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defense_does_not_block(player, dodge_left):
|
||||
"""Test defense no longer blocks (encounter tracks active/recovery internally)."""
|
||||
async def test_defense_blocks_for_timing_window(player, dodge_left):
|
||||
"""Test defense sleeps for timing_window_ms (commitment via blocking)."""
|
||||
before = time.monotonic()
|
||||
await combat_commands.do_defend(player, "", dodge_left)
|
||||
elapsed = time.monotonic() - before
|
||||
|
||||
# Should return immediately, not block for active_ms
|
||||
assert elapsed < 0.1 # Allow for some overhead
|
||||
expected = dodge_left.timing_window_ms / 1000.0
|
||||
assert elapsed >= expected - 0.05
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def punch():
|
|||
name="punch right",
|
||||
move_type="attack",
|
||||
stamina_cost=5.0,
|
||||
hit_time_ms=800,
|
||||
timing_window_ms=800,
|
||||
damage_pct=0.15,
|
||||
countered_by=["dodge left", "parry high"],
|
||||
)
|
||||
|
|
@ -37,8 +37,7 @@ def dodge():
|
|||
name="dodge left",
|
||||
move_type="defense",
|
||||
stamina_cost=3.0,
|
||||
active_ms=800,
|
||||
recovery_ms=2700,
|
||||
timing_window_ms=800,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -48,8 +47,7 @@ def wrong_dodge():
|
|||
name="dodge right",
|
||||
move_type="defense",
|
||||
stamina_cost=3.0,
|
||||
active_ms=800,
|
||||
recovery_ms=2700,
|
||||
timing_window_ms=800,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -59,7 +57,7 @@ def sweep():
|
|||
name="sweep",
|
||||
move_type="attack",
|
||||
stamina_cost=8.0,
|
||||
hit_time_ms=600,
|
||||
timing_window_ms=600,
|
||||
damage_pct=0.20,
|
||||
countered_by=["jump"],
|
||||
)
|
||||
|
|
@ -74,12 +72,12 @@ def test_combat_encounter_initial_state(attacker, defender):
|
|||
assert encounter.move_started_at == 0.0
|
||||
|
||||
|
||||
def test_attack_transitions_to_pending(attacker, defender, punch):
|
||||
"""Test attacking transitions to PENDING state."""
|
||||
def test_attack_transitions_to_telegraph(attacker, defender, punch):
|
||||
"""Test attacking transitions to TELEGRAPH state."""
|
||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
encounter.attack(punch)
|
||||
|
||||
assert encounter.state == CombatState.PENDING
|
||||
assert encounter.state == CombatState.TELEGRAPH
|
||||
assert encounter.current_move is punch
|
||||
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):
|
||||
"""Test defend records the defense move and activates it."""
|
||||
"""Test defend records the defense move."""
|
||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
encounter.attack(punch)
|
||||
encounter.defend(dodge)
|
||||
|
||||
assert encounter.pending_defense is dodge
|
||||
assert encounter.defense_activated_at is not None
|
||||
|
||||
|
||||
def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
|
||||
|
|
@ -114,12 +111,29 @@ def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
|
|||
assert defender.stamina == initial_stamina
|
||||
|
||||
|
||||
def test_tick_pending_to_resolve(attacker, defender, punch):
|
||||
"""Test tick advances from PENDING to RESOLVE after hit time."""
|
||||
def test_tick_telegraph_to_window(attacker, defender, punch):
|
||||
"""Test tick advances from TELEGRAPH to WINDOW after brief delay."""
|
||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
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)
|
||||
now = time.monotonic()
|
||||
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."""
|
||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
|
||||
# IDLE → PENDING
|
||||
# IDLE → TELEGRAPH
|
||||
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)
|
||||
encounter.tick(time.monotonic())
|
||||
assert encounter.state == CombatState.RESOLVE
|
||||
|
|
@ -215,12 +234,13 @@ def test_full_state_machine_cycle(attacker, defender, punch):
|
|||
def test_combat_state_enum():
|
||||
"""Test CombatState enum values."""
|
||||
assert CombatState.IDLE.value == "idle"
|
||||
assert CombatState.PENDING.value == "pending"
|
||||
assert CombatState.TELEGRAPH.value == "telegraph"
|
||||
assert CombatState.WINDOW.value == "window"
|
||||
assert CombatState.RESOLVE.value == "resolve"
|
||||
|
||||
|
||||
def test_resolve_knockout_does_not_end_combat(attacker, defender, punch):
|
||||
"""KO should not end combat by itself."""
|
||||
def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch):
|
||||
"""Test resolve returns combat_ended=True when defender PL <= 0."""
|
||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
|
||||
# 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()
|
||||
|
||||
assert defender.pl <= 0
|
||||
assert result.combat_ended is False
|
||||
assert result.combat_ended is True
|
||||
assert result.damage > 0
|
||||
|
||||
|
||||
def test_resolve_exhaustion_does_not_end_combat(attacker, defender, punch):
|
||||
"""Exhaustion should not end combat by itself."""
|
||||
def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch):
|
||||
"""Test resolve returns combat_ended=True when attacker stamina <= 0."""
|
||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
|
||||
# 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()
|
||||
|
||||
assert attacker.stamina <= 0
|
||||
assert result.combat_ended is False
|
||||
|
||||
|
||||
def test_resolve_exhausted_defender_does_not_end_combat(attacker, defender, punch):
|
||||
"""Exhausted defender still does not auto-end encounter."""
|
||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
defender.stamina = 0.0
|
||||
|
||||
encounter.attack(punch)
|
||||
result = encounter.resolve()
|
||||
|
||||
assert defender.stamina <= 0
|
||||
assert result.combat_ended is False
|
||||
assert result.combat_ended is True
|
||||
|
||||
|
||||
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 ---
|
||||
|
||||
|
||||
def test_switch_attack_during_pending(attacker, defender, punch, sweep):
|
||||
"""Test attack during PENDING replaces move and restarts timer."""
|
||||
def test_switch_attack_during_telegraph(attacker, defender, punch, sweep):
|
||||
"""Test attack during TELEGRAPH replaces move and keeps timer."""
|
||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
encounter.attack(punch)
|
||||
original_start = encounter.move_started_at
|
||||
|
||||
assert encounter.state == CombatState.PENDING
|
||||
assert encounter.state == CombatState.TELEGRAPH
|
||||
|
||||
# Switch to sweep during pending
|
||||
time.sleep(0.1) # Small delay to ensure timer would differ
|
||||
# Switch to sweep during telegraph
|
||||
encounter.attack(sweep)
|
||||
|
||||
assert encounter.current_move is sweep
|
||||
assert encounter.state == CombatState.PENDING
|
||||
# Timer should restart on switch
|
||||
assert encounter.move_started_at > original_start
|
||||
assert encounter.state == CombatState.TELEGRAPH
|
||||
# Timer should NOT restart
|
||||
assert encounter.move_started_at == original_start
|
||||
|
||||
|
||||
def test_switch_attack_during_window(attacker, defender, punch, sweep):
|
||||
"""Test attack during WINDOW replaces move and keeps timer."""
|
||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
encounter.attack(punch)
|
||||
original_start = encounter.move_started_at
|
||||
|
||||
# Advance to WINDOW
|
||||
time.sleep(0.31)
|
||||
encounter.tick(time.monotonic())
|
||||
assert encounter.state == CombatState.WINDOW
|
||||
|
||||
# Switch to sweep during window
|
||||
encounter.attack(sweep)
|
||||
|
||||
assert encounter.current_move is sweep
|
||||
assert encounter.state == CombatState.WINDOW
|
||||
# Timer should NOT restart
|
||||
assert encounter.move_started_at == original_start
|
||||
|
||||
|
||||
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 != ""
|
||||
|
||||
|
||||
# --- 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):
|
||||
"""Attack startup should not reset timeout until damage lands."""
|
||||
def test_last_action_at_updates_on_attack(attacker, defender, punch):
|
||||
"""Test last_action_at is set when attack() is called."""
|
||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
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()
|
||||
encounter.resolve()
|
||||
encounter.attack(punch)
|
||||
|
||||
assert encounter.last_action_at >= before
|
||||
|
||||
|
||||
def test_last_action_at_unchanged_when_attack_is_countered(
|
||||
attacker, defender, punch, dodge
|
||||
):
|
||||
"""No damage (successful counter) should not refresh timeout timestamp."""
|
||||
encounter = CombatEncounter(
|
||||
attacker=attacker, defender=defender, last_action_at=10.0
|
||||
)
|
||||
def test_last_action_at_updates_on_defend(attacker, defender, punch, dodge):
|
||||
"""Test last_action_at is set when defend() is called."""
|
||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
encounter.attack(punch)
|
||||
first_action = encounter.last_action_at
|
||||
|
||||
time.sleep(0.01)
|
||||
encounter.defend(dodge)
|
||||
encounter.resolve()
|
||||
assert encounter.last_action_at == 10.0
|
||||
|
||||
|
||||
# --- 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
|
||||
assert encounter.last_action_at > first_action
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ def punch():
|
|||
name="punch right",
|
||||
move_type="attack",
|
||||
stamina_cost=5.0,
|
||||
hit_time_ms=800,
|
||||
timing_window_ms=800,
|
||||
damage_pct=0.15,
|
||||
countered_by=["dodge left"],
|
||||
)
|
||||
|
|
@ -107,7 +107,7 @@ async def test_process_combat_advances_encounters(attacker, defender, punch):
|
|||
time.sleep(0.31)
|
||||
await process_combat()
|
||||
|
||||
assert encounter.state == CombatState.PENDING
|
||||
assert encounter.state == CombatState.WINDOW
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -127,8 +127,8 @@ async def test_process_combat_handles_multiple_encounters(punch):
|
|||
time.sleep(0.31)
|
||||
await process_combat()
|
||||
|
||||
assert enc1.state == CombatState.PENDING
|
||||
assert enc2.state == CombatState.PENDING
|
||||
assert enc1.state == CombatState.WINDOW
|
||||
assert enc2.state == CombatState.WINDOW
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -141,7 +141,7 @@ async def test_process_combat_auto_resolves_expired_windows(attacker, defender,
|
|||
# Skip past telegraph and window
|
||||
time.sleep(0.31) # Telegraph
|
||||
await process_combat()
|
||||
assert encounter.state == CombatState.PENDING
|
||||
assert encounter.state == CombatState.WINDOW
|
||||
|
||||
time.sleep(0.85) # Window
|
||||
await process_combat()
|
||||
|
|
@ -201,8 +201,8 @@ async def test_encounter_cleanup_after_resolution(attacker, defender, punch):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_combat_keeps_encounter_after_knockout(punch):
|
||||
"""KO should not end combat; encounter stays active."""
|
||||
async def test_process_combat_ends_encounter_on_knockout(punch):
|
||||
"""Test process_combat ends encounter when defender is knocked out."""
|
||||
w = _mock_writer
|
||||
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
|
||||
defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0, writer=w())
|
||||
|
|
@ -220,16 +220,17 @@ async def test_process_combat_keeps_encounter_after_knockout(punch):
|
|||
time.sleep(0.85)
|
||||
await process_combat()
|
||||
|
||||
# Combat should remain active after KO
|
||||
assert get_encounter(attacker) is encounter
|
||||
assert get_encounter(defender) is encounter
|
||||
assert attacker.mode_stack == ["normal", "combat"]
|
||||
assert defender.mode_stack == ["normal", "combat"]
|
||||
# Combat should have ended and been cleaned up
|
||||
assert get_encounter(attacker) is None
|
||||
assert get_encounter(defender) is None
|
||||
# Mode stacks should have combat popped
|
||||
assert attacker.mode_stack == ["normal"]
|
||||
assert defender.mode_stack == ["normal"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_combat_keeps_encounter_after_exhaustion(punch):
|
||||
"""Exhaustion should not end combat; encounter stays active."""
|
||||
async def test_process_combat_ends_encounter_on_exhaustion(punch):
|
||||
"""Test process_combat ends encounter when attacker is exhausted."""
|
||||
w = _mock_writer
|
||||
attacker = Player(
|
||||
name="Goku",
|
||||
|
|
@ -261,68 +262,11 @@ async def test_process_combat_keeps_encounter_after_exhaustion(punch):
|
|||
time.sleep(0.85)
|
||||
await process_combat()
|
||||
|
||||
# Combat should remain active
|
||||
assert get_encounter(attacker) is encounter
|
||||
assert get_encounter(defender) is encounter
|
||||
assert attacker.mode_stack == ["normal", "combat"]
|
||||
assert defender.mode_stack == ["normal", "combat"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_combat_keeps_encounter_when_defender_already_exhausted(punch):
|
||||
"""Defender exhaustion should not auto-end encounter."""
|
||||
w = _mock_writer
|
||||
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
|
||||
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=0.0, writer=w())
|
||||
|
||||
attacker.mode_stack.append("combat")
|
||||
defender.mode_stack.append("combat")
|
||||
|
||||
encounter = start_encounter(attacker, defender)
|
||||
encounter.attack(punch)
|
||||
|
||||
time.sleep(0.31)
|
||||
await process_combat()
|
||||
time.sleep(0.85)
|
||||
await process_combat()
|
||||
|
||||
assert get_encounter(attacker) is encounter
|
||||
assert get_encounter(defender) is encounter
|
||||
assert attacker.mode_stack == ["normal", "combat"]
|
||||
assert defender.mode_stack == ["normal", "combat"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_combat_keeps_encounter_when_both_unconscious(punch):
|
||||
"""Double unconscious should not auto-end; timeout/finisher decides."""
|
||||
w = _mock_writer
|
||||
attacker = Player(
|
||||
name="Goku",
|
||||
x=0,
|
||||
y=0,
|
||||
pl=100.0,
|
||||
stamina=punch.stamina_cost,
|
||||
writer=w(),
|
||||
)
|
||||
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=0.0, writer=w())
|
||||
|
||||
attacker.mode_stack.append("combat")
|
||||
defender.mode_stack.append("combat")
|
||||
|
||||
encounter = start_encounter(attacker, defender)
|
||||
encounter.attack(punch)
|
||||
|
||||
time.sleep(0.31)
|
||||
await process_combat()
|
||||
time.sleep(0.85)
|
||||
await process_combat()
|
||||
|
||||
assert get_encounter(attacker) is encounter
|
||||
assert get_encounter(defender) is encounter
|
||||
assert attacker.kills == 0
|
||||
assert defender.kills == 0
|
||||
assert attacker.deaths == 0
|
||||
assert defender.deaths == 0
|
||||
# Combat should have ended
|
||||
assert get_encounter(attacker) is None
|
||||
assert get_encounter(defender) is None
|
||||
assert attacker.mode_stack == ["normal"]
|
||||
assert defender.mode_stack == ["normal"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -391,7 +335,7 @@ async def test_process_combat_sends_messages_on_resolve(punch):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
attacker = Player(name="Goku", 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
|
||||
async def test_recent_action_prevents_timeout():
|
||||
"""Fresh encounter start prevents immediate timeout."""
|
||||
"""Test recent action prevents idle timeout."""
|
||||
w = _mock_writer
|
||||
attacker = Player(name="Goku", 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
|
||||
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)
|
||||
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)
|
||||
|
||||
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"],
|
||||
stamina_cost=5.0,
|
||||
telegraph="{attacker} winds up a right hook!",
|
||||
hit_time_ms=800,
|
||||
timing_window_ms=800,
|
||||
damage_pct=0.15,
|
||||
countered_by=["dodge left", "parry high"],
|
||||
command="punch",
|
||||
|
|
@ -24,7 +24,7 @@ def test_combat_move_dataclass():
|
|||
assert move.aliases == ["pr"]
|
||||
assert move.stamina_cost == 5.0
|
||||
assert move.telegraph == "{attacker} winds up a right hook!"
|
||||
assert move.hit_time_ms == 800
|
||||
assert move.timing_window_ms == 800
|
||||
assert move.damage_pct == 0.15
|
||||
assert move.countered_by == ["dodge left", "parry high"]
|
||||
assert move.handler is None
|
||||
|
|
@ -38,14 +38,14 @@ def test_combat_move_minimal():
|
|||
name="test move",
|
||||
move_type="attack",
|
||||
stamina_cost=10.0,
|
||||
hit_time_ms=500,
|
||||
timing_window_ms=500,
|
||||
)
|
||||
assert move.name == "test move"
|
||||
assert move.move_type == "attack"
|
||||
assert move.aliases == []
|
||||
assert move.stamina_cost == 10.0
|
||||
assert move.telegraph == ""
|
||||
assert move.hit_time_ms == 500
|
||||
assert move.timing_window_ms == 500
|
||||
assert move.damage_pct == 0.0
|
||||
assert move.countered_by == []
|
||||
assert move.command == ""
|
||||
|
|
@ -60,7 +60,7 @@ aliases = ["rh"]
|
|||
move_type = "attack"
|
||||
stamina_cost = 8.0
|
||||
telegraph = "{attacker} spins into a roundhouse kick!"
|
||||
hit_time_ms = 600
|
||||
timing_window_ms = 600
|
||||
damage_pct = 0.25
|
||||
countered_by = ["duck", "parry high", "parry low"]
|
||||
"""
|
||||
|
|
@ -83,7 +83,7 @@ def test_load_variant_move_from_toml(tmp_path):
|
|||
name = "punch"
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 800
|
||||
timing_window_ms = 800
|
||||
damage_pct = 0.15
|
||||
|
||||
[variants.left]
|
||||
|
|
@ -114,7 +114,7 @@ countered_by = ["dodge left", "parry high"]
|
|||
assert left.countered_by == ["dodge right", "parry high"]
|
||||
# Inherited from parent
|
||||
assert left.stamina_cost == 5.0
|
||||
assert left.hit_time_ms == 800
|
||||
assert left.timing_window_ms == 800
|
||||
assert left.damage_pct == 0.15
|
||||
|
||||
right = by_name["punch right"]
|
||||
|
|
@ -130,13 +130,13 @@ def test_variant_inherits_shared_properties(tmp_path):
|
|||
name = "kick"
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 800
|
||||
timing_window_ms = 800
|
||||
damage_pct = 0.10
|
||||
|
||||
[variants.low]
|
||||
aliases = ["kl"]
|
||||
damage_pct = 0.08
|
||||
hit_time_ms = 600
|
||||
timing_window_ms = 600
|
||||
|
||||
[variants.high]
|
||||
aliases = ["kh"]
|
||||
|
|
@ -150,12 +150,12 @@ damage_pct = 0.15
|
|||
|
||||
low = by_name["kick low"]
|
||||
assert low.damage_pct == 0.08
|
||||
assert low.hit_time_ms == 600 # overridden
|
||||
assert low.timing_window_ms == 600 # overridden
|
||||
assert low.stamina_cost == 5.0 # inherited
|
||||
|
||||
high = by_name["kick high"]
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -165,8 +165,7 @@ def test_load_move_with_defaults(tmp_path):
|
|||
name = "basic move"
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
active_ms = 600
|
||||
recovery_ms = 2700
|
||||
timing_window_ms = 600
|
||||
"""
|
||||
toml_file = tmp_path / "basic.toml"
|
||||
toml_file.write_text(toml_content)
|
||||
|
|
@ -186,7 +185,7 @@ def test_load_move_missing_name(tmp_path):
|
|||
toml_content = """
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 800
|
||||
timing_window_ms = 800
|
||||
"""
|
||||
toml_file = tmp_path / "bad.toml"
|
||||
toml_file.write_text(toml_content)
|
||||
|
|
@ -214,7 +213,7 @@ def test_load_move_missing_stamina_cost(tmp_path):
|
|||
toml_content = """
|
||||
name = "test"
|
||||
move_type = "attack"
|
||||
hit_time_ms = 800
|
||||
timing_window_ms = 800
|
||||
"""
|
||||
toml_file = tmp_path / "bad.toml"
|
||||
toml_file.write_text(toml_content)
|
||||
|
|
@ -223,8 +222,8 @@ hit_time_ms = 800
|
|||
load_move(toml_file)
|
||||
|
||||
|
||||
def test_load_attack_missing_hit_time_raises(tmp_path):
|
||||
"""Test loading attack without hit_time_ms raises error."""
|
||||
def test_load_move_missing_timing_window(tmp_path):
|
||||
"""Test loading move without timing_window_ms raises error."""
|
||||
toml_content = """
|
||||
name = "test"
|
||||
move_type = "attack"
|
||||
|
|
@ -233,89 +232,10 @@ stamina_cost = 5.0
|
|||
toml_file = tmp_path / "bad.toml"
|
||||
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)
|
||||
|
||||
|
||||
def test_load_attack_zero_hit_time_raises(tmp_path):
|
||||
"""Test loading attack with hit_time_ms = 0 raises error."""
|
||||
toml_content = """
|
||||
name = "test"
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 0
|
||||
"""
|
||||
toml_file = tmp_path / "bad.toml"
|
||||
toml_file.write_text(toml_content)
|
||||
|
||||
with pytest.raises(ValueError, match="hit_time_ms"):
|
||||
load_move(toml_file)
|
||||
|
||||
|
||||
def test_load_defense_missing_active_ms_raises(tmp_path):
|
||||
"""Test loading defense without active_ms raises error."""
|
||||
toml_content = """
|
||||
name = "test"
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
recovery_ms = 2000
|
||||
"""
|
||||
toml_file = tmp_path / "bad.toml"
|
||||
toml_file.write_text(toml_content)
|
||||
|
||||
with pytest.raises(ValueError, match="active_ms"):
|
||||
load_move(toml_file)
|
||||
|
||||
|
||||
def test_load_defense_zero_active_ms_raises(tmp_path):
|
||||
"""Test loading defense with active_ms = 0 raises error."""
|
||||
toml_content = """
|
||||
name = "test"
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
active_ms = 0
|
||||
recovery_ms = 2000
|
||||
"""
|
||||
toml_file = tmp_path / "bad.toml"
|
||||
toml_file.write_text(toml_content)
|
||||
|
||||
with pytest.raises(ValueError, match="active_ms"):
|
||||
load_move(toml_file)
|
||||
|
||||
|
||||
def test_load_attack_valid_passes(tmp_path):
|
||||
"""Test loading attack with valid hit_time_ms passes."""
|
||||
toml_content = """
|
||||
name = "test"
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 500
|
||||
"""
|
||||
toml_file = tmp_path / "valid.toml"
|
||||
toml_file.write_text(toml_content)
|
||||
|
||||
moves = load_move(toml_file)
|
||||
assert len(moves) == 1
|
||||
assert moves[0].hit_time_ms == 500
|
||||
|
||||
|
||||
def test_load_defense_valid_passes(tmp_path):
|
||||
"""Test loading defense with valid active_ms passes."""
|
||||
toml_content = """
|
||||
name = "test"
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
active_ms = 500
|
||||
recovery_ms = 2000
|
||||
"""
|
||||
toml_file = tmp_path / "valid.toml"
|
||||
toml_file.write_text(toml_content)
|
||||
|
||||
moves = load_move(toml_file)
|
||||
assert len(moves) == 1
|
||||
assert moves[0].active_ms == 500
|
||||
|
||||
|
||||
def test_load_moves_from_directory(tmp_path):
|
||||
"""Test loading all moves from a directory."""
|
||||
# Create a variant move
|
||||
|
|
@ -325,7 +245,7 @@ def test_load_moves_from_directory(tmp_path):
|
|||
name = "punch"
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 800
|
||||
timing_window_ms = 800
|
||||
damage_pct = 0.15
|
||||
|
||||
[variants.right]
|
||||
|
|
@ -342,8 +262,7 @@ countered_by = ["dodge left"]
|
|||
name = "duck"
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
active_ms = 500
|
||||
recovery_ms = 2700
|
||||
timing_window_ms = 500
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
@ -384,7 +303,7 @@ name = "move one"
|
|||
aliases = ["m"]
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 800
|
||||
timing_window_ms = 800
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
@ -395,8 +314,7 @@ name = "move two"
|
|||
aliases = ["m"]
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
active_ms = 500
|
||||
recovery_ms = 2700
|
||||
timing_window_ms = 500
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
@ -412,7 +330,7 @@ def test_load_moves_name_collision(tmp_path):
|
|||
name = "punch"
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 800
|
||||
timing_window_ms = 800
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
@ -422,7 +340,7 @@ hit_time_ms = 800
|
|||
name = "punch"
|
||||
move_type = "attack"
|
||||
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"
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 800
|
||||
timing_window_ms = 800
|
||||
damage_pct = 0.15
|
||||
|
||||
[variants.right]
|
||||
|
|
@ -454,8 +372,7 @@ countered_by = ["dodge left", "nonexistent move"]
|
|||
name = "dodge"
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
active_ms = 500
|
||||
recovery_ms = 2700
|
||||
timing_window_ms = 500
|
||||
|
||||
[variants.left]
|
||||
aliases = ["dl"]
|
||||
|
|
@ -484,7 +401,7 @@ def test_load_moves_valid_countered_by_refs_no_warning(tmp_path, caplog):
|
|||
name = "punch"
|
||||
move_type = "attack"
|
||||
stamina_cost = 5.0
|
||||
hit_time_ms = 800
|
||||
timing_window_ms = 800
|
||||
damage_pct = 0.15
|
||||
|
||||
[variants.right]
|
||||
|
|
@ -498,8 +415,7 @@ countered_by = ["dodge left", "parry high"]
|
|||
name = "dodge"
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
active_ms = 500
|
||||
recovery_ms = 2700
|
||||
timing_window_ms = 500
|
||||
|
||||
[variants.left]
|
||||
aliases = ["dl"]
|
||||
|
|
@ -512,8 +428,7 @@ aliases = ["dl"]
|
|||
name = "parry"
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
active_ms = 500
|
||||
recovery_ms = 2700
|
||||
timing_window_ms = 500
|
||||
|
||||
[variants.high]
|
||||
aliases = ["f"]
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ def attack_move():
|
|||
variant="left",
|
||||
move_type="attack",
|
||||
stamina_cost=5,
|
||||
hit_time_ms=850,
|
||||
timing_window_ms=850,
|
||||
telegraph="telegraphs a left punch at {defender}",
|
||||
telegraph_color="yellow",
|
||||
aliases=[],
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ async def test_flying_during_window_causes_miss(player, target, punch_right):
|
|||
encounter.attack(punch_right)
|
||||
|
||||
# Advance to WINDOW phase
|
||||
encounter.state = CombatState.PENDING
|
||||
encounter.state = CombatState.WINDOW
|
||||
|
||||
# Defender flies during window
|
||||
target.flying = True
|
||||
|
|
@ -159,7 +159,7 @@ async def test_both_flying_at_resolve_attack_lands(player, target, punch_right):
|
|||
encounter.attack(punch_right)
|
||||
|
||||
# Advance to WINDOW phase (no altitude change)
|
||||
encounter.state = CombatState.PENDING
|
||||
encounter.state = CombatState.WINDOW
|
||||
|
||||
# Resolve
|
||||
result = encounter.resolve()
|
||||
|
|
@ -180,7 +180,7 @@ async def test_attacker_flies_during_window_causes_miss(player, target, punch_ri
|
|||
encounter.attack(punch_right)
|
||||
|
||||
# Advance to WINDOW phase
|
||||
encounter.state = CombatState.PENDING
|
||||
encounter.state = CombatState.WINDOW
|
||||
|
||||
# Attacker flies during window
|
||||
player.flying = True
|
||||
|
|
@ -205,7 +205,7 @@ async def test_flying_dodge_messages_correct_grammar(player, target, punch_right
|
|||
encounter.attack(punch_right)
|
||||
|
||||
# Advance to WINDOW phase
|
||||
encounter.state = CombatState.PENDING
|
||||
encounter.state = CombatState.WINDOW
|
||||
|
||||
# Defender flies during window
|
||||
target.flying = True
|
||||
|
|
|
|||
|
|
@ -21,6 +21,19 @@ def _clean_test_commands():
|
|||
commands._registry.update(snapshot)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_zone():
|
||||
# Create a 100x100 zone filled with passable terrain
|
||||
|
|
@ -84,6 +97,7 @@ async def test_dispatch_routes_to_handler(player):
|
|||
commands.register(CommandDefinition("testcmd", test_handler))
|
||||
await commands.dispatch(player, "testcmd arg1 arg2")
|
||||
|
||||
assert called
|
||||
assert received_args == "arg1 arg2"
|
||||
|
||||
|
||||
|
|
@ -346,7 +360,7 @@ async def test_dispatch_allows_wildcard_mode(player):
|
|||
commands.register(CommandDefinition("universal", any_handler, mode="*"))
|
||||
await commands.dispatch(player, "universal")
|
||||
|
||||
assert called is True
|
||||
assert called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -362,7 +376,7 @@ async def test_dispatch_allows_matching_mode(player):
|
|||
player.mode_stack.append("combat")
|
||||
await commands.dispatch(player, "strike")
|
||||
|
||||
assert called is True
|
||||
assert called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for the commands listing command."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
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
|
||||
def player(mock_reader, mock_writer):
|
||||
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 "type: attack" 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 "{attacker} shifts {his} weight back..." 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
|
||||
assert "stamina: 5.0" in output
|
||||
assert "hit time: 3000ms" in output
|
||||
assert "timing window: 1800ms" 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 "type: attack" 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 "{attacker} retracts {his} left arm..." in output
|
||||
assert "countered by: dodge right, parry high" in output
|
||||
|
|
|
|||
|
|
@ -1,44 +1,60 @@
|
|||
"""Tests for the Container class."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.container import Container
|
||||
from mudlib.object import Object
|
||||
from mudlib.player import Player
|
||||
from mudlib.thing import Thing
|
||||
from mudlib.zone import Zone
|
||||
|
||||
# --- 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 ---
|
||||
|
||||
|
||||
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 ---
|
||||
|
||||
|
||||
|
|
@ -116,105 +132,3 @@ def test_container_with_contents():
|
|||
assert sword in chest.contents
|
||||
assert gem in chest.contents
|
||||
assert len(chest.contents) == 2
|
||||
|
||||
|
||||
# --- look command container display ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_look_shows_closed_container(player, test_zone, mock_writer):
|
||||
"""look shows closed containers with (closed) suffix."""
|
||||
from mudlib.commands.look import cmd_look
|
||||
|
||||
Container(name="chest", location=test_zone, x=5, y=5, closed=True)
|
||||
await cmd_look(player, "")
|
||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||
assert "chest (closed)" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_look_shows_open_empty_container(player, test_zone, mock_writer):
|
||||
"""look shows open empty containers with (open, empty) suffix."""
|
||||
from mudlib.commands.look import cmd_look
|
||||
|
||||
Container(name="chest", location=test_zone, x=5, y=5, closed=False)
|
||||
await cmd_look(player, "")
|
||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||
assert "chest (open, empty)" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_look_shows_open_container_with_contents(player, test_zone, mock_writer):
|
||||
"""look shows open containers with their contents."""
|
||||
from mudlib.commands.look import cmd_look
|
||||
|
||||
chest = Container(name="chest", location=test_zone, x=5, y=5, closed=False)
|
||||
Thing(name="rock", location=chest)
|
||||
Thing(name="coin", location=chest)
|
||||
await cmd_look(player, "")
|
||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||
assert "chest (open, containing: rock, coin)" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_look_shows_regular_things_unchanged(player, test_zone, mock_writer):
|
||||
"""look shows regular Things without container suffixes."""
|
||||
from mudlib.commands.look import cmd_look
|
||||
|
||||
Thing(name="rock", location=test_zone, x=5, y=5)
|
||||
await cmd_look(player, "")
|
||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||
assert "On the ground: rock" in output
|
||||
assert "(closed)" not in output
|
||||
assert "(open" not in output
|
||||
|
||||
|
||||
# --- inventory command container display ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inventory_shows_closed_container(player, mock_writer):
|
||||
"""inventory shows closed containers with (closed) suffix."""
|
||||
from mudlib.commands.things import cmd_inventory
|
||||
|
||||
Container(name="sack", location=player, closed=True)
|
||||
await cmd_inventory(player, "")
|
||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||
assert "sack (closed)" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inventory_shows_open_empty_container(player, mock_writer):
|
||||
"""inventory shows open empty containers with (open, empty) suffix."""
|
||||
from mudlib.commands.things import cmd_inventory
|
||||
|
||||
Container(name="sack", location=player, closed=False)
|
||||
await cmd_inventory(player, "")
|
||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||
assert "sack (open, empty)" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inventory_shows_container_with_contents(player, mock_writer):
|
||||
"""inventory shows open containers with their contents."""
|
||||
from mudlib.commands.things import cmd_inventory
|
||||
|
||||
sack = Container(name="sack", location=player, closed=False)
|
||||
Thing(name="rock", location=sack)
|
||||
Thing(name="gem", location=sack)
|
||||
await cmd_inventory(player, "")
|
||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||
assert "sack (open, containing: rock, gem)" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inventory_shows_regular_things_unchanged(player, mock_writer):
|
||||
"""inventory shows regular Things without container suffixes."""
|
||||
from mudlib.commands.things import cmd_inventory
|
||||
|
||||
Thing(name="rock", location=player)
|
||||
await cmd_inventory(player, "")
|
||||
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
|
||||
assert " rock\r\n" in output
|
||||
assert "(closed)" not in output
|
||||
assert "(open" not in output
|
||||
|
|
|
|||
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 tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -10,6 +11,19 @@ from mudlib.content import load_command, load_commands
|
|||
from mudlib.player import Player
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer):
|
||||
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from unittest.mock import MagicMock
|
|||
|
||||
import pytest
|
||||
|
||||
from mudlib.container import Container
|
||||
from mudlib.corpse import Corpse, create_corpse
|
||||
from mudlib.entity import Mob
|
||||
from mudlib.mobs import mobs
|
||||
|
|
@ -62,6 +63,36 @@ def potion():
|
|||
return Thing(name="potion", description="a health potion", portable=True)
|
||||
|
||||
|
||||
class TestCorpseClass:
|
||||
def test_corpse_is_container_subclass(self):
|
||||
"""Corpse is a subclass of Container."""
|
||||
assert issubclass(Corpse, Container)
|
||||
|
||||
def test_corpse_not_portable(self, test_zone):
|
||||
"""Corpse is not portable (can't pick up a corpse)."""
|
||||
corpse = Corpse(name="test corpse", location=test_zone, x=0, y=0)
|
||||
assert corpse.portable is False
|
||||
|
||||
def test_corpse_always_open(self, test_zone):
|
||||
"""Corpse is always open (closed=False)."""
|
||||
corpse = Corpse(name="test corpse", location=test_zone, x=0, y=0)
|
||||
assert corpse.closed is False
|
||||
|
||||
def test_corpse_has_decompose_at_field(self, test_zone):
|
||||
"""Corpse has decompose_at field (float, monotonic time)."""
|
||||
decompose_time = time.monotonic() + 300
|
||||
corpse = Corpse(
|
||||
name="test corpse",
|
||||
location=test_zone,
|
||||
x=0,
|
||||
y=0,
|
||||
decompose_at=decompose_time,
|
||||
)
|
||||
assert hasattr(corpse, "decompose_at")
|
||||
assert isinstance(corpse.decompose_at, float)
|
||||
assert corpse.decompose_at == decompose_time
|
||||
|
||||
|
||||
class TestCreateCorpseFactory:
|
||||
def test_creates_corpse_at_mob_position(self, goblin_mob, test_zone):
|
||||
"""create_corpse creates a corpse at mob's x, y in the zone."""
|
||||
|
|
@ -188,7 +219,7 @@ class TestCorpseAsContainer:
|
|||
|
||||
|
||||
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)
|
||||
def clear_corpses(self):
|
||||
|
|
@ -199,8 +230,8 @@ class TestCombatDeathCorpse:
|
|||
active_corpses.clear()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_knockout_in_combat_does_not_spawn_corpse(self, test_zone):
|
||||
"""KO in combat should not create a corpse by itself."""
|
||||
async def test_mob_death_in_combat_spawns_corpse(self, test_zone):
|
||||
"""Mob death in combat spawns a corpse at mob's position."""
|
||||
from mudlib.combat.encounter import CombatState
|
||||
from mudlib.combat.engine import (
|
||||
active_encounters,
|
||||
|
|
@ -242,7 +273,7 @@ class TestCombatDeathCorpse:
|
|||
name="punch right",
|
||||
move_type="attack",
|
||||
stamina_cost=5.0,
|
||||
hit_time_ms=800,
|
||||
timing_window_ms=800,
|
||||
damage_pct=0.15,
|
||||
countered_by=["dodge left"],
|
||||
)
|
||||
|
|
@ -252,17 +283,16 @@ class TestCombatDeathCorpse:
|
|||
# Process combat to trigger resolve
|
||||
await process_combat()
|
||||
|
||||
# Check no corpse spawned yet
|
||||
# Check for corpse at mob's position
|
||||
corpses = [
|
||||
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
|
||||
]
|
||||
assert len(corpses) == 0
|
||||
assert mob in mobs
|
||||
assert mob.alive is True
|
||||
assert len(corpses) == 1
|
||||
assert corpses[0].name == "goblin's corpse"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_knockout_keeps_inventory_on_mob(self, test_zone, sword):
|
||||
"""KO should not transfer inventory to a corpse until finished."""
|
||||
async def test_mob_death_transfers_inventory_to_corpse(self, test_zone, sword):
|
||||
"""Mob death transfers inventory to corpse."""
|
||||
from mudlib.combat.encounter import CombatState
|
||||
from mudlib.combat.engine import (
|
||||
active_encounters,
|
||||
|
|
@ -303,7 +333,7 @@ class TestCombatDeathCorpse:
|
|||
name="punch right",
|
||||
move_type="attack",
|
||||
stamina_cost=5.0,
|
||||
hit_time_ms=800,
|
||||
timing_window_ms=800,
|
||||
damage_pct=0.15,
|
||||
countered_by=["dodge left"],
|
||||
)
|
||||
|
|
@ -313,17 +343,20 @@ class TestCombatDeathCorpse:
|
|||
# Process combat
|
||||
await process_combat()
|
||||
|
||||
# No corpse yet
|
||||
# Find corpse
|
||||
corpses = [
|
||||
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
|
||||
]
|
||||
assert len(corpses) == 0
|
||||
assert sword in mob._contents
|
||||
assert sword.location is mob
|
||||
assert len(corpses) == 1
|
||||
corpse = corpses[0]
|
||||
|
||||
# Verify sword is in corpse
|
||||
assert sword in corpse._contents
|
||||
assert sword.location is corpse
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_corpse_in_zone_contents_after_ko(self, test_zone):
|
||||
"""Zone should not contain corpse from a plain KO."""
|
||||
async def test_corpse_appears_in_zone_contents(self, test_zone):
|
||||
"""Corpse appears in zone.contents_at after mob death."""
|
||||
from mudlib.combat.encounter import CombatState
|
||||
from mudlib.combat.engine import (
|
||||
active_encounters,
|
||||
|
|
@ -363,7 +396,7 @@ class TestCombatDeathCorpse:
|
|||
name="punch right",
|
||||
move_type="attack",
|
||||
stamina_cost=5.0,
|
||||
hit_time_ms=800,
|
||||
timing_window_ms=800,
|
||||
damage_pct=0.15,
|
||||
countered_by=["dodge left"],
|
||||
)
|
||||
|
|
@ -373,50 +406,14 @@ class TestCombatDeathCorpse:
|
|||
# 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))
|
||||
corpse_count = sum(1 for obj in contents if isinstance(obj, Corpse))
|
||||
assert corpse_count == 0
|
||||
assert corpse_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapneck_finisher_spawns_corpse(self, test_zone):
|
||||
"""Explicit finisher kill should create a corpse."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from mudlib.commands.snapneck import cmd_snap_neck
|
||||
from mudlib.player import Player, players
|
||||
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
reader = MagicMock()
|
||||
attacker = Player(name="hero", x=5, y=10, reader=reader, writer=writer)
|
||||
attacker.location = test_zone
|
||||
test_zone._contents.append(attacker)
|
||||
players[attacker.name] = attacker
|
||||
|
||||
mob = Mob(
|
||||
name="goblin",
|
||||
x=5,
|
||||
y=10,
|
||||
location=test_zone,
|
||||
pl=0.0,
|
||||
stamina=0.0,
|
||||
)
|
||||
mobs.append(mob)
|
||||
|
||||
from mudlib.combat.engine import start_encounter
|
||||
|
||||
start_encounter(attacker, mob)
|
||||
attacker.mode_stack.append("combat")
|
||||
await cmd_snap_neck(attacker, "goblin")
|
||||
|
||||
corpses = [
|
||||
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
|
||||
]
|
||||
assert len(corpses) == 1
|
||||
assert corpses[0].name == "goblin's corpse"
|
||||
players.clear()
|
||||
# Verify it's the goblin's corpse
|
||||
corpse = next(obj for obj in contents if isinstance(obj, Corpse))
|
||||
assert corpse.name == "goblin's corpse"
|
||||
|
||||
|
||||
class TestCorpseDisplay:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Tests for editor integration with the shell and command system."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -10,6 +10,19 @@ from mudlib.editor import Editor
|
|||
from mudlib.player import Player
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer):
|
||||
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||
|
|
@ -34,6 +47,7 @@ async def test_edit_command_sends_welcome_message(player, mock_writer):
|
|||
"""Test that edit command sends welcome message."""
|
||||
await cmd_edit(player, "")
|
||||
|
||||
assert mock_writer.write.called
|
||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||
assert "editor" in output.lower()
|
||||
assert ":h" in output
|
||||
|
|
@ -91,6 +105,7 @@ async def test_editor_save_callback_sends_message(player, mock_writer):
|
|||
|
||||
assert response.saved is True
|
||||
# Save callback should have sent a message
|
||||
assert mock_writer.write.called
|
||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||
assert "saved" in output.lower()
|
||||
|
||||
|
|
@ -172,7 +187,7 @@ async def test_edit_combat_move_opens_toml(player, tmp_path):
|
|||
aliases = ["rh"]
|
||||
move_type = "attack"
|
||||
stamina_cost = 8.0
|
||||
hit_time_ms = 2000
|
||||
timing_window_ms = 2000
|
||||
"""
|
||||
toml_file = tmp_path / "roundhouse.toml"
|
||||
toml_file.write_text(toml_content)
|
||||
|
|
@ -222,6 +237,7 @@ move_type = "attack"
|
|||
# Check that file was written
|
||||
saved_content = toml_file.read_text()
|
||||
assert "stamina_cost = 9.0" in saved_content
|
||||
assert mock_writer.write.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -264,6 +280,7 @@ async def test_edit_unknown_content_shows_error(player, mock_writer, tmp_path):
|
|||
|
||||
assert player.editor is None
|
||||
assert player.mode == "normal"
|
||||
assert mock_writer.write.called
|
||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||
assert "unknown" in output.lower()
|
||||
assert "nonexistent" in output.lower()
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ def test_mud_filesystem_save_restore(tmp_path):
|
|||
|
||||
test_data = b"\x01\x02\x03\x04\x05"
|
||||
success = filesystem.save_game(test_data)
|
||||
assert success is True
|
||||
assert success
|
||||
assert save_path.exists()
|
||||
|
||||
restored = filesystem.restore_game()
|
||||
|
|
@ -163,6 +163,7 @@ async def test_embedded_session_handle_input():
|
|||
|
||||
response = await session.handle_input("look")
|
||||
|
||||
assert response is not None
|
||||
assert response.done is False
|
||||
assert len(response.output) > 0
|
||||
# Looking should describe the starting location
|
||||
|
|
|
|||
|
|
@ -11,6 +11,19 @@ from mudlib.zone import Zone
|
|||
from mudlib.zones import register_zone, zone_registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_zone():
|
||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||
|
|
|
|||
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."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.entity import Entity, Mob
|
||||
from mudlib.player import Player
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
def test_entity_default_posture():
|
||||
"""Entity with no special state should be 'standing'."""
|
||||
entity = Entity(name="Test", x=0, y=0)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.commands.examine import cmd_examine
|
||||
|
|
@ -8,6 +10,19 @@ 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)]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,19 @@ from mudlib.player import Player, players
|
|||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_zone():
|
||||
terrain = [["." for _ in range(100)] for _ in range(100)]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""Tests for get and drop commands."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.commands import _registry
|
||||
|
|
@ -9,6 +11,19 @@ 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)]
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@ async def test_char_vitals_sent_on_combat_resolve():
|
|||
name="punch right",
|
||||
move_type="attack",
|
||||
stamina_cost=5.0,
|
||||
hit_time_ms=800,
|
||||
timing_window_ms=800,
|
||||
damage_pct=0.15,
|
||||
countered_by=["dodge left"],
|
||||
)
|
||||
|
|
@ -413,9 +413,11 @@ async def test_char_vitals_sent_on_combat_resolve():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_char_status_sent_on_combat_end():
|
||||
"""Test Char.Status is sent when combat ends (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.moves import CombatMove
|
||||
|
||||
# Clear encounters
|
||||
active_encounters.clear()
|
||||
|
|
@ -441,9 +443,22 @@ async def test_char_status_sent_on_combat_end():
|
|||
attacker.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.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
|
||||
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."""
|
||||
|
||||
import textwrap
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib import commands
|
||||
from mudlib.commands import help as help_mod # noqa: F401
|
||||
from mudlib.commands import (
|
||||
helpadmin, # noqa: F401
|
||||
look, # noqa: F401
|
||||
movement, # noqa: F401
|
||||
)
|
||||
from mudlib.commands import helpadmin # noqa: F401
|
||||
from mudlib.commands.help import _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
|
||||
def player(mock_reader, mock_writer):
|
||||
from mudlib.player import Player
|
||||
|
||||
return Player(name="Tester", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_player(mock_reader, mock_writer):
|
||||
def player(mock_writer):
|
||||
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
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_and_load_topics():
|
||||
"""Clear help topics, load content topics, then clear again after tests."""
|
||||
from pathlib import Path
|
||||
|
||||
def _clear_topics():
|
||||
_help_topics.clear()
|
||||
help_dir = Path(__file__).resolve().parents[1] / "content" / "help"
|
||||
if help_dir.exists():
|
||||
loaded = load_help_topics(help_dir)
|
||||
_help_topics.update(loaded)
|
||||
yield
|
||||
_help_topics.clear()
|
||||
|
||||
|
|
@ -264,82 +261,3 @@ def test_at_help_toml_loads_from_content():
|
|||
topics = load_help_topics(help_dir)
|
||||
assert "@help" in topics
|
||||
assert topics["@help"].admin is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_command_is_registered():
|
||||
"""The help command should be registered in the command registry."""
|
||||
assert "help" in commands._registry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_has_wildcard_mode():
|
||||
"""Help should work from any mode."""
|
||||
cmd_def = commands._registry["help"]
|
||||
assert cmd_def.mode == "*"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_no_args_shows_usage(player):
|
||||
"""help with no args shows usage hint."""
|
||||
await commands.dispatch(player, "help")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
assert "help <command>" in output
|
||||
assert "commands" in output
|
||||
assert "skills" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_known_command_shows_detail(player):
|
||||
"""help <known command> shows detail view."""
|
||||
await commands.dispatch(player, "help look")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
assert "look" in output.lower()
|
||||
assert "mode:" in output.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_unknown_command_shows_error(player):
|
||||
"""help <unknown> shows error message."""
|
||||
await commands.dispatch(player, "help nonexistent")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
assert "nonexistent" in output.lower()
|
||||
assert "unknown" in output.lower() or "not found" in output.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_and_commands_both_exist():
|
||||
"""Both help and commands should be registered independently."""
|
||||
assert "help" in commands._registry
|
||||
assert "commands" in commands._registry
|
||||
# They should be different functions
|
||||
assert commands._registry["help"].handler != commands._registry["commands"].handler
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_zones_shows_guide(admin_player):
|
||||
"""help zones shows zone guide text with command references."""
|
||||
await commands.dispatch(admin_player, "help zones")
|
||||
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
|
||||
assert "zones" in output
|
||||
assert "@zones" in output
|
||||
assert "@goto" in output
|
||||
assert "@dig" in output
|
||||
assert "@paint" in output
|
||||
assert "@save" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_zones_shows_see_also(admin_player):
|
||||
"""help zones output contains see also cross-references."""
|
||||
await commands.dispatch(admin_player, "help zones")
|
||||
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
|
||||
assert "see:" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_zones_requires_admin(player):
|
||||
"""Non-admin players cannot see admin help topics."""
|
||||
await commands.dispatch(player, "help zones")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
assert "unknown" in output.lower()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Tests for help command showing unlock status for combat moves."""
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -17,6 +17,14 @@ def clear_state():
|
|||
players.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_writer):
|
||||
return Player(name="Test", writer=mock_writer)
|
||||
|
|
@ -29,7 +37,7 @@ def mock_move_kill_count():
|
|||
name="roundhouse",
|
||||
move_type="attack",
|
||||
stamina_cost=30.0,
|
||||
hit_time_ms=850,
|
||||
timing_window_ms=850,
|
||||
aliases=["rh"],
|
||||
description="A powerful spinning kick",
|
||||
damage_pct=0.35,
|
||||
|
|
@ -44,7 +52,7 @@ def mock_move_mob_kills():
|
|||
name="goblin slayer",
|
||||
move_type="attack",
|
||||
stamina_cost=25.0,
|
||||
hit_time_ms=800,
|
||||
timing_window_ms=800,
|
||||
description="Specialized technique against goblins",
|
||||
damage_pct=0.40,
|
||||
unlock_condition=UnlockCondition(
|
||||
|
|
@ -60,7 +68,7 @@ def mock_move_no_unlock():
|
|||
name="jab",
|
||||
move_type="attack",
|
||||
stamina_cost=10.0,
|
||||
hit_time_ms=600,
|
||||
timing_window_ms=600,
|
||||
description="A quick straight punch",
|
||||
damage_pct=0.15,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,19 @@ from mudlib.if_session import IFResponse, IFSession
|
|||
from mudlib.player import Player
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer):
|
||||
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,33 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,19 @@ from mudlib.player import Player, players
|
|||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_players():
|
||||
"""Clear players registry before and after each test."""
|
||||
|
|
|
|||
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."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
def player(mock_reader, mock_writer, test_zone):
|
||||
"""Create a test player."""
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.container import Container
|
||||
|
|
@ -6,6 +8,19 @@ 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)]
|
||||
|
|
|
|||
|
|
@ -4,25 +4,54 @@ import time
|
|||
|
||||
import pytest
|
||||
|
||||
from mudlib.combat.engine import start_encounter
|
||||
from mudlib.commands.snapneck import cmd_snap_neck
|
||||
from mudlib.combat.engine import (
|
||||
process_combat,
|
||||
start_encounter,
|
||||
)
|
||||
from mudlib.combat.moves import CombatMove
|
||||
from mudlib.entity import Mob
|
||||
from mudlib.player import accumulate_play_time
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def punch_move():
|
||||
"""Create a basic punch move for testing."""
|
||||
return CombatMove(
|
||||
name="punch right",
|
||||
move_type="attack",
|
||||
stamina_cost=5.0,
|
||||
timing_window_ms=800,
|
||||
damage_pct=0.15,
|
||||
countered_by=[],
|
||||
resolve_hit="{attacker} hits {defender}!",
|
||||
resolve_miss="{defender} dodges!",
|
||||
announce="{attacker} punches!",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_player_finishes_mob_increments_stats(player, test_zone):
|
||||
"""Snap-neck kill increments kills and mob_kills."""
|
||||
async def test_player_kills_mob_increments_stats(player, test_zone, punch_move):
|
||||
"""Player kills mob -> kills incremented, mob_kills tracked."""
|
||||
# Create a goblin mob
|
||||
goblin = Mob(name="goblin", x=0, y=0)
|
||||
goblin.location = test_zone
|
||||
test_zone._contents.append(goblin)
|
||||
|
||||
# Start encounter and make target unconscious
|
||||
start_encounter(player, goblin)
|
||||
player.mode_stack.append("combat")
|
||||
goblin.pl = 0.0
|
||||
await cmd_snap_neck(player, "goblin")
|
||||
# Start encounter
|
||||
encounter = start_encounter(player, goblin)
|
||||
|
||||
# Execute attack
|
||||
encounter.attack(punch_move)
|
||||
|
||||
# Advance past telegraph (0.3s) + window (0.8s)
|
||||
encounter.tick(time.monotonic() + 0.31) # -> WINDOW
|
||||
encounter.tick(time.monotonic() + 1.2) # -> RESOLVE
|
||||
|
||||
# Set defender to very low pl so damage kills them
|
||||
goblin.pl = 1.0
|
||||
|
||||
# Process combat (this will resolve and end encounter)
|
||||
await process_combat()
|
||||
|
||||
# Verify stats
|
||||
assert player.kills == 1
|
||||
|
|
@ -30,32 +59,50 @@ async def test_player_finishes_mob_increments_stats(player, test_zone):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_player_finished_by_mob_increments_deaths(player, nearby_player):
|
||||
"""Snap-neck finisher from opponent increments deaths."""
|
||||
start_encounter(nearby_player, player)
|
||||
nearby_player.mode_stack.append("combat")
|
||||
player.mode_stack.append("combat")
|
||||
player.pl = 0.0
|
||||
await cmd_snap_neck(nearby_player, "Goku")
|
||||
async def test_player_killed_by_mob_increments_deaths(player, test_zone, punch_move):
|
||||
"""Player killed by mob -> deaths incremented."""
|
||||
# Create a goblin mob
|
||||
goblin = Mob(name="goblin", x=0, y=0)
|
||||
goblin.location = test_zone
|
||||
test_zone._contents.append(goblin)
|
||||
|
||||
# Start encounter with mob as attacker
|
||||
encounter = start_encounter(goblin, player)
|
||||
|
||||
# Execute attack
|
||||
encounter.attack(punch_move)
|
||||
|
||||
# Advance to RESOLVE
|
||||
encounter.tick(time.monotonic() + 0.31)
|
||||
encounter.tick(time.monotonic() + 1.2)
|
||||
|
||||
# Set player to low pl so they die
|
||||
player.pl = 1.0
|
||||
|
||||
# Process combat
|
||||
await process_combat()
|
||||
|
||||
# Verify deaths incremented
|
||||
assert player.deaths == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_finisher_kills_accumulate(player, test_zone):
|
||||
"""After 3 finishers, kill counters accumulate correctly."""
|
||||
async def test_multiple_kills_accumulate(player, test_zone, punch_move):
|
||||
"""After killing 3 goblins, player.kills == 3, player.mob_kills["goblin"] == 3."""
|
||||
for _ in range(3):
|
||||
# Create goblin
|
||||
goblin = Mob(name="goblin", x=0, y=0)
|
||||
goblin.location = test_zone
|
||||
test_zone._contents.append(goblin)
|
||||
|
||||
# Create encounter and finish
|
||||
start_encounter(player, goblin)
|
||||
player.mode_stack.append("combat")
|
||||
goblin.pl = 0.0
|
||||
await cmd_snap_neck(player, "goblin")
|
||||
# Create and resolve encounter
|
||||
encounter = start_encounter(player, goblin)
|
||||
encounter.attack(punch_move)
|
||||
encounter.tick(time.monotonic() + 0.31)
|
||||
encounter.tick(time.monotonic() + 1.2)
|
||||
goblin.pl = 1.0
|
||||
|
||||
await process_combat()
|
||||
|
||||
# Verify accumulated kills
|
||||
assert player.kills == 3
|
||||
|
|
|
|||
|
|
@ -26,6 +26,19 @@ def _reset_globals():
|
|||
mudlib.weather._current_weather = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_zone():
|
||||
"""Create a test zone with simple terrain."""
|
||||
|
|
@ -85,7 +98,7 @@ async def test_look_includes_exits_line(player):
|
|||
"""Look output should include 'Exits:' line."""
|
||||
await cmd_look(player, "")
|
||||
output = get_output(player)
|
||||
assert "Exits: north south east west up" in output
|
||||
assert "Exits: north south east west" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -156,36 +169,14 @@ async def test_look_shows_portals(player, test_zone):
|
|||
@pytest.mark.asyncio
|
||||
async def test_look_with_args_routes_to_examine(player, test_zone):
|
||||
"""look <thing> should route to examine command logic."""
|
||||
called = {}
|
||||
|
||||
async def fake_examine(p, args, *, prefer_inventory=True):
|
||||
called["args"] = args
|
||||
called["prefer_inventory"] = prefer_inventory
|
||||
await p.send("examined\r\n")
|
||||
|
||||
import mudlib.commands.examine
|
||||
|
||||
original = mudlib.commands.examine.examine_target
|
||||
mudlib.commands.examine.examine_target = fake_examine # type: ignore[invalid-assignment]
|
||||
try:
|
||||
await cmd_look(player, "sword")
|
||||
finally:
|
||||
mudlib.commands.examine.examine_target = original
|
||||
# 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)
|
||||
|
||||
await cmd_look(player, "sword")
|
||||
output = get_output(player)
|
||||
assert called["args"] == "sword"
|
||||
assert called["prefer_inventory"] is False
|
||||
assert "examined" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_look_flying_shows_down_exit(player):
|
||||
"""Flying players should see down as a vertical exit."""
|
||||
player.flying = True
|
||||
|
||||
await cmd_look(player, "")
|
||||
output = get_output(player)
|
||||
assert "Exits: north south east west down" in output
|
||||
# Should see the item's description (examine behavior)
|
||||
assert "A sharp blade." in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -50,6 +51,19 @@ def test_zone():
|
|||
return zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer, test_zone):
|
||||
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||
|
|
@ -118,7 +132,7 @@ class TestMobAttackAI:
|
|||
await process_mobs(moves)
|
||||
|
||||
# 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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -272,7 +286,7 @@ class TestMobDefenseAI:
|
|||
|
||||
# Player attacks, putting encounter in TELEGRAPH
|
||||
encounter.attack(punch_right)
|
||||
assert encounter.state == CombatState.PENDING
|
||||
assert encounter.state == CombatState.TELEGRAPH
|
||||
|
||||
await process_mobs(moves)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for mob AI integration with behavior states."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -42,6 +43,19 @@ def test_zone():
|
|||
return zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer, test_zone):
|
||||
p = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Tests for mob templates, registry, spawn/despawn, and combat integration."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -182,6 +182,19 @@ class TestGetNearbyMob:
|
|||
# --- Phase 2: target resolution tests ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer, test_zone):
|
||||
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||
|
|
@ -375,8 +388,8 @@ class TestMobDefeat:
|
|||
return spawn_mob(template, 0, 0, test_zone)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_not_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
|
||||
"""KO does not despawn mob without an explicit finisher."""
|
||||
async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
|
||||
"""Mob with PL <= 0 gets despawned after combat resolves."""
|
||||
from mudlib.combat.engine import process_combat, start_encounter
|
||||
|
||||
encounter = start_encounter(player, goblin_mob)
|
||||
|
|
@ -391,14 +404,12 @@ class TestMobDefeat:
|
|||
|
||||
await process_combat()
|
||||
|
||||
assert goblin_mob in mobs
|
||||
assert goblin_mob.alive is True
|
||||
assert goblin_mob not in mobs
|
||||
assert goblin_mob.alive is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_player_gets_no_victory_message_on_ko(
|
||||
self, player, goblin_mob, punch_right
|
||||
):
|
||||
"""KO should not be treated as a defeat/kill message."""
|
||||
async def test_player_gets_victory_message(self, player, goblin_mob, punch_right):
|
||||
"""Player receives a victory message when mob is defeated."""
|
||||
from mudlib.combat.engine import process_combat, start_encounter
|
||||
|
||||
encounter = start_encounter(player, goblin_mob)
|
||||
|
|
@ -411,30 +422,29 @@ class TestMobDefeat:
|
|||
await process_combat()
|
||||
|
||||
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
|
||||
async def test_exhaustion_does_not_end_encounter(
|
||||
self, player, goblin_mob, punch_right
|
||||
):
|
||||
"""Attacker exhaustion does not auto-end combat."""
|
||||
async def test_mob_stamina_depleted_despawns(self, player, goblin_mob, punch_right):
|
||||
"""Mob is despawned when attacker stamina depleted (combat end)."""
|
||||
from mudlib.combat.engine import process_combat, start_encounter
|
||||
|
||||
encounter = start_encounter(player, goblin_mob)
|
||||
player.mode_stack.append("combat")
|
||||
|
||||
# Drain player stamina before resolve
|
||||
# Drain player stamina so combat ends on exhaustion
|
||||
player.stamina = 0.0
|
||||
encounter.attack(punch_right)
|
||||
encounter.state = CombatState.RESOLVE
|
||||
|
||||
await process_combat()
|
||||
|
||||
assert get_encounter(player) is encounter
|
||||
# Encounter should have ended
|
||||
assert get_encounter(player) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_player_ko_not_despawned(self, player, goblin_mob, punch_right):
|
||||
"""When player is KO'd, player remains present."""
|
||||
async def test_player_defeat_not_despawned(self, player, goblin_mob, punch_right):
|
||||
"""When player loses, player is not despawned."""
|
||||
from mudlib.combat.engine import process_combat, start_encounter
|
||||
|
||||
# Mob attacks player — mob is attacker, player is defender
|
||||
|
|
@ -447,8 +457,10 @@ class TestMobDefeat:
|
|||
|
||||
await process_combat()
|
||||
|
||||
# Player should get defeat message, not be despawned
|
||||
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||
assert len(messages) > 0
|
||||
assert not any("defeated" in msg.lower() for msg in messages)
|
||||
assert any(
|
||||
"defeated" in msg.lower() or "damage" in msg.lower() for msg in messages
|
||||
)
|
||||
# Player is still in players dict (not removed)
|
||||
assert player.name in players
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""End-to-end integration tests for NPC system (behavior + dialogue + schedule)."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.commands.talk import cmd_reply, cmd_talk, dialogue_trees
|
||||
|
|
@ -42,6 +44,19 @@ def test_zone():
|
|||
return zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer, test_zone):
|
||||
p = Player(name="Hero", x=10, y=10, reader=mock_reader, writer=mock_writer)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""Tests for open and close commands."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.commands import _registry
|
||||
|
|
@ -9,6 +11,19 @@ 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)]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -24,6 +25,21 @@ def temp_db():
|
|||
os.unlink(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
writer.close = MagicMock()
|
||||
writer.is_closing = MagicMock(return_value=False)
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quit_saves_player_state(temp_db, mock_reader, mock_writer):
|
||||
"""Quit command saves player state before disconnecting."""
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
"""Tests for the play command."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_zone():
|
||||
"""Create a test zone for spatial queries."""
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
"""Tests for player description and home_zone fields."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.player import Player
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
def test_player_default_description(mock_reader, mock_writer):
|
||||
"""Test that Player has default empty description."""
|
||||
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||
|
|
|
|||
|
|
@ -1,77 +1,49 @@
|
|||
"""Tests for the Portal class."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.object import Object
|
||||
from mudlib.player import Player
|
||||
from mudlib.portal import Portal
|
||||
from mudlib.thing import Thing
|
||||
from mudlib.zone import Zone
|
||||
from mudlib.zones import register_zone, zone_registry
|
||||
|
||||
# --- fixtures ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_zone():
|
||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||
return Zone(
|
||||
name="testzone",
|
||||
width=10,
|
||||
height=10,
|
||||
toroidal=True,
|
||||
terrain=terrain,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def zone_a():
|
||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||
return Zone(
|
||||
name="zone_a",
|
||||
width=10,
|
||||
height=10,
|
||||
toroidal=True,
|
||||
terrain=terrain,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def zone_b():
|
||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||
return Zone(
|
||||
name="zone_b",
|
||||
width=10,
|
||||
height=10,
|
||||
toroidal=True,
|
||||
terrain=terrain,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer, test_zone):
|
||||
p = Player(
|
||||
name="TestPlayer",
|
||||
x=5,
|
||||
y=5,
|
||||
reader=mock_reader,
|
||||
writer=mock_writer,
|
||||
location=test_zone,
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_zones():
|
||||
"""Clear zone registry before and after each test."""
|
||||
zone_registry.clear()
|
||||
yield
|
||||
zone_registry.clear()
|
||||
|
||||
|
||||
# --- construction ---
|
||||
|
||||
|
||||
def test_portal_creation_minimal():
|
||||
"""Portal can be created with just a name."""
|
||||
p = Portal(name="portal")
|
||||
assert p.name == "portal"
|
||||
assert p.location is None
|
||||
assert p.target_zone == ""
|
||||
assert p.target_x == 0
|
||||
assert p.target_y == 0
|
||||
|
||||
|
||||
def test_portal_creation_with_target():
|
||||
"""Portal can be created with target zone and coordinates."""
|
||||
p = Portal(name="gateway", target_zone="dungeon", target_x=5, target_y=10)
|
||||
assert p.target_zone == "dungeon"
|
||||
assert p.target_x == 5
|
||||
assert p.target_y == 10
|
||||
|
||||
|
||||
def test_portal_is_thing_subclass():
|
||||
"""Portal inherits from Thing."""
|
||||
p = Portal(name="portal")
|
||||
assert isinstance(p, Thing)
|
||||
|
||||
|
||||
def test_portal_is_object_subclass():
|
||||
"""Portal inherits from Object (via Thing)."""
|
||||
p = Portal(name="portal")
|
||||
assert isinstance(p, Object)
|
||||
|
||||
|
||||
def test_portal_always_non_portable():
|
||||
"""Portal is always non-portable (cannot be picked up)."""
|
||||
p = Portal(name="portal")
|
||||
assert p.portable is False
|
||||
|
||||
|
||||
def test_portal_forced_non_portable():
|
||||
"""Portal forces portable=False even if explicitly set True."""
|
||||
# Even if we try to make it portable, it should be forced to False
|
||||
|
|
@ -79,6 +51,18 @@ def test_portal_forced_non_portable():
|
|||
assert p.portable is False
|
||||
|
||||
|
||||
def test_portal_inherits_description():
|
||||
"""Portal can have a description (from Thing)."""
|
||||
p = Portal(name="gateway", description="a shimmering portal")
|
||||
assert p.description == "a shimmering portal"
|
||||
|
||||
|
||||
def test_portal_inherits_aliases():
|
||||
"""Portal can have aliases (from Thing)."""
|
||||
p = Portal(name="gateway", aliases=["portal", "gate"])
|
||||
assert p.aliases == ["portal", "gate"]
|
||||
|
||||
|
||||
def test_portal_in_zone():
|
||||
"""Portal can exist in a zone with coordinates."""
|
||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||
|
|
@ -110,140 +94,3 @@ def test_portal_rejects_things():
|
|||
p = Portal(name="portal")
|
||||
thing = Thing(name="sword")
|
||||
assert p.can_accept(thing) is False
|
||||
|
||||
|
||||
# --- portal display ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_look_shows_portal_at_position(player, test_zone, mock_writer):
|
||||
"""look command shows portals at player position."""
|
||||
from mudlib.commands.look import cmd_look
|
||||
|
||||
Portal(
|
||||
name="shimmering doorway",
|
||||
location=test_zone,
|
||||
x=5,
|
||||
y=5,
|
||||
target_zone="elsewhere",
|
||||
target_x=0,
|
||||
target_y=0,
|
||||
)
|
||||
|
||||
await cmd_look(player, "")
|
||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||
# New format: "You see {portal.name}."
|
||||
assert "you see shimmering doorway." in output.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_look_shows_multiple_portals(player, test_zone, mock_writer):
|
||||
"""look command shows multiple portals at player position."""
|
||||
from mudlib.commands.look import cmd_look
|
||||
|
||||
Portal(
|
||||
name="red portal",
|
||||
location=test_zone,
|
||||
x=5,
|
||||
y=5,
|
||||
target_zone="redzone",
|
||||
target_x=0,
|
||||
target_y=0,
|
||||
)
|
||||
Portal(
|
||||
name="blue portal",
|
||||
location=test_zone,
|
||||
x=5,
|
||||
y=5,
|
||||
target_zone="bluezone",
|
||||
target_x=0,
|
||||
target_y=0,
|
||||
)
|
||||
|
||||
await cmd_look(player, "")
|
||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||
assert "red portal" in output.lower()
|
||||
assert "blue portal" in output.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_look_no_portals_at_position(player, test_zone, mock_writer):
|
||||
"""look command doesn't show portals when none at position."""
|
||||
from mudlib.commands.look import cmd_look
|
||||
|
||||
Portal(
|
||||
name="distant portal",
|
||||
location=test_zone,
|
||||
x=8,
|
||||
y=8,
|
||||
target_zone="elsewhere",
|
||||
target_x=0,
|
||||
target_y=0,
|
||||
)
|
||||
|
||||
await cmd_look(player, "")
|
||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||
# Should not mention portals when none are at player position
|
||||
assert "portal" not in output.lower() or "distant portal" not in output.lower()
|
||||
|
||||
|
||||
# --- two-way portal transitions ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_way_portal_transitions(mock_reader, mock_writer, zone_a, zone_b):
|
||||
"""Portals work bidirectionally between zones."""
|
||||
from mudlib.commands.portals import cmd_enter
|
||||
|
||||
# Create player in zone_a at (2, 2)
|
||||
player = Player(
|
||||
name="TestPlayer",
|
||||
x=2,
|
||||
y=2,
|
||||
reader=mock_reader,
|
||||
writer=mock_writer,
|
||||
location=zone_a,
|
||||
)
|
||||
|
||||
# Register zones
|
||||
register_zone("zone_a", zone_a)
|
||||
register_zone("zone_b", zone_b)
|
||||
|
||||
# Create portal in zone A pointing to zone B
|
||||
Portal(
|
||||
name="doorway to B",
|
||||
location=zone_a,
|
||||
x=2,
|
||||
y=2,
|
||||
target_zone="zone_b",
|
||||
target_x=7,
|
||||
target_y=7,
|
||||
)
|
||||
|
||||
# Create portal in zone B pointing to zone A
|
||||
Portal(
|
||||
name="doorway to A",
|
||||
location=zone_b,
|
||||
x=7,
|
||||
y=7,
|
||||
target_zone="zone_a",
|
||||
target_x=2,
|
||||
target_y=2,
|
||||
)
|
||||
|
||||
# Player starts in zone A at (2, 2)
|
||||
assert player.location is zone_a
|
||||
assert player.x == 2
|
||||
assert player.y == 2
|
||||
|
||||
# Enter portal to zone B
|
||||
await cmd_enter(player, "doorway to B")
|
||||
assert player.location is zone_b
|
||||
assert player.x == 7
|
||||
assert player.y == 7
|
||||
|
||||
# Enter portal back to zone A
|
||||
await cmd_enter(player, "doorway to A")
|
||||
assert player.location is zone_a
|
||||
assert player.x == 2
|
||||
assert player.y == 2
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""Tests for auto-triggering portals on movement."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.player import Player
|
||||
|
|
@ -8,6 +10,19 @@ from mudlib.zone import Zone
|
|||
from mudlib.zones import register_zone, zone_registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_zone():
|
||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||
|
|
|
|||
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."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib import commands
|
||||
from mudlib.commands import CommandDefinition
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer):
|
||||
from mudlib.player import Player
|
||||
|
|
|
|||
|
|
@ -399,7 +399,7 @@ def test_move_shows_name_when_in_combat_with_active_move():
|
|||
name="punch right",
|
||||
move_type="attack",
|
||||
stamina_cost=5.0,
|
||||
hit_time_ms=800,
|
||||
timing_window_ms=800,
|
||||
damage_pct=0.15,
|
||||
countered_by=["dodge left"],
|
||||
)
|
||||
|
|
@ -463,7 +463,7 @@ def test_combat_state_shows_state_when_in_combat():
|
|||
name="punch right",
|
||||
move_type="attack",
|
||||
stamina_cost=5.0,
|
||||
hit_time_ms=800,
|
||||
timing_window_ms=800,
|
||||
damage_pct=0.15,
|
||||
countered_by=["dodge left"],
|
||||
)
|
||||
|
|
@ -473,7 +473,7 @@ def test_combat_state_shows_state_when_in_combat():
|
|||
active_encounters.append(encounter)
|
||||
|
||||
result = render_prompt(player)
|
||||
assert result == "[pending] > "
|
||||
assert result == "[telegraph] > "
|
||||
|
||||
|
||||
def test_terrain_variable_grass():
|
||||
|
|
|
|||
|
|
@ -1,11 +1,26 @@
|
|||
"""Tests for the prompt command."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.commands.prompt import cmd_prompt
|
||||
from mudlib.prompt import render_prompt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer):
|
||||
from mudlib.player import Player
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""Tests for put and take-from commands."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.commands import _registry
|
||||
|
|
@ -9,6 +11,19 @@ from mudlib.thing import Thing
|
|||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_zone():
|
||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue