Compare commits

..

19 commits

Author SHA1 Message Date
be63a1cbde
Extract shared test fixtures to conftest.py
Moved common test fixtures (mock_writer, mock_reader, test_zone, player,
nearby_player, clear_state) from individual test files into a shared
conftest.py. This eliminates duplication across test_power.py, test_sleep.py,
test_combat_zaxis.py, test_quit.py, test_stamina_cues.py, and
test_stamina_cue_wiring.py.

Some test files override specific fixtures where they need custom behavior
(e.g., test_quit.py adds a close method to mock_writer, stamina tests use
smaller zones and custom player positions).
2026-02-14 01:00:37 -05:00
8bb87965d7
Wire stamina cues into combat and power loops 2026-02-14 01:00:37 -05:00
894a0b7396
Fix flying dodge template grammar 2026-02-14 01:00:37 -05:00
1dbc3a1c68
Replace magic number with DEATH_PL constant 2026-02-14 01:00:37 -05:00
8d31eeafaf
Extract power-up validation helper 2026-02-14 01:00:37 -05:00
5629f052a4
Add snap neck finisher command
Allows instant kill of unconscious opponents. Only works in combat on targets with unconscious posture. Ends encounter, handles mob despawn, sends dramatic messages to both parties.
2026-02-14 01:00:37 -05:00
b4dea1d349
Add unconscious state with automatic recovery
Players become unconscious when PL or stamina drops to 0. While unconscious, both stats slowly recover at 0.1 per tick (1.0 per second). When both reach above 0, player regains consciousness with a message. Recovery runs in the main game loop via process_unconscious.
2026-02-14 01:00:37 -05:00
d6d62abdb8
Add flying dodge mechanic at resolve time
When altitude differs at resolve time (attacker or defender changed flying state during window phase), attack misses. Treated as successful dodge with zero damage.
2026-02-14 01:00:37 -05:00
4da8d41b45
Add z-axis altitude check for starting combat
Players must be at same altitude (both flying or both grounded) to initiate combat. Attack fails with 'You can't reach them from here!' if altitude differs.
2026-02-14 01:00:37 -05:00
d8cd880b61
Add sleep command for deep rest recovery 2026-02-14 01:00:37 -05:00
36fcbecc12
Block quit command during combat 2026-02-14 01:00:37 -05:00
a4c9f31056
Cancel power-up tasks when combat starts
When combat begins, any active power-up task on either the attacker
or defender should be cancelled to prevent background power changes
during combat. This ensures players can't continue charging while
fighting.

The fix checks both entities for a _power_task attribute and cancels
it if present, then clears the reference.
2026-02-14 01:00:37 -05:00
2a546a3171
Fix operator precedence in POV smart conjugation
The {s} conjugation check had incorrect operator precedence that
evaluated the ch/sh suffix check independently of the prev_text
existence check. This could lead to confusing logic flow even
though it didn't crash due to len() handling empty strings safely.

Fixed by wrapping both suffix conditions in parentheses so they're
both guarded by the prev_text truthiness check.
2026-02-14 01:00:37 -05:00
afe99ceff5
Wire combat move and state into prompt variables 2026-02-13 23:21:53 -05:00
47534b1514
Add visible stamina cue broadcasts 2026-02-13 23:21:53 -05:00
4e8459df5f
Convert combat resolution to POV templates 2026-02-13 23:21:52 -05:00
15cc0d1ae0
Add announce and resolve templates to combat moves 2026-02-13 23:21:50 -05:00
2b21257d26
Add POV template engine for combat messages 2026-02-13 23:05:19 -05:00
292557e5fd
Add power up/down commands
Implements power level management system with tick-based power-up loop.
Players can raise PL toward max_pl (costs stamina per tick), lower PL
instantly, set exact PL targets, and cancel ongoing power-ups.
2026-02-13 23:01:33 -05:00
39 changed files with 3042 additions and 67 deletions

View file

@ -6,9 +6,15 @@ timing_window_ms = 1800
damage_pct = 0.15
[variants.left]
telegraph = "{attacker} winds up a left hook!"
telegraph = "{attacker} retracts {his} left arm..."
announce = "{attacker} throw{s} a left hook at {defender}!"
resolve_hit = "{attacker} connect{s} with a left hook!"
resolve_miss = "{defender} dodge{s} {attacker}'s left hook!"
countered_by = ["dodge right", "parry high"]
[variants.right]
telegraph = "{attacker} winds up a right hook!"
telegraph = "{attacker} retracts {his} right arm..."
announce = "{attacker} throw{s} a right hook at {defender}!"
resolve_hit = "{attacker} connect{s} with a right hook!"
resolve_miss = "{defender} dodge{s} {attacker}'s right hook!"
countered_by = ["dodge left", "parry high"]

View file

@ -2,7 +2,10 @@ name = "roundhouse"
description = "a spinning kick that sacrifices speed for devastating power"
move_type = "attack"
stamina_cost = 8.0
telegraph = "{attacker} spins into a roundhouse kick!"
telegraph = "{attacker} shifts {his} weight back..."
announce = "{attacker} launch{es} a roundhouse kick at {defender}!"
resolve_hit = "{attacker}'s roundhouse slams into {defender}!"
resolve_miss = "{defender} counter{s} {attacker}'s roundhouse!"
timing_window_ms = 2000
damage_pct = 0.25
countered_by = ["duck", "parry high", "parry low"]

View file

@ -2,7 +2,10 @@ name = "sweep"
description = "a low kick targeting the legs, designed to take an opponent off balance"
move_type = "attack"
stamina_cost = 6.0
telegraph = "{attacker} drops low for a leg sweep!"
telegraph = "{attacker} drops low..."
announce = "{attacker} sweep{s} at {defender}'s legs!"
resolve_hit = "{attacker}'s sweep catches {defender}'s legs!"
resolve_miss = "{defender} jump{s} over {attacker}'s sweep!"
timing_window_ms = 1800
damage_pct = 0.18
countered_by = ["jump", "parry low"]

View file

@ -0,0 +1,5 @@
name = "sleep"
help = "fall asleep for fastest stamina recovery (3x rest rate)"
mode = "normal"
handler = "mudlib.commands.sleep:cmd_sleep"
aliases = ["wake"]

View file

@ -7,8 +7,11 @@ from pathlib import Path
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter, start_encounter
from mudlib.combat.moves import CombatMove, load_moves
from mudlib.combat.stamina import check_stamina_cues
from mudlib.commands import CommandDefinition, register
from mudlib.player import Player, players
from mudlib.render.colors import colorize
from mudlib.render.pov import render_pov
# Combat moves will be injected after loading
combat_moves: dict[str, CombatMove] = {}
@ -50,6 +53,11 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
await player.send("You need a target to start combat.\r\n")
return
# Check altitude match before starting combat
if getattr(player, "flying", False) != getattr(target, "flying", False):
await player.send("You can't reach them from here!\r\n")
return
# Start new encounter
try:
encounter = start_encounter(player, target)
@ -58,7 +66,13 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
# Send telegraph to defender if they can receive messages
if hasattr(target, "send") and move.telegraph:
telegraph = move.telegraph.format(attacker=player.name)
telegraph = render_pov(move.telegraph, target, player, target)
telegraph_color = move.telegraph_color
if telegraph_color:
color_depth = getattr(target, "color_depth", None)
telegraph = colorize(
f"{{{telegraph_color}}}{telegraph}{{/}}", color_depth
)
await target.send(f"{telegraph}\r\n")
except ValueError as e:
@ -71,7 +85,13 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
else:
defender = encounter.attacker
if hasattr(defender, "send") and move.telegraph:
telegraph = move.telegraph.format(attacker=player.name)
telegraph = render_pov(move.telegraph, defender, player, defender)
telegraph_color = move.telegraph_color
if telegraph_color:
color_depth = getattr(defender, "color_depth", None)
telegraph = colorize(
f"{{{telegraph_color}}}{telegraph}{{/}}", color_depth
)
await defender.send(f"{telegraph}\r\n")
# Detect switch before attack() modifies state
@ -88,6 +108,9 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
send_char_vitals(player)
# Check stamina cues after attack cost
await check_stamina_cues(player)
if switching:
await player.send(f"You switch to {move.name}!\r\n")
else:
@ -117,6 +140,9 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
send_char_vitals(player)
# Check stamina cues after defense cost
await check_stamina_cues(player)
# If in combat, queue the defense on the encounter
encounter = get_encounter(player)
if encounter is not None:

View file

@ -28,8 +28,7 @@ IDLE_TIMEOUT = 30.0
class ResolveResult:
"""Result of resolving a combat exchange."""
attacker_msg: str
defender_msg: str
resolve_template: str # POV template for resolve message
damage: float
countered: bool
combat_ended: bool
@ -114,20 +113,32 @@ class CombatEncounter:
"""Resolve the combat exchange and return result.
Returns:
ResolveResult with messages for both participants
ResolveResult with template for both participants
"""
if self.current_move is None:
return ResolveResult(
attacker_msg="No active move to resolve.",
defender_msg="No active move to resolve.",
resolve_template="No active move to resolve.",
damage=0.0,
countered=False,
combat_ended=False,
)
move_name = self.current_move.name
attacker_name = self.attacker.name
defender_name = self.defender.name
# Check for altitude mismatch (flying dodge)
attacker_flying = getattr(self.attacker, "flying", False)
defender_flying = getattr(self.defender, "flying", False)
if attacker_flying != defender_flying:
# Altitude mismatch - attack misses
template = "{attacker} miss{es}{defender} {are|is} out of reach!"
# Reset to IDLE
self.state = CombatState.IDLE
self.current_move = None
self.pending_defense = None
return ResolveResult(
resolve_template=template,
damage=0.0,
countered=True,
combat_ended=False,
)
# Check if defense counters attack
defense_succeeds = (
@ -137,29 +148,30 @@ class CombatEncounter:
if defense_succeeds:
# Successful counter - no damage
damage = 0.0
attacker_msg = f"{defender_name} countered your {move_name}!"
defender_msg = f"You countered {attacker_name}'s {move_name}!"
template = (
self.current_move.resolve_miss
if self.current_move.resolve_miss
else "{defender} counter{s} {attacker}'s attack!"
)
countered = True
elif self.pending_defense:
# Wrong defense - normal damage
damage = self.attacker.pl * self.current_move.damage_pct
self.defender.pl -= damage
attacker_msg = (
f"Your {move_name} hits {defender_name} for {damage:.1f} damage!"
)
defender_msg = (
f"{attacker_name}'s {move_name} hits you for {damage:.1f} damage!"
template = (
self.current_move.resolve_hit
if self.current_move.resolve_hit
else "{attacker}'s attack hit{s} {defender} for damage!"
)
countered = False
else:
# No defense - increased damage
damage = self.attacker.pl * self.current_move.damage_pct * 1.5
self.defender.pl -= damage
attacker_msg = (
f"Your {move_name} slams {defender_name} for {damage:.1f} damage!"
)
defender_msg = (
f"{attacker_name}'s {move_name} slams you for {damage:.1f} damage!"
template = (
self.current_move.resolve_hit
if self.current_move.resolve_hit
else "{attacker}'s attack hit{s} {defender} for damage!"
)
countered = False
@ -172,8 +184,7 @@ class CombatEncounter:
self.pending_defense = None
return ResolveResult(
attacker_msg=attacker_msg,
defender_msg=defender_msg,
resolve_template=template,
damage=damage,
countered=countered,
combat_ended=combat_ended,

View file

@ -3,8 +3,11 @@
import time
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
from mudlib.combat.stamina import check_stamina_cues
from mudlib.entity import Entity, Mob
from mudlib.gmcp import send_char_status, send_char_vitals
from mudlib.render.colors import colorize
from mudlib.render.pov import render_pov
# Global list of active combat encounters
active_encounters: list[CombatEncounter] = []
@ -40,6 +43,14 @@ def start_encounter(attacker: Entity, defender: Entity) -> CombatEncounter:
)
active_encounters.append(encounter)
# Cancel any active power-up tasks
for entity in (attacker, defender):
task = getattr(entity, "_power_task", None)
if task is not None:
task.cancel()
if hasattr(entity, "_power_task"):
entity._power_task = None # type: ignore[attr-defined]
return encounter
@ -91,16 +102,51 @@ async def process_combat() -> None:
end_encounter(encounter)
continue
# Save previous state to detect transitions
previous_state = encounter.state
# Tick the state machine
encounter.tick(now)
# Send announce message on TELEGRAPH → WINDOW transition
if (
previous_state == CombatState.TELEGRAPH
and encounter.state == CombatState.WINDOW
and encounter.current_move
and encounter.current_move.announce
):
template = encounter.current_move.announce
announce_color = encounter.current_move.announce_color
for viewer in (encounter.attacker, encounter.defender):
msg = render_pov(
template, viewer, encounter.attacker, encounter.defender
)
if announce_color:
color_depth = getattr(viewer, "color_depth", None)
msg = colorize(f"{{{announce_color}}}{msg}{{/}}", color_depth)
await viewer.send(msg + "\r\n")
# Auto-resolve if in RESOLVE state
if encounter.state == CombatState.RESOLVE:
# Save current_move before resolve() clears it
current_move = encounter.current_move
result = encounter.resolve()
# Send resolution messages to both participants
await encounter.attacker.send(result.attacker_msg + "\r\n")
await encounter.defender.send(result.defender_msg + "\r\n")
# Send resolution messages to both participants using POV
resolve_color = current_move.resolve_color if current_move else "bold"
for viewer in (encounter.attacker, encounter.defender):
msg = render_pov(
result.resolve_template,
viewer,
encounter.attacker,
encounter.defender,
)
if resolve_color:
color_depth = getattr(viewer, "color_depth", None)
msg = colorize(f"{{{resolve_color}}}{msg}{{/}}", color_depth)
await viewer.send(msg + "\r\n")
# Send vitals update after damage resolution
from mudlib.player import Player
@ -109,6 +155,10 @@ async def process_combat() -> None:
if isinstance(entity, Player):
send_char_vitals(entity)
# 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:

View file

@ -30,6 +30,13 @@ class CombatMove:
# variant key ("left", "right", "" for simple moves)
variant: str = ""
description: str = ""
# Three-beat output templates
announce: str = "" # POV template for announce beat
resolve_hit: str = "" # POV template for hit (wrong/no defense)
resolve_miss: str = "" # POV template for successful counter
telegraph_color: str = "dim" # color tag for telegraph
announce_color: str = "" # color tag for announce (default/none)
resolve_color: str = "bold" # color tag for resolve
def load_move(path: Path) -> list[CombatMove]:
@ -85,6 +92,22 @@ def load_move(path: Path) -> list[CombatMove]:
command=base_name,
variant=variant_key,
description=data.get("description", ""),
announce=variant_data.get("announce", data.get("announce", "")),
resolve_hit=variant_data.get(
"resolve_hit", data.get("resolve_hit", "")
),
resolve_miss=variant_data.get(
"resolve_miss", data.get("resolve_miss", "")
),
telegraph_color=variant_data.get(
"telegraph_color", data.get("telegraph_color", "dim")
),
announce_color=variant_data.get(
"announce_color", data.get("announce_color", "")
),
resolve_color=variant_data.get(
"resolve_color", data.get("resolve_color", "bold")
),
)
)
return moves
@ -104,6 +127,12 @@ def load_move(path: Path) -> list[CombatMove]:
command=base_name,
variant="",
description=data.get("description", ""),
announce=data.get("announce", ""),
resolve_hit=data.get("resolve_hit", ""),
resolve_miss=data.get("resolve_miss", ""),
telegraph_color=data.get("telegraph_color", "dim"),
announce_color=data.get("announce_color", ""),
resolve_color=data.get("resolve_color", "bold"),
)
]

View file

@ -0,0 +1,73 @@
"""Stamina cue broadcasts for combat strain visibility."""
from mudlib.commands.movement import send_nearby_message
from mudlib.entity import Entity
async def check_stamina_cues(entity: Entity) -> None:
"""Broadcast stamina strain messages when thresholds are crossed.
Thresholds (escalating severity):
- Below 75%: breathing heavily
- Below 50%: drenched in sweat
- Below 25%: visibly shaking from exhaustion
- Below 10%: can barely stand
Each threshold triggers only ONCE per descent. Track the last triggered
threshold to prevent spam.
Args:
entity: The entity to check stamina for
"""
if entity.max_stamina == 0:
return
stamina_pct = entity.stamina / entity.max_stamina
# Define thresholds from lowest to highest (check in reverse order)
thresholds = [
(
0.10,
"You can barely stand.",
f"{entity.name} can barely stand.",
),
(
0.25,
"You're visibly shaking from exhaustion.",
f"{entity.name} is visibly shaking from exhaustion.",
),
(
0.50,
"You're drenched in sweat.",
f"{entity.name} is drenched in sweat.",
),
(
0.75,
"You're breathing heavily.",
f"{entity.name} is breathing heavily.",
),
]
# Find the current threshold (highest threshold we're below)
current_threshold = None
self_msg = None
nearby_msg = None
for threshold, self_text, nearby_text in thresholds:
if stamina_pct < threshold:
current_threshold = threshold
self_msg = self_text
nearby_msg = nearby_text
break
# If we found a threshold and it's lower than the last triggered one
if current_threshold is not None and current_threshold < entity._last_stamina_cue:
# Send self-directed message
await entity.send(f"{self_msg}\r\n")
# Broadcast to nearby players (only if entity has a location)
if entity.location is not None:
await send_nearby_message(entity, entity.x, entity.y, f"{nearby_msg}\r\n")
# Update tracking
entity._last_stamina_cue = current_threshold

View file

@ -134,6 +134,8 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
async def send_nearby_message(entity: Entity, x: int, y: int, message: str) -> None:
"""Send a message to all players near a location, excluding the entity.
Sleeping players do not receive nearby messages (they are blind to room events).
Args:
entity: The entity who triggered the message (excluded from receiving it)
x: X coordinate of the location
@ -147,6 +149,9 @@ async def send_nearby_message(entity: Entity, x: int, y: int, message: str) -> N
assert isinstance(zone, Zone), "Entity must be in a zone to send nearby messages"
for obj in zone.contents_near(x, y, viewport_range):
if obj is not entity and isinstance(obj, Entity):
# Skip sleeping players (they are blind to room events)
if getattr(obj, "sleeping", False):
continue
await obj.send(message)

View file

@ -0,0 +1,212 @@
"""Power commands for raising and lowering PL."""
import asyncio
from mudlib.combat.engine import get_encounter
from mudlib.combat.stamina import check_stamina_cues
from mudlib.commands import CommandDefinition, register
from mudlib.commands.movement import send_nearby_message
from mudlib.gmcp import send_char_vitals
from mudlib.player import Player
# Power-up mechanics
POWER_UP_TICK_INTERVAL = 0.05 # seconds between ticks
POWER_UP_PL_PER_TICK = 5.0 # PL increase per tick
POWER_UP_STAMINA_COST_PER_TICK = 2.0 # stamina cost per tick
POWER_DOWN_MIN_PL = 1.0 # minimum PL when powering down
async def _check_can_power_up(player: Player) -> str | None:
"""Return error message if player can't power up, else None."""
# Check if already powering up
if player._power_task is not None and not player._power_task.done():
return "You're already powering up!\r\n"
# Check for combat
if get_encounter(player) is not None:
return "You can't power up during combat!\r\n"
# Check stamina
if player.stamina <= 0:
return "You don't have enough stamina to power up!\r\n"
return None
async def power_up_loop(player: Player, target_pl: float | None = None) -> None:
"""Background task that powers up the player over time.
Args:
player: The player powering up
target_pl: Optional target PL to stop at (defaults to max_pl)
"""
target = target_pl if target_pl is not None else player.max_pl
try:
while player.pl < target and player.stamina > 0:
await asyncio.sleep(POWER_UP_TICK_INTERVAL)
# Deduct stamina
player.stamina -= POWER_UP_STAMINA_COST_PER_TICK
if player.stamina < 0:
player.stamina = 0.0
# Increase PL
old_pl = player.pl
player.pl = min(player.pl + POWER_UP_PL_PER_TICK, target)
# Send GMCP update
send_char_vitals(player)
# Check stamina cues after deduction
await check_stamina_cues(player)
# Send periodic aura message (every ~0.2s)
if int(old_pl / 20) != int(player.pl / 20):
await player.send("Your aura flares!\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name}'s aura flares!\r\n"
)
# Check if we've run out of stamina
if player.stamina <= 0:
await player.send("You run out of stamina and stop powering up.\r\n")
await send_nearby_message(
player,
player.x,
player.y,
f"{player.name} stops powering up.\r\n",
)
break
# Reached target
if player.pl >= target:
await player.send(f"You reach your target power level of {target}.\r\n")
await send_nearby_message(
player,
player.x,
player.y,
f"{player.name} reaches their target power level.\r\n",
)
finally:
# Clean up the task reference
player._power_task = None
async def cmd_power(player: Player, args: str) -> None:
"""Manage power level.
Usage:
power up - raise PL toward max_pl
power down - lower PL to minimum
power <number> - set PL to exact value
power stop - cancel ongoing power-up
"""
if not args:
await player.send("Usage: power up|down|stop|<number>\r\n")
return
args = args.strip().lower()
# Handle power stop
if args == "stop":
if player._power_task is None or player._power_task.done():
await player.send("You're not powering up.\r\n")
return
player._power_task.cancel()
player._power_task = None
await player.send("You stop powering up.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} stops powering up.\r\n"
)
return
# Handle power down
if args == "down":
# Cancel any ongoing power-up
if player._power_task is not None and not player._power_task.done():
player._power_task.cancel()
player._power_task = None
player.pl = POWER_DOWN_MIN_PL
send_char_vitals(player)
await player.send(f"You power down to PL {POWER_DOWN_MIN_PL}.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} powers down.\r\n"
)
return
# Handle power up
if args == "up":
error = await _check_can_power_up(player)
if error:
await player.send(error)
return
# Check if already at max
if player.pl >= player.max_pl:
await player.send("You're already at maximum power!\r\n")
return
# Start power-up loop
await player.send("You begin powering up...\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} begins powering up!\r\n"
)
player._power_task = asyncio.create_task(power_up_loop(player))
return
# Handle power <number>
try:
target = float(args)
except ValueError:
await player.send(f"Invalid power level: {args}\r\n")
return
# Validate target
if target <= 0:
await player.send("Power level must be greater than 0.\r\n")
return
if target > player.max_pl:
await player.send(
f"Power level cannot exceed your maximum of {player.max_pl}.\r\n"
)
return
# If target is lower than current, instant change
if target < player.pl:
# Cancel any ongoing power-up
if player._power_task is not None and not player._power_task.done():
player._power_task.cancel()
player._power_task = None
player.pl = target
send_char_vitals(player)
await player.send(f"You lower your power to {target}.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} lowers their power.\r\n"
)
return
# If target is higher, start power-up loop to that target
if target > player.pl:
error = await _check_can_power_up(player)
if error:
await player.send(error)
return
# Start power-up loop to target
await player.send(f"You begin powering up to {target}...\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} begins powering up!\r\n"
)
player._power_task = asyncio.create_task(power_up_loop(player, target))
return
# Target equals current PL
await player.send(f"You're already at PL {target}.\r\n")
register(CommandDefinition("power", cmd_power, aliases=["pow"]))

View file

@ -12,6 +12,11 @@ async def cmd_quit(player: Player, args: str) -> None:
player: The player executing the command
args: Command arguments (unused)
"""
# Block quitting during combat
if player.mode == "combat":
await player.send("You can't quit during combat!\r\n")
return
# Save player state before disconnecting
save_player(player)

View file

@ -0,0 +1,43 @@
"""Sleep command for fastest stamina recovery."""
from mudlib.commands.movement import send_nearby_message
from mudlib.gmcp import send_char_status
from mudlib.player import Player
async def cmd_sleep(player: Player, args: str) -> None:
"""Toggle sleeping state for deep rest and fastest stamina recovery.
Cannot sleep if stamina is already full or if in combat.
While sleeping, players are blind to nearby events but recover stamina 3x faster.
Broadcasts to nearby players when falling asleep and waking up.
"""
# Check if in combat
if player.mode == "combat":
await player.send("You can't sleep in the middle of combat!\r\n")
return
# Check if stamina is already full
if player.stamina >= player.max_stamina:
await player.send("You're not tired.\r\n")
return
# Toggle sleeping state
if player.sleeping:
# Wake up
player.sleeping = False
player.resting = False
send_char_status(player)
await player.send("You wake up.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} wakes up.\r\n"
)
else:
# Fall asleep
player.sleeping = True
player.resting = True # sleeping implies resting
send_char_status(player)
await player.send("You fall asleep.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} falls asleep.\r\n"
)

View file

@ -0,0 +1,100 @@
"""Snap neck finisher command."""
from mudlib.combat.engine import end_encounter, get_encounter
from mudlib.commands import CommandDefinition, register
from mudlib.player import Player, players
DEATH_PL = -100.0
async def cmd_snap_neck(player: Player, args: str) -> None:
"""Snap the neck of an unconscious target.
Args:
player: The player executing the command
args: Target name
"""
# Get encounter
encounter = get_encounter(player)
if encounter is None:
await player.send("You're not in combat.\r\n")
return
# Parse target
target_name = args.strip()
if not target_name:
await player.send("Snap whose neck?\r\n")
return
# Find target
target = players.get(target_name)
if target is None and player.location is not None:
from mudlib.mobs import get_nearby_mob
from mudlib.zone import Zone
if isinstance(player.location, Zone):
target = get_nearby_mob(target_name, player.x, player.y, player.location)
if target is None:
await player.send(f"You don't see {target_name} here.\r\n")
return
# Verify target is in the encounter
if encounter.attacker is not player and encounter.defender is not player:
await player.send("You're not in combat with that target.\r\n")
return
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
# Check if target is unconscious
if target.posture != "unconscious":
await player.send(f"{target.name} is not unconscious.\r\n")
return
# Execute the finisher
target.pl = DEATH_PL
# Send dramatic messages
await player.send(f"You snap {target.name}'s neck!\r\n")
await target.send(f"{player.name} snaps your neck!\r\n")
# Send GMCP vitals update (only for Players)
from mudlib.entity import Mob
from mudlib.gmcp import send_char_vitals
if not isinstance(target, Mob):
send_char_vitals(target)
# Handle mob despawn
if isinstance(target, Mob):
from mudlib.mobs import despawn_mob
despawn_mob(target)
# 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":
player.mode_stack.pop()
send_char_status(player)
if isinstance(target, Player) and target.mode == "combat":
target.mode_stack.pop()
send_char_status(target)
# End the encounter
end_encounter(encounter)
# Register command
register(
CommandDefinition(
name="snapneck",
handler=cmd_snap_neck,
aliases=["sn"],
mode="*",
help="Snap the neck of an unconscious opponent (instant kill)",
)
)

View file

@ -25,12 +25,14 @@ class Entity(Object):
max_stamina: float = 100.0 # stamina ceiling
defense_locked_until: float = 0.0 # monotonic time when defense recovery ends
resting: bool = False # whether this entity is currently resting
sleeping: bool = False # whether this entity is currently sleeping (deep rest)
_last_stamina_cue: float = 1.0 # Last stamina percentage that triggered a cue
@property
def posture(self) -> str:
"""Return entity's current posture for room display.
Priority order: unconscious > fighting > flying > resting > standing
Priority order: unconscious > fighting > flying > sleeping > resting > standing
"""
# Unconscious (highest priority)
if self.pl <= 0 or self.stamina <= 0:
@ -44,6 +46,10 @@ class Entity(Object):
if getattr(self, "flying", False):
return "flying"
# Sleeping (before resting since sleeping implies resting)
if self.sleeping:
return "sleeping"
# Resting
if self.resting:
return "resting"

View file

@ -7,6 +7,8 @@ from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter
from mudlib.combat.moves import CombatMove
from mudlib.mobs import mobs
from mudlib.render.colors import colorize
from mudlib.render.pov import render_pov
# Seconds between mob actions (gives player time to read and react)
MOB_ACTION_COOLDOWN = 1.0
@ -73,8 +75,15 @@ async def _try_attack(mob, encounter, combat_moves, now):
# Send telegraph to the player (the defender after role swap)
if move.telegraph:
telegraph_msg = move.telegraph.format(attacker=mob.name)
await encounter.defender.send(f"{telegraph_msg}\r\n")
defender = encounter.defender
telegraph_msg = render_pov(move.telegraph, defender, mob, defender)
telegraph_color = move.telegraph_color
if telegraph_color:
color_depth = getattr(defender, "color_depth", None)
telegraph_msg = colorize(
f"{{{telegraph_color}}}{telegraph_msg}{{/}}", color_depth
)
await defender.send(f"{telegraph_msg}\r\n")
def _try_defend(mob, encounter, combat_moves, now):

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
@ -32,6 +33,7 @@ class Player(Entity):
paint_brush: str = "."
prompt_template: str | None = None
_last_msdp: dict = field(default_factory=dict, repr=False)
_power_task: asyncio.Task | None = None
@property
def mode(self) -> str:

View file

@ -55,7 +55,7 @@ def render_prompt(player: Player) -> str:
"pl": str(round(player.pl)),
"max_pl": str(round(player.max_pl)),
"opponent": _get_opponent_name(player),
"move": "", # TODO: current attack/defense move name
"move": _get_current_move(player),
"name": player.name,
"mode": player.mode,
"x": str(player.x),
@ -95,6 +95,27 @@ def _get_opponent_name(player: Player) -> str:
return encounter.attacker.name
def _get_current_move(player: Player) -> str:
"""Get the name of the player's current combat move if any.
Args:
player: The player to check
Returns:
Move name if in combat with active move, empty string otherwise
"""
from mudlib.combat.engine import get_encounter
encounter = get_encounter(player)
if encounter is None:
return ""
if encounter.current_move is None:
return ""
return encounter.current_move.name
def _get_combat_state(player: Player) -> str:
"""Get the current combat state for the player.

105
src/mudlib/render/pov.py Normal file
View file

@ -0,0 +1,105 @@
"""POV template engine for combat messages."""
import re
from typing import Any
def render_pov(
template: str,
viewer: Any | None,
attacker: Any | None,
defender: Any | None,
) -> str:
"""Render a combat message template from a specific viewer's POV.
Args:
template: Message template with {attacker}, {defender}, and
contextual tags like {s}, {es}, {y|ies}, {his}, {him}
viewer: Entity viewing the message (determines "You" vs name)
attacker: Attacking entity
defender: Defending entity
Returns:
Rendered message with appropriate perspective substitutions
Contextual tags apply to the most recently mentioned entity:
- {s} "" for 2nd person, "s"/"es" for 3rd (smart conjugation)
- {es} "" for 2nd person, "es" for 3rd person
- {y|ies} left form for 2nd person, right form for 3rd person
- {his} "your" for 2nd person, "his" for 3rd person
- {him} "you" for 2nd person, "him" for 3rd person
"""
if not template:
return template
# Track whether the last entity mentioned was "You" (2nd person)
last_was_you = False
# Pattern to match all tags
tag_pattern = re.compile(r"\{(attacker|defender|s|es|his|him|[^}]*\|[^}]*)\}")
# Process template character by character, building result
result = []
pos = 0
for match in tag_pattern.finditer(template):
# Add text before this tag
result.append(template[pos : match.start()])
tag = match.group(0)
tag_name = match.group(1)
# Entity tags
if tag_name in ("attacker", "defender"):
entity = attacker if tag_name == "attacker" else defender
if entity is viewer:
last_was_you = True
result.append("You")
else:
last_was_you = False
result.append(entity.name if entity else "")
# Contextual verb conjugation tags
elif tag == "{s}":
if last_was_you:
result.append("")
else:
# Check previous character for smart conjugation
prev_text = "".join(result)
if prev_text and (
prev_text[-1:] in "sxz"
or (len(prev_text) >= 2 and prev_text[-2:] in ("ch", "sh"))
):
result.append("es")
else:
result.append("s")
elif tag == "{es}":
result.append("" if last_was_you else "es")
elif tag == "{his}":
result.append("your" if last_was_you else "his")
elif tag == "{him}":
# Check if "self" follows to form reflexive pronoun
remaining = template[match.end() :]
if remaining.startswith("self"):
result.append("your" if last_was_you else "him")
else:
result.append("you" if last_was_you else "him")
elif "|" in tag:
# Handle {a|b} pattern
content = tag[1:-1] # Strip { and }
left, right = content.split("|", 1)
result.append(left if last_was_you else right)
else:
result.append(tag)
pos = match.end()
# Add remaining text after last tag
result.append(template[pos:])
return "".join(result)

View file

@ -14,21 +14,34 @@ async def process_resting() -> None:
Called once per game loop tick (10 times per second).
Adds STAMINA_PER_TICK stamina to each resting player.
Sleeping players get 3x the recovery rate.
Auto-stops resting when stamina reaches max.
"""
for player in list(players.values()):
if not player.resting:
continue
# Add stamina for this tick
player.stamina = min(player.stamina + STAMINA_PER_TICK, player.max_stamina)
# Calculate stamina gain (3x if sleeping)
stamina_gain = STAMINA_PER_TICK * (3 if player.sleeping else 1)
player.stamina = min(player.stamina + stamina_gain, player.max_stamina)
# Check if we reached max stamina
if player.stamina >= player.max_stamina:
was_sleeping = player.sleeping
player.resting = False
player.sleeping = False
player._last_stamina_cue = 1.0 # Reset stamina cues on full recovery
send_char_status(player)
send_char_vitals(player)
await player.send("You feel fully rested.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} stops resting.\r\n"
message = (
"You wake up fully rested.\r\n"
if was_sleeping
else "You feel fully rested.\r\n"
)
await player.send(message)
nearby_message = (
f"{player.name} wakes up.\r\n"
if was_sleeping
else f"{player.name} stops resting.\r\n"
)
await send_nearby_message(player, player.x, player.y, nearby_message)

View file

@ -24,8 +24,10 @@ import mudlib.commands.look
import mudlib.commands.movement
import mudlib.commands.play
import mudlib.commands.portals
import mudlib.commands.power
import mudlib.commands.quit
import mudlib.commands.reload
import mudlib.commands.snapneck
import mudlib.commands.spawn
import mudlib.commands.things
import mudlib.commands.use
@ -59,6 +61,7 @@ from mudlib.store import (
)
from mudlib.thing import Thing
from mudlib.things import load_thing_templates, spawn_thing, thing_templates
from mudlib.unconscious import process_unconscious
from mudlib.world.terrain import World
from mudlib.zone import Zone
from mudlib.zones import get_zone, load_zones, register_zone
@ -96,6 +99,7 @@ async def game_loop() -> None:
await process_combat()
await process_mobs(mudlib.combat.commands.combat_moves)
await process_resting()
await process_unconscious()
# MSDP updates once per second (every TICK_RATE ticks)
if tick_count % TICK_RATE == 0:

40
src/mudlib/unconscious.py Normal file
View file

@ -0,0 +1,40 @@
"""Unconscious state recovery system."""
from mudlib.gmcp import send_char_status, send_char_vitals
from mudlib.player import players
# Recovery rate per tick (10 ticks/sec)
# 0.1 per tick = 1.0 per second recovery
RECOVERY_PER_TICK = 0.1
async def process_unconscious() -> None:
"""Process recovery for all unconscious players.
Called once per game loop tick (10 times per second).
Adds RECOVERY_PER_TICK to both PL and stamina for unconscious players.
When both PL and stamina are above 0, player regains consciousness.
"""
for player in list(players.values()):
# Check if player is unconscious (PL or stamina at or below 0)
if player.pl > 0 and player.stamina > 0:
continue
# Track whether we were unconscious at start
was_unconscious = player.posture == "unconscious"
# Recover PL if needed
if player.pl <= 0:
player.pl = min(player.pl + RECOVERY_PER_TICK, player.max_pl)
# Recover stamina if needed
if player.stamina <= 0:
player.stamina = min(player.stamina + RECOVERY_PER_TICK, player.max_stamina)
# Check if player is now conscious
if was_unconscious and player.pl > 0 and player.stamina > 0:
# Player regained consciousness
player._last_stamina_cue = 1.0 # Reset stamina cues on recovery
send_char_status(player)
send_char_vitals(player)
await player.send("You come to.\r\n")

69
tests/conftest.py Normal file
View file

@ -0,0 +1,69 @@
"""Shared test fixtures for mudlib tests."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.combat.engine import active_encounters
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_state():
"""Clear global state before and after each test."""
players.clear()
active_encounters.clear()
yield
players.clear()
active_encounters.clear()
@pytest.fixture
def mock_writer():
"""Create a mock writer for testing."""
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
"""Create a mock reader for testing."""
return MagicMock()
@pytest.fixture
def test_zone():
"""Create a test zone for players."""
terrain = [["." for _ in range(256)] for _ in range(256)]
zone = Zone(
name="testzone",
width=256,
height=256,
toroidal=True,
terrain=terrain,
impassable=set(),
)
return zone
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
"""Create a test player named Goku at origin."""
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p
return p
@pytest.fixture
def nearby_player(mock_reader, mock_writer, test_zone):
"""Create a nearby test player named Vegeta at origin."""
p = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p
return p

View file

@ -338,7 +338,7 @@ async def test_switch_attack_sends_new_telegraph(
target_msgs = [call[0][0] for call in target.writer.write.call_args_list]
# Defender should get a new telegraph with the new move's text
assert any("left hook" in msg.lower() for msg in target_msgs)
assert any("left arm" in msg.lower() for msg in target_msgs)
# --- defense commitment tests ---

View file

@ -154,8 +154,7 @@ def test_resolve_successful_counter(attacker, defender, punch, dodge):
assert defender.pl == initial_pl
assert result.damage == 0.0
assert result.countered is True
assert "countered" in result.attacker_msg.lower()
assert "countered" in result.defender_msg.lower()
assert "counter" in result.resolve_template.lower()
assert encounter.state == CombatState.IDLE
assert result.combat_ended is False
@ -173,8 +172,7 @@ def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge):
assert defender.pl == initial_pl - expected_damage
assert result.damage == expected_damage
assert result.countered is False
assert "hits" in result.attacker_msg.lower()
assert "hits" in result.defender_msg.lower()
assert "hit" in result.resolve_template.lower()
assert encounter.state == CombatState.IDLE
assert result.combat_ended is False
@ -192,8 +190,8 @@ def test_resolve_no_defense(attacker, defender, punch):
assert defender.pl == initial_pl - expected_damage
assert result.damage == expected_damage
assert result.countered is False
assert "slams" in result.attacker_msg.lower()
assert "slams" in result.defender_msg.lower()
# Template should indicate a hit
assert result.resolve_template != ""
assert encounter.state == CombatState.IDLE
assert result.combat_ended is False
@ -282,33 +280,33 @@ def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
assert result.combat_ended is False
def test_resolve_attacker_msg_contains_move_name(attacker, defender, punch):
"""Test attacker message includes the move name."""
def test_resolve_template_not_empty(attacker, defender, punch):
"""Test resolve returns a template."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
result = encounter.resolve()
assert "punch right" in result.attacker_msg.lower()
assert result.resolve_template != ""
def test_resolve_defender_msg_contains_attacker_name(attacker, defender, punch):
"""Test defender message includes attacker's name."""
def test_resolve_template_uses_pov_tags(attacker, defender, punch):
"""Test resolve template uses POV tags."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
result = encounter.resolve()
assert attacker.name.lower() in result.defender_msg.lower()
# Template should contain POV tags like {attacker} or {defender}
assert "{" in result.resolve_template
def test_resolve_counter_messages_contain_move_name(attacker, defender, punch, dodge):
"""Test counter messages include the move name for both players."""
def test_resolve_counter_template_indicates_counter(attacker, defender, punch, dodge):
"""Test counter template indicates a successful counter."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
encounter.defend(dodge)
result = encounter.resolve()
assert "punch right" in result.attacker_msg.lower()
assert "punch right" in result.defender_msg.lower()
assert "counter" in result.resolve_template.lower()
# --- Attack switching (feint) tests ---
@ -392,7 +390,7 @@ def test_resolve_uses_final_move(attacker, defender, punch, sweep):
# Sweep does 0.20 * 1.5 = 0.30 of PL (no defense)
expected_damage = attacker.pl * sweep.damage_pct * 1.5
assert defender.pl == initial_pl - expected_damage
assert "sweep" in result.attacker_msg.lower()
assert result.resolve_template != ""
# --- last_action_at tracking tests ---

View file

@ -326,8 +326,8 @@ async def test_process_combat_sends_messages_on_resolve(punch):
attacker_msgs = [call[0][0] for call in mock_writer.write.call_args_list]
defender_msgs = [call[0][0] for call in defender_writer.write.call_args_list]
assert any("punch right" in msg.lower() for msg in attacker_msgs)
assert any("punch right" in msg.lower() for msg in defender_msgs)
assert len(attacker_msgs) > 0
assert len(defender_msgs) > 0
# --- Idle timeout tests ---

224
tests/test_combat_zaxis.py Normal file
View file

@ -0,0 +1,224 @@
"""Tests for z-axis altitude checks in combat."""
from pathlib import Path
import pytest
from mudlib.combat import commands as combat_commands
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter, start_encounter
from mudlib.combat.moves import load_moves
@pytest.fixture
def target(mock_reader, mock_writer, test_zone):
"""Alias for nearby_player fixture with name 'target' for this module."""
from mudlib.player import Player, players
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
t.location = test_zone
test_zone._contents.append(t)
players[t.name] = t
return t
@pytest.fixture
def moves():
"""Load combat moves from content directory."""
content_dir = Path(__file__).parent.parent / "content" / "combat"
return load_moves(content_dir)
@pytest.fixture(autouse=True)
def inject_moves(moves):
"""Inject loaded moves into combat commands module."""
combat_commands.combat_moves = moves
yield
combat_commands.combat_moves = {}
@pytest.fixture
def punch_right(moves):
"""Get the punch right move."""
return moves["punch right"]
@pytest.fixture
def dodge_left(moves):
"""Get the dodge left move."""
return moves["dodge left"]
# --- Z-axis altitude check tests for starting combat ---
@pytest.mark.asyncio
async def test_attack_fails_when_attacker_flying_defender_grounded(
player, target, punch_right
):
"""Test attack fails when attacker is flying and defender is grounded."""
player.flying = True
target.flying = False
await combat_commands.do_attack(player, "Vegeta", punch_right)
# Combat should not start
encounter = get_encounter(player)
assert encounter is None
# Player should get error message
player.writer.write.assert_called()
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("can't reach" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_attack_fails_when_attacker_grounded_defender_flying(
player, target, punch_right
):
"""Test attack fails when attacker is grounded and defender is flying."""
player.flying = False
target.flying = True
await combat_commands.do_attack(player, "Vegeta", punch_right)
# Combat should not start
encounter = get_encounter(player)
assert encounter is None
# Player should get error message
player.writer.write.assert_called()
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("can't reach" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_attack_succeeds_when_both_flying(player, target, punch_right):
"""Test attack succeeds when both are flying."""
player.flying = True
target.flying = True
await combat_commands.do_attack(player, "Vegeta", punch_right)
# Combat should start
encounter = get_encounter(player)
assert encounter is not None
assert encounter.attacker is player
assert encounter.defender is target
@pytest.mark.asyncio
async def test_attack_succeeds_when_both_grounded(player, target, punch_right):
"""Test attack succeeds when both are grounded."""
player.flying = False
target.flying = False
await combat_commands.do_attack(player, "Vegeta", punch_right)
# Combat should start
encounter = get_encounter(player)
assert encounter is not None
assert encounter.attacker is player
assert encounter.defender is target
# --- Flying dodge tests (altitude mismatch at resolve time) ---
@pytest.mark.asyncio
async def test_flying_during_window_causes_miss(player, target, punch_right):
"""Test flying during window phase causes attack to miss at resolve time."""
# Both grounded, start combat
player.flying = False
target.flying = False
encounter = start_encounter(player, target)
encounter.attack(punch_right)
# Advance to WINDOW phase
encounter.state = CombatState.WINDOW
# Defender flies during window
target.flying = True
# Resolve
result = encounter.resolve()
# Attack should miss (countered or zero damage)
assert result.countered is True or result.damage == 0.0
@pytest.mark.asyncio
async def test_both_flying_at_resolve_attack_lands(player, target, punch_right):
"""Test both flying at resolve time — attack lands normally."""
# Both flying, start combat
player.flying = True
target.flying = True
encounter = start_encounter(player, target)
encounter.attack(punch_right)
# Advance to WINDOW phase (no altitude change)
encounter.state = CombatState.WINDOW
# Resolve
result = encounter.resolve()
# Attack should land (damage > 0 unless defended)
# Since there's no defense, damage should be > 0
assert result.damage > 0.0
@pytest.mark.asyncio
async def test_attacker_flies_during_window_causes_miss(player, target, punch_right):
"""Test attacker flying during window phase causes attack to miss."""
# Both grounded, start combat
player.flying = False
target.flying = False
encounter = start_encounter(player, target)
encounter.attack(punch_right)
# Advance to WINDOW phase
encounter.state = CombatState.WINDOW
# Attacker flies during window
player.flying = True
# Resolve
result = encounter.resolve()
# Attack should miss
assert result.countered is True or result.damage == 0.0
@pytest.mark.asyncio
async def test_flying_dodge_messages_correct_grammar(player, target, punch_right):
"""Test flying dodge produces grammatically correct messages from both POVs."""
from mudlib.render.pov import render_pov
# Both grounded, start combat
player.flying = False
target.flying = False
encounter = start_encounter(player, target)
encounter.attack(punch_right)
# Advance to WINDOW phase
encounter.state = CombatState.WINDOW
# Defender flies during window
target.flying = True
# Resolve
result = encounter.resolve()
# Render from both POVs
attacker_msg = render_pov(result.resolve_template, player, player, target)
defender_msg = render_pov(result.resolve_template, target, player, target)
# Attacker POV should say "You miss — Vegeta is out of reach!"
assert attacker_msg == "You miss — Vegeta is out of reach!"
# Defender POV should say "Goku misses — You are out of reach!"
assert defender_msg == "Goku misses — You are out of reach!"

View file

@ -177,7 +177,7 @@ async def test_commands_detail_simple_combat_move(player, combat_moves):
assert "stamina: 8.0" in output
assert "timing window: 2000ms" in output
assert "damage: 25%" in output
assert "{attacker} spins into a roundhouse kick!" in output
assert "{attacker} shifts {his} weight back..." in output
assert "countered by: duck, parry high, parry low" in output
@ -192,11 +192,11 @@ async def test_commands_detail_variant_base(player, combat_moves):
# Should show both variants
assert "punch left" in output
assert "{attacker} winds up a left hook!" in output
assert "{attacker} retracts {his} left arm..." in output
assert "countered by: dodge right, parry high" in output
assert "punch right" in output
assert "{attacker} winds up a right hook!" in output
assert "{attacker} retracts {his} right arm..." in output
assert "countered by: dodge left, parry high" in output
# Should show shared properties in each variant
@ -216,7 +216,7 @@ async def test_commands_detail_specific_variant(player, combat_moves):
assert "stamina: 5.0" in output
assert "timing window: 1800ms" in output
assert "damage: 15%" in output
assert "{attacker} winds up a left hook!" in output
assert "{attacker} retracts {his} left arm..." in output
assert "countered by: dodge right, parry high" in output
# Should NOT show "punch right"

View file

@ -253,8 +253,8 @@ class TestMobAttackAI:
player.writer.write.assert_called()
calls = [str(call) for call in player.writer.write.call_args_list]
# Check for actual telegraph patterns from the TOML files
# Goblin moves: punch left/right ("winds up"), sweep ("drops low")
telegraph_patterns = ["winds up", "drops low"]
# Goblin moves: punch left/right ("retracts"), sweep ("drops low")
telegraph_patterns = ["retracts", "drops low"]
telegraph_sent = any(
"goblin" in call.lower()
and any(pattern in call.lower() for pattern in telegraph_patterns)

222
tests/test_pov.py Normal file
View file

@ -0,0 +1,222 @@
"""Tests for POV template engine."""
from dataclasses import dataclass
import pytest
from mudlib.render.pov import render_pov
@dataclass
class MockEntity:
"""Simple entity for testing."""
name: str
@pytest.fixture
def jared():
"""Attacker entity."""
return MockEntity(name="Jared")
@pytest.fixture
def goku():
"""Defender entity."""
return MockEntity(name="Goku")
@pytest.fixture
def vegeta():
"""Bystander entity."""
return MockEntity(name="Vegeta")
def test_attacker_pov_basic(jared, goku):
"""Attacker sees 'You' for self, defender name for target."""
template = "{attacker} hit{s} {defender}"
result = render_pov(template, viewer=jared, attacker=jared, defender=goku)
assert result == "You hit Goku"
def test_defender_pov_basic(jared, goku):
"""Defender sees attacker name, 'You' for self."""
template = "{attacker} hit{s} {defender}"
result = render_pov(template, viewer=goku, attacker=jared, defender=goku)
assert result == "Jared hits You"
def test_bystander_pov_basic(jared, goku, vegeta):
"""Bystander sees both entity names."""
template = "{attacker} hits {defender}"
result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku)
assert result == "Jared hits Goku"
def test_verb_s_conjugation_you(jared, goku):
"""Verb with {s} after You gets no suffix."""
template = "{attacker} punch{s} {defender}"
result = render_pov(template, viewer=jared, attacker=jared, defender=goku)
assert result == "You punch Goku"
def test_verb_s_conjugation_third_person(jared, goku, vegeta):
"""Verb with {s} after name gets 's' suffix."""
template = "{attacker} punch{s} {defender}"
result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku)
assert result == "Jared punches Goku"
def test_verb_es_conjugation_you(jared, goku):
"""Verb with {es} after You gets no suffix."""
template = "{attacker} lurch{es} forward"
result = render_pov(template, viewer=jared, attacker=jared, defender=goku)
assert result == "You lurch forward"
def test_verb_es_conjugation_third_person(jared, goku, vegeta):
"""Verb with {es} after name gets 'es' suffix."""
template = "{attacker} lurch{es} forward"
result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku)
assert result == "Jared lurches forward"
def test_irregular_verb_y_ies_you(jared, goku):
"""Irregular verb {y|ies} after You uses left form."""
template = "{attacker} parr{y|ies} the blow"
result = render_pov(template, viewer=jared, attacker=jared, defender=goku)
assert result == "You parry the blow"
def test_irregular_verb_y_ies_third_person(jared, goku, vegeta):
"""Irregular verb {y|ies} after name uses right form."""
template = "{attacker} parr{y|ies} the blow"
result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku)
assert result == "Jared parries the blow"
def test_pronoun_his_you(jared, goku):
"""{his} after You becomes 'your'."""
template = "{attacker} raise{s} {his} fist"
result = render_pov(template, viewer=jared, attacker=jared, defender=goku)
assert result == "You raise your fist"
def test_pronoun_his_third_person(jared, goku, vegeta):
"""{his} after name becomes 'his'."""
template = "{attacker} raise{s} {his} fist"
result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku)
assert result == "Jared raises his fist"
def test_pronoun_him_you(jared, goku):
"""{him} after You becomes 'you'."""
template = "{attacker} brace{s} {him}self"
result = render_pov(template, viewer=jared, attacker=jared, defender=goku)
assert result == "You brace yourself"
def test_pronoun_him_third_person(jared, goku, vegeta):
"""{him} after name becomes 'him'."""
template = "{attacker} brace{s} {him}self"
result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku)
assert result == "Jared braces himself"
def test_mixed_template_attacker_pov(jared, goku):
"""Complex template with multiple substitutions, attacker POV."""
template = "{attacker} slam{s} {his} fist into {defender}"
result = render_pov(template, viewer=jared, attacker=jared, defender=goku)
assert result == "You slam your fist into Goku"
def test_mixed_template_defender_pov(jared, goku):
"""Complex template with multiple substitutions, defender POV."""
template = "{attacker} slam{s} {his} fist into {defender}"
result = render_pov(template, viewer=goku, attacker=jared, defender=goku)
assert result == "Jared slams his fist into You"
def test_mixed_template_bystander_pov(jared, goku, vegeta):
"""Complex template with multiple substitutions, bystander POV."""
template = "{attacker} slam{s} {his} fist into {defender}"
result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku)
assert result == "Jared slams his fist into Goku"
def test_defender_contextual_tags(jared, goku):
"""Contextual tags apply to defender when they precede them."""
template = "{defender} brace{s} {him}self against {attacker}"
result = render_pov(template, viewer=goku, attacker=jared, defender=goku)
assert result == "You brace yourself against Jared"
def test_defender_contextual_third_person(jared, goku, vegeta):
"""Contextual tags apply to defender in third person."""
template = "{defender} brace{s} {him}self against {attacker}"
result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku)
assert result == "Goku braces himself against Jared"
def test_plain_text_passthrough():
"""Template with no POV tags passes through unchanged."""
template = "The battle rages on!"
result = render_pov(template, viewer=None, attacker=None, defender=None)
assert result == "The battle rages on!"
def test_you_capitalization_mid_sentence(jared, goku):
"""'You' is capitalized even when not at start of sentence."""
template = "The blow strikes {defender} hard"
result = render_pov(template, viewer=goku, attacker=jared, defender=goku)
assert result == "The blow strikes You hard"
def test_multiple_entity_references(jared, goku):
"""Context switches between entities correctly."""
template = "{attacker} throw{s} a punch at {defender}, but {defender} dodge{s} it"
result = render_pov(template, viewer=goku, attacker=jared, defender=goku)
assert result == "Jared throws a punch at You, but You dodge it"
def test_multiple_entity_references_attacker_pov(jared, goku):
"""Context switches correctly from attacker POV."""
template = "{attacker} throw{s} a punch at {defender}, but {defender} dodg{es} it"
result = render_pov(template, viewer=jared, attacker=jared, defender=goku)
assert result == "You throw a punch at Goku, but Goku dodges it"
def test_you_capitalized_after_comma(jared, goku):
"""'You' stays capitalized after comma/conjunction."""
template = "{attacker} strikes, and {defender} fall{s}"
result = render_pov(template, viewer=goku, attacker=jared, defender=goku)
assert result == "Jared strikes, and You fall"
def test_s_conjugation_after_ch(jared, goku, vegeta):
"""{s} after 'ch' should add 'es' for third person."""
template = "{attacker} punch{s} {defender}"
result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku)
assert result == "Jared punches Goku"
def test_s_conjugation_after_sh(jared, goku, vegeta):
"""{s} after 'sh' should add 'es' for third person."""
template = "{attacker} smash{s} into {defender}"
result = render_pov(template, viewer=vegeta, attacker=jared, defender=goku)
assert result == "Jared smashes into Goku"
def test_s_conjugation_you_after_ch(jared, goku):
"""{s} should not add suffix for 'You' regardless of word ending."""
template = "{attacker} punch{s} {defender}"
result = render_pov(template, viewer=jared, attacker=jared, defender=goku)
assert result == "You punch Goku"
def test_s_conjugation_at_template_start():
"""{s} at start of template should not crash when checking prev_text."""
template = "{s}test"
result = render_pov(template, viewer=None, attacker=None, defender=None)
# Without entity, last_was_you defaults to False, so should add 's'
assert result == "stest"

298
tests/test_power.py Normal file
View file

@ -0,0 +1,298 @@
"""Tests for power commands."""
import asyncio
import time
import pytest
from mudlib.combat.encounter import CombatEncounter
from mudlib.combat.engine import active_encounters
from mudlib.commands.power import cmd_power
@pytest.mark.asyncio
async def test_power_down_instantly_lowers_pl(player):
"""Test power down instantly lowers PL to minimum."""
player.pl = 100.0
player.max_pl = 100.0
await cmd_power(player, "down")
assert player.pl == 1.0
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("power down" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_power_to_number_lowers_instantly(player):
"""Test power <number> instantly lowers when target < current."""
player.pl = 100.0
player.max_pl = 100.0
await cmd_power(player, "50")
assert player.pl == 50.0
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("50" in msg for msg in messages)
@pytest.mark.asyncio
async def test_power_to_number_rejects_zero(player):
"""Test power <number> rejects 0 or negative values."""
player.pl = 100.0
await cmd_power(player, "0")
assert player.pl == 100.0 # unchanged
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("must be greater than 0" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_power_to_number_rejects_above_max(player):
"""Test power <number> rejects values above max_pl."""
player.pl = 100.0
player.max_pl = 100.0
await cmd_power(player, "150")
assert player.pl == 100.0 # unchanged
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("maximum" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_power_up_starts_increasing_pl(player):
"""Test power up starts a loop that increases PL over time."""
player.pl = 10.0
player.max_pl = 100.0
player.stamina = 100.0
await cmd_power(player, "up")
# Give it a moment to run a few ticks
await asyncio.sleep(0.15)
assert player.pl > 10.0
assert player.pl < player.max_pl
@pytest.mark.asyncio
async def test_power_up_deducts_stamina(player):
"""Test power up deducts stamina per tick."""
player.pl = 10.0
player.max_pl = 100.0
player.stamina = 100.0
await cmd_power(player, "up")
# Give it a moment to run a few ticks
await asyncio.sleep(0.15)
assert player.stamina < 100.0
@pytest.mark.asyncio
async def test_power_up_stops_at_max_pl(player):
"""Test power up stops when PL reaches max_pl."""
player.pl = 95.0
player.max_pl = 100.0
player.stamina = 100.0
await cmd_power(player, "up")
# Wait for it to complete
await asyncio.sleep(0.3)
assert player.pl == 100.0
assert player._power_task is None or player._power_task.done()
@pytest.mark.asyncio
async def test_power_up_stops_when_stamina_depleted(player):
"""Test power up stops when stamina runs out."""
player.pl = 10.0
player.max_pl = 100.0
player.stamina = 5.0 # Very low stamina
await cmd_power(player, "up")
# Wait for stamina to deplete
await asyncio.sleep(0.3)
# PL should have increased a little, but stopped
assert player.pl > 10.0
assert player.pl < 100.0
assert player.stamina <= 0.0
assert player._power_task is None or player._power_task.done()
@pytest.mark.asyncio
async def test_power_stop_cancels_power_up(player):
"""Test power stop cancels an ongoing power-up."""
player.pl = 10.0
player.max_pl = 100.0
player.stamina = 100.0
await cmd_power(player, "up")
await asyncio.sleep(0.05) # Let it start
await cmd_power(player, "stop")
pl_at_stop = player.pl
await asyncio.sleep(0.1) # Wait a bit more
# PL should not have increased after stop
assert player.pl == pl_at_stop
assert player._power_task is None or player._power_task.done()
@pytest.mark.asyncio
async def test_power_up_rejects_during_combat(player, nearby_player):
"""Test power up is rejected when player is in combat."""
player.pl = 50.0
player.stamina = 100.0
# Put player in combat
encounter = CombatEncounter(
attacker=player, defender=nearby_player, last_action_at=time.monotonic()
)
active_encounters.append(encounter)
await cmd_power(player, "up")
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("combat" in msg.lower() for msg in messages)
assert player.pl == 50.0 # unchanged
@pytest.mark.asyncio
async def test_power_up_rejects_with_no_stamina(player):
"""Test power up is rejected when stamina is 0."""
player.pl = 50.0
player.stamina = 0.0
await cmd_power(player, "up")
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("stamina" in msg.lower() for msg in messages)
assert player.pl == 50.0 # unchanged
@pytest.mark.asyncio
async def test_power_up_sends_feedback_messages(player):
"""Test power up sends feedback to player."""
player.pl = 10.0
player.max_pl = 100.0
player.stamina = 100.0
await cmd_power(player, "up")
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("powering up" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_power_up_broadcasts_to_nearby(player, nearby_player):
"""Test power up broadcasts to nearby players."""
player.pl = 10.0
player.max_pl = 100.0
player.stamina = 100.0
await cmd_power(player, "up")
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any("Goku" in msg and "power" in msg.lower() for msg in nearby_messages)
@pytest.mark.asyncio
async def test_power_up_when_already_powering_up(player):
"""Test power up when already powering up sends message."""
player.pl = 10.0
player.max_pl = 100.0
player.stamina = 100.0
await cmd_power(player, "up")
await asyncio.sleep(0.05)
# Try to power up again
await cmd_power(player, "up")
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("already" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_power_down_broadcasts_to_nearby(player, nearby_player):
"""Test power down broadcasts to nearby players."""
player.pl = 100.0
await cmd_power(player, "down")
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any("Goku" in msg and "power" in msg.lower() for msg in nearby_messages)
@pytest.mark.asyncio
async def test_power_to_number_raises_with_loop(player):
"""Test power <number> starts power-up loop when target > current."""
player.pl = 10.0
player.max_pl = 100.0
player.stamina = 100.0
await cmd_power(player, "80")
# Give it a moment to run
await asyncio.sleep(0.15)
# Should be increasing toward 80
assert player.pl > 10.0
assert player.stamina < 100.0 # deducting stamina
@pytest.mark.asyncio
async def test_combat_start_cancels_power_up(player, nearby_player):
"""Test starting combat cancels any active power-up task."""
from mudlib.combat.engine import start_encounter
# Start player powering up
player.pl = 10.0
player.max_pl = 100.0
player.stamina = 100.0
await cmd_power(player, "up")
await asyncio.sleep(0.05) # Let power-up start
# Verify power-up is active
assert player._power_task is not None
assert not player._power_task.done()
# Start combat encounter
start_encounter(player, nearby_player)
# Power-up task should be cancelled
assert player._power_task is None or player._power_task.cancelled()
@pytest.mark.asyncio
async def test_combat_start_cancels_defender_power_up(player, nearby_player):
"""Test starting combat cancels defender's active power-up task."""
from mudlib.combat.engine import start_encounter
# Start nearby_player powering up
nearby_player.pl = 10.0
nearby_player.max_pl = 100.0
nearby_player.stamina = 100.0
await cmd_power(nearby_player, "up")
await asyncio.sleep(0.05) # Let power-up start
# Verify power-up is active
assert nearby_player._power_task is not None
assert not nearby_player._power_task.done()
# Start combat encounter (nearby_player is defender)
start_encounter(player, nearby_player)
# Power-up task should be cancelled
assert nearby_player._power_task is None or nearby_player._power_task.cancelled()

View file

@ -379,3 +379,98 @@ def test_combat_state_idle():
)
result = render_prompt(player)
assert result == "[idle] > "
def test_move_shows_name_when_in_combat_with_active_move():
"""Move variable shows attack name when in combat with current_move."""
from mudlib.combat.moves import CombatMove
player = Player(
name="Goku",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal", "combat"],
prompt_template="{move} > ",
)
opponent = Entity(name="Vegeta", pl=150.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 = CombatEncounter(attacker=player, defender=opponent)
encounter.attack(punch)
active_encounters.append(encounter)
result = render_prompt(player)
assert result == "punch right > "
def test_move_empty_when_not_in_combat():
"""Move variable is empty string when not in combat."""
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
prompt_template="{move} > ",
)
result = render_prompt(player)
assert result == " > "
def test_move_empty_when_in_combat_but_no_current_move():
"""Move variable is empty when in combat but IDLE state (no current_move)."""
player = Player(
name="Goku",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal", "combat"],
prompt_template="{move} > ",
)
opponent = Entity(name="Vegeta", pl=150.0)
encounter = CombatEncounter(attacker=player, defender=opponent)
active_encounters.append(encounter)
result = render_prompt(player)
assert result == " > "
def test_combat_state_shows_state_when_in_combat():
"""Combat state variable shows encounter state value."""
from mudlib.combat.moves import CombatMove
player = Player(
name="Goku",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal", "combat"],
prompt_template="[{combat_state}] > ",
)
opponent = Entity(name="Vegeta", pl=150.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 = CombatEncounter(attacker=player, defender=opponent)
encounter.attack(punch)
active_encounters.append(encounter)
result = render_prompt(player)
assert result == "[telegraph] > "

48
tests/test_quit.py Normal file
View file

@ -0,0 +1,48 @@
"""Tests for quit command."""
from unittest.mock import MagicMock, patch
import pytest
from mudlib.commands.quit import cmd_quit
from mudlib.player import players
@pytest.fixture
def mock_writer():
"""Override conftest mock_writer to add close method for quit tests."""
from unittest.mock import AsyncMock
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
writer.close = MagicMock()
return writer
@pytest.mark.asyncio
async def test_quit_blocked_during_combat(player):
"""Test quit is blocked when player is in combat mode."""
player.mode_stack.append("combat")
await cmd_quit(player, "")
player.writer.write.assert_called_once_with("You can't quit during combat!\r\n")
player.writer.close.assert_not_called()
assert player.name in players # Still in player registry
@pytest.mark.asyncio
async def test_quit_works_when_not_in_combat(player):
"""Test quit works normally when not in combat."""
# Player starts in "normal" mode by default
with patch("mudlib.commands.quit.save_player"):
await cmd_quit(player, "")
# Should write goodbye message
player.writer.write.assert_called_with("Goodbye!\r\n")
player.writer.drain.assert_called_once()
player.writer.close.assert_called_once()
assert player.name not in players # Removed from registry
assert player.location is None # Removed from zone

View file

@ -169,7 +169,13 @@ def test_game_loop_exists():
@pytest.mark.asyncio
async def test_game_loop_calls_clear_expired():
"""Game loop calls clear_expired each tick."""
with patch("mudlib.server.clear_expired") as mock_clear:
with (
patch("mudlib.server.clear_expired") as mock_clear,
patch("mudlib.server.process_combat", new_callable=AsyncMock),
patch("mudlib.server.process_mobs", new_callable=AsyncMock),
patch("mudlib.server.process_resting", new_callable=AsyncMock),
patch("mudlib.server.process_unconscious", new_callable=AsyncMock),
):
task = asyncio.create_task(server.game_loop())
await asyncio.sleep(0.25)
task.cancel()

158
tests/test_sleep.py Normal file
View file

@ -0,0 +1,158 @@
"""Tests for sleep command."""
import pytest
from mudlib.commands.sleep import cmd_sleep
from mudlib.resting import STAMINA_PER_TICK, process_resting
@pytest.mark.asyncio
async def test_sleep_when_full_sends_not_tired_message(player):
"""Test sleeping when at full stamina sends 'not tired' message."""
player.stamina = 100.0
player.max_stamina = 100.0
await cmd_sleep(player, "")
player.writer.write.assert_called_once_with("You're not tired.\r\n")
assert player.stamina == 100.0
assert not player.sleeping
assert not player.resting
@pytest.mark.asyncio
async def test_sleep_when_not_sleeping_starts_sleeping(player):
"""Test sleep command when not sleeping starts the sleeping state."""
player.stamina = 50.0
player.max_stamina = 100.0
player.sleeping = False
player.resting = False
await cmd_sleep(player, "")
assert player.sleeping
assert player.resting # sleeping implies resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("fall asleep" in msg or "go to sleep" in msg for msg in messages)
@pytest.mark.asyncio
async def test_sleep_when_already_sleeping_wakes_up(player):
"""Test sleep command when already sleeping wakes the player."""
player.stamina = 50.0
player.max_stamina = 100.0
player.sleeping = True
player.resting = True
await cmd_sleep(player, "")
assert not player.sleeping
assert not player.resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("wake up" in msg or "wake" in msg for msg in messages)
@pytest.mark.asyncio
async def test_sleep_blocked_during_combat(player):
"""Test sleep is blocked when player is in combat mode."""
player.stamina = 50.0
player.mode_stack.append("combat") # Push combat mode onto the stack
await cmd_sleep(player, "")
assert not player.sleeping
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any(
"can't sleep" in msg.lower() or "combat" in msg.lower() for msg in messages
)
@pytest.mark.asyncio
async def test_sleep_broadcasts_to_nearby_players(player, nearby_player):
"""Test sleeping broadcasts message to nearby players."""
player.stamina = 50.0
player.sleeping = False
await cmd_sleep(player, "")
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any(
"Goku" in msg and ("asleep" in msg or "sleep" in msg) for msg in nearby_messages
)
@pytest.mark.asyncio
async def test_wake_broadcasts_to_nearby_players(player, nearby_player):
"""Test waking up broadcasts message to nearby players."""
player.stamina = 50.0
player.sleeping = True
player.resting = True
await cmd_sleep(player, "")
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any("Goku" in msg and "wake" in msg for msg in nearby_messages)
@pytest.mark.asyncio
async def test_sleep_stamina_recovery_faster_than_rest(player):
"""Test sleeping provides faster stamina recovery than resting."""
player.stamina = 50.0
player.max_stamina = 100.0
player.sleeping = True
player.resting = True
await process_resting()
# Sleep should give 3x the base rate (0.2 * 3 = 0.6)
expected = 50.0 + (STAMINA_PER_TICK * 3)
assert player.stamina == expected
@pytest.mark.asyncio
async def test_sleep_auto_stops_when_full(player):
"""Test sleep auto-stops when stamina reaches max."""
player.stamina = 99.5
player.max_stamina = 100.0
player.sleeping = True
player.resting = True
await process_resting()
assert player.stamina == 100.0
assert not player.sleeping
assert not player.resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("fully rested" in msg or "wake" in msg for msg in messages)
@pytest.mark.asyncio
async def test_sleeping_player_does_not_see_nearby_messages(player, nearby_player):
"""Test sleeping players don't receive nearby messages."""
from mudlib.commands.movement import send_nearby_message
player.sleeping = True
# Nearby player does something that would normally broadcast
await send_nearby_message(
nearby_player, nearby_player.x, nearby_player.y, "Test message.\r\n"
)
# Sleeping player should not have received it
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert len(messages) == 0
@pytest.mark.asyncio
async def test_awake_player_sees_nearby_messages(player, nearby_player):
"""Test awake players receive nearby messages normally."""
from mudlib.commands.movement import send_nearby_message
player.sleeping = False
await send_nearby_message(
nearby_player, nearby_player.x, nearby_player.y, "Test message.\r\n"
)
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("Test message" in msg for msg in messages)

View file

@ -0,0 +1,282 @@
"""Tests for stamina cue system wiring and reset behavior."""
import asyncio
import contextlib
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mudlib.player import Player
from mudlib.zone import Zone
@pytest.fixture
def test_zone():
"""Override conftest test_zone with smaller zone for stamina tests."""
return Zone(name="test", width=10, height=10)
@pytest.fixture
def player(mock_writer, test_zone):
"""Override conftest player with custom position and stats for stamina tests."""
p = Player(
name="Goku",
x=5,
y=5,
pl=100.0,
stamina=100.0,
max_stamina=100.0,
writer=mock_writer,
)
p.move_to(test_zone, x=5, y=5)
return p
@pytest.fixture
def defender(test_zone):
"""Defender fixture with custom position for stamina tests."""
w = MagicMock()
w.write = MagicMock()
w.drain = AsyncMock()
p = Player(
name="Vegeta",
x=5,
y=5,
pl=100.0,
stamina=100.0,
max_stamina=100.0,
writer=w,
)
p.move_to(test_zone, x=5, y=5)
return p
@pytest.mark.asyncio
async def test_stamina_cue_after_combat_damage(player, defender):
"""check_stamina_cues is called after damage in combat resolution."""
import time
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import active_encounters, start_encounter
from mudlib.combat.moves import CombatMove
# Clear encounters
active_encounters.clear()
# Create a move that deals damage
move = CombatMove(
name="weak punch",
command="weak",
move_type="attack",
damage_pct=0.5,
stamina_cost=5.0,
timing_window_ms=100,
)
# Start encounter
encounter = start_encounter(player, defender)
encounter.attack(move)
# Fast-forward to RESOLVE state
encounter.state = CombatState.RESOLVE
encounter.move_started_at = time.monotonic()
# Patch check_stamina_cues to verify it's called
with patch("mudlib.combat.engine.check_stamina_cues") as mock_check:
from mudlib.combat.engine import process_combat
await process_combat()
# Should be called for both entities after damage
assert mock_check.called
calls = [call[0][0] for call in mock_check.call_args_list]
assert player in calls
assert defender in calls
@pytest.mark.asyncio
async def test_stamina_cue_after_power_up_deduction(player):
"""check_stamina_cues is called during power-up stamina deduction."""
from mudlib.commands.power import power_up_loop
player.stamina = 100.0
player.max_stamina = 100.0
player.pl = 10.0
player.max_pl = 50.0
with patch("mudlib.commands.power.check_stamina_cues") as mock_check:
# Run power-up for a short time
task = asyncio.create_task(power_up_loop(player, target_pl=20.0))
await asyncio.sleep(0.15) # Let a few ticks happen
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
# check_stamina_cues should have been called at least once
assert mock_check.called
# Verify player was the argument
calls = [call[0][0] for call in mock_check.call_args_list]
assert player in calls
@pytest.mark.asyncio
async def test_stamina_cue_after_attack_cost(player, defender):
"""check_stamina_cues is called after attack stamina cost deduction."""
from mudlib.combat.commands import do_attack
from mudlib.combat.engine import active_encounters
from mudlib.combat.moves import CombatMove
from mudlib.player import players
# Clear encounters
active_encounters.clear()
# Register defender in global players dict so do_attack can find it
players["Vegeta"] = defender
try:
# Create a move with high stamina cost
move = CombatMove(
name="power punch",
command="power",
move_type="attack",
damage_pct=0.3,
stamina_cost=60.0,
timing_window_ms=1000,
)
player.stamina = 100.0
player.max_stamina = 100.0
with patch("mudlib.combat.commands.check_stamina_cues") as mock_check:
await do_attack(player, "Vegeta", move)
# check_stamina_cues should have been called
assert mock_check.called
# Verify player was the argument
calls = [call[0][0] for call in mock_check.call_args_list]
assert player in calls
finally:
players.clear()
@pytest.mark.asyncio
async def test_stamina_cue_after_defense_cost(player):
"""check_stamina_cues is called after defense stamina cost deduction."""
from mudlib.combat.commands import do_defend
from mudlib.combat.moves import CombatMove
# Create a defense move with stamina cost
move = CombatMove(
name="duck",
command="duck",
move_type="defense",
stamina_cost=30.0,
timing_window_ms=100,
)
player.stamina = 100.0
player.max_stamina = 100.0
with patch("mudlib.combat.commands.check_stamina_cues") as mock_check:
# Use a short timing window to avoid blocking too long
move.timing_window_ms = 10
await do_defend(player, "", move)
# check_stamina_cues should have been called
assert mock_check.called
# Verify player was the argument
calls = [call[0][0] for call in mock_check.call_args_list]
assert player in calls
@pytest.mark.asyncio
async def test_stamina_cue_reset_on_resting_recovery(player):
"""_last_stamina_cue resets to 1.0 when stamina fully recovers via resting."""
from mudlib.combat.stamina import check_stamina_cues
from mudlib.player import players
from mudlib.resting import process_resting
# Register player in global dict so process_resting can find them
players[player.name] = player
try:
# Drop stamina to trigger a cue
player.stamina = 20.0
player.max_stamina = 100.0
await check_stamina_cues(player)
assert player._last_stamina_cue == 0.25 # Below 25% threshold
# Start resting and set stamina very close to max
player.resting = True
player.stamina = 99.85 # Will be clamped to 100.0 after one tick
# Process one tick to reach max
await process_resting()
# Stamina should be at max and _last_stamina_cue should reset
assert player.stamina == 100.0
assert player._last_stamina_cue == 1.0
finally:
players.clear()
@pytest.mark.asyncio
async def test_stamina_cue_reset_on_unconscious_recovery(player):
"""_last_stamina_cue resets to 1.0 when stamina recovers from unconsciousness."""
from mudlib.combat.stamina import check_stamina_cues
from mudlib.player import players
from mudlib.unconscious import process_unconscious
# Register player in global dict so process_unconscious can find them
players[player.name] = player
try:
# Drop stamina to trigger a cue
player.stamina = 5.0
player.max_stamina = 100.0
await check_stamina_cues(player)
assert player._last_stamina_cue == 0.10 # Below 10% threshold
# Drop to unconscious
player.stamina = 0.0
player.pl = 0.0
# Verify player is unconscious
assert player.posture == "unconscious"
# Process recovery tick to regain consciousness
await process_unconscious()
# Should have regained consciousness and reset cue
assert player.stamina > 0
assert player.pl > 0
assert player._last_stamina_cue == 1.0
finally:
players.clear()
@pytest.mark.asyncio
async def test_cue_fires_again_after_reset(player):
"""After reset, cues fire again on next descent."""
from mudlib.combat.stamina import check_stamina_cues
player.max_stamina = 100.0
# First descent: trigger 75% threshold
player.stamina = 70.0
player.writer.write.reset_mock()
await check_stamina_cues(player)
first_call_count = player.writer.write.call_count
assert first_call_count > 0
assert player._last_stamina_cue == 0.75
# Recover to max and reset
player.stamina = 100.0
player._last_stamina_cue = 1.0
# Second descent: should fire again at 75%
player.stamina = 70.0
player.writer.write.reset_mock()
await check_stamina_cues(player)
second_call_count = player.writer.write.call_count
assert second_call_count > 0 # Should fire again

231
tests/test_stamina_cues.py Normal file
View file

@ -0,0 +1,231 @@
"""Tests for stamina cue broadcasts."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mudlib.player import Player
from mudlib.zone import Zone
@pytest.fixture
def test_zone():
"""Override conftest test_zone with smaller zone for stamina tests."""
return Zone(name="test", width=10, height=10)
@pytest.fixture
def player(mock_writer, test_zone):
"""Override conftest player with custom position and stats for stamina tests."""
p = Player(
name="Goku",
x=5,
y=5,
pl=100.0,
stamina=100.0,
max_stamina=100.0,
writer=mock_writer,
)
p.move_to(test_zone, x=5, y=5)
return p
@pytest.fixture
def nearby_player(test_zone):
"""Override conftest nearby_player with custom position for stamina tests."""
w = MagicMock()
w.write = MagicMock()
w.drain = AsyncMock()
p = Player(
name="Vegeta",
x=5,
y=5,
pl=100.0,
stamina=100.0,
max_stamina=100.0,
writer=w,
)
p.move_to(test_zone, x=5, y=5)
return p
@pytest.mark.asyncio
async def test_no_cue_above_75_percent(player):
"""No cue when stamina is above 75%."""
from mudlib.combat.stamina import check_stamina_cues
player.stamina = 80.0
player.max_stamina = 100.0
await check_stamina_cues(player)
# No message sent to player
player.writer.write.assert_not_called()
@pytest.mark.asyncio
async def test_breathing_heavily_below_75_percent(player):
"""'Breathing heavily' cue when stamina drops below 75%."""
from mudlib.combat.stamina import check_stamina_cues
player.stamina = 70.0
player.max_stamina = 100.0
await check_stamina_cues(player)
# Check self-directed message
calls = [call[0][0] for call in player.writer.write.call_args_list]
assert any("You're breathing heavily." in msg for msg in calls)
@pytest.mark.asyncio
async def test_drenched_in_sweat_below_50_percent(player):
"""'Drenched in sweat' cue when below 50%."""
from mudlib.combat.stamina import check_stamina_cues
player.stamina = 45.0
player.max_stamina = 100.0
await check_stamina_cues(player)
calls = [call[0][0] for call in player.writer.write.call_args_list]
assert any("You're drenched in sweat." in msg for msg in calls)
@pytest.mark.asyncio
async def test_visibly_shaking_below_25_percent(player):
"""'Visibly shaking' cue when below 25%."""
from mudlib.combat.stamina import check_stamina_cues
player.stamina = 20.0
player.max_stamina = 100.0
await check_stamina_cues(player)
calls = [call[0][0] for call in player.writer.write.call_args_list]
assert any("You're visibly shaking from exhaustion." in msg for msg in calls)
@pytest.mark.asyncio
async def test_can_barely_stand_below_10_percent(player):
"""'Can barely stand' cue when below 10%."""
from mudlib.combat.stamina import check_stamina_cues
player.stamina = 8.0
player.max_stamina = 100.0
await check_stamina_cues(player)
calls = [call[0][0] for call in player.writer.write.call_args_list]
assert any("You can barely stand." in msg for msg in calls)
@pytest.mark.asyncio
async def test_same_threshold_not_triggered_twice(player):
"""Same threshold doesn't trigger twice (spam prevention)."""
from mudlib.combat.stamina import check_stamina_cues
# First trigger at 70%
player.stamina = 70.0
player.max_stamina = 100.0
await check_stamina_cues(player)
first_call_count = player.writer.write.call_count
# Second trigger at same threshold (still 70%)
player.stamina = 70.0
await check_stamina_cues(player)
second_call_count = player.writer.write.call_count
# No new messages should have been sent
assert second_call_count == first_call_count
@pytest.mark.asyncio
async def test_higher_threshold_doesnt_retrigger(player):
"""Higher threshold doesn't trigger if already at lower."""
from mudlib.combat.stamina import check_stamina_cues
# Drop to 20% first
player.stamina = 20.0
player.max_stamina = 100.0
await check_stamina_cues(player)
player.writer.write.reset_mock()
# Recover to 30% (back into 25-50% range)
player.stamina = 30.0
await check_stamina_cues(player)
# No new cue should trigger
player.writer.write.assert_not_called()
@pytest.mark.asyncio
async def test_self_directed_message_sent(player):
"""Self-directed message sent to the entity."""
from mudlib.combat.stamina import check_stamina_cues
player.stamina = 70.0
player.max_stamina = 100.0
await check_stamina_cues(player)
# Verify self message was sent
calls = [call[0][0] for call in player.writer.write.call_args_list]
assert any("You're breathing heavily." in msg for msg in calls)
@pytest.mark.asyncio
async def test_nearby_broadcast_sent(player, nearby_player):
"""Nearby broadcast sent to other players."""
from mudlib.combat.stamina import check_stamina_cues
player.stamina = 70.0
player.max_stamina = 100.0
with patch("mudlib.combat.stamina.send_nearby_message") as mock_nearby:
await check_stamina_cues(player)
# Verify send_nearby_message was called
mock_nearby.assert_called_once()
args = mock_nearby.call_args[0]
assert args[0] is player
assert args[1] == 5 # x coordinate
assert args[2] == 5 # y coordinate
assert "Goku is breathing heavily." in args[3]
@pytest.mark.asyncio
async def test_descending_thresholds_trigger_each_once(player):
"""Descending through thresholds triggers each once."""
from mudlib.combat.stamina import check_stamina_cues
player.max_stamina = 100.0
# Start at 80%, no cue
player.stamina = 80.0
await check_stamina_cues(player)
count_80 = player.writer.write.call_count
# Drop to 70%, should trigger 75% threshold
player.stamina = 70.0
await check_stamina_cues(player)
count_70 = player.writer.write.call_count
assert count_70 > count_80
# Drop to 40%, should trigger 50% threshold
player.stamina = 40.0
await check_stamina_cues(player)
count_40 = player.writer.write.call_count
assert count_40 > count_70
# Drop to 20%, should trigger 25% threshold
player.stamina = 20.0
await check_stamina_cues(player)
count_20 = player.writer.write.call_count
assert count_20 > count_40
# Drop to 5%, should trigger 10% threshold
player.stamina = 5.0
await check_stamina_cues(player)
count_5 = player.writer.write.call_count
assert count_5 > count_20

314
tests/test_three_beat.py Normal file
View file

@ -0,0 +1,314 @@
"""Tests for three-beat combat output system."""
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.caps import ClientCaps
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import (
active_encounters,
process_combat,
start_encounter,
)
from mudlib.combat.moves import CombatMove
from mudlib.player import Player
def _mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture(autouse=True)
def clear_encounters():
"""Clear encounters before and after each test."""
active_encounters.clear()
yield
active_encounters.clear()
@pytest.fixture
def punch():
return CombatMove(
name="punch left",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
damage_pct=0.15,
countered_by=["dodge right"],
telegraph="{attacker} retracts {his} left arm...",
announce="{attacker} throw{s} a left hook at {defender}!",
resolve_hit="{attacker} connect{s} with {his} left hook!",
resolve_miss="{defender} counter{s} the left hook!",
telegraph_color="dim",
announce_color="",
resolve_color="bold",
)
@pytest.mark.asyncio
async def test_announce_sent_on_telegraph_to_window_transition(punch):
"""Test announce message sent when TELEGRAPH→WINDOW transition occurs."""
atk_writer = _mock_writer()
def_writer = _mock_writer()
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer)
defender = Player(
name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer
)
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
# Should be in TELEGRAPH state
assert encounter.state == CombatState.TELEGRAPH
# Reset mocks to ignore previous messages
atk_writer.write.reset_mock()
def_writer.write.reset_mock()
# Wait for telegraph phase and process
time.sleep(0.31)
await process_combat()
# Should have transitioned to WINDOW
assert encounter.state == CombatState.WINDOW
# Check announce message was sent
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
def_msgs = [call[0][0] for call in def_writer.write.call_args_list]
# Attacker should see "You throw a left hook at Vegeta!"
assert any("throw" in msg.lower() and "hook" in msg.lower() for msg in atk_msgs)
# Defender should see "Goku throws a left hook at you!"
assert any("throws" in msg.lower() and "hook" in msg.lower() for msg in def_msgs)
@pytest.mark.asyncio
async def test_announce_uses_pov_templates(punch):
"""Test announce messages use POV templates correctly."""
atk_writer = _mock_writer()
def_writer = _mock_writer()
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer)
defender = Player(
name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer
)
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
atk_writer.write.reset_mock()
def_writer.write.reset_mock()
time.sleep(0.31)
await process_combat()
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
def_msgs = [call[0][0] for call in def_writer.write.call_args_list]
# Attacker POV: "You throw a left hook at Vegeta!"
assert any("You throw" in msg for msg in atk_msgs)
# Defender POV: "Goku throws a left hook at You!"
assert any("Goku throws" in msg and "at You" in msg for msg in def_msgs)
@pytest.mark.asyncio
async def test_resolve_uses_pov_templates(punch):
"""Test resolve messages use POV templates correctly."""
atk_writer = _mock_writer()
def_writer = _mock_writer()
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer)
defender = Player(
name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer
)
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
# Advance to window
time.sleep(0.31)
await process_combat()
atk_writer.write.reset_mock()
def_writer.write.reset_mock()
# Advance to resolve
time.sleep(0.85)
await process_combat()
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
def_msgs = [call[0][0] for call in def_writer.write.call_args_list]
# Attacker POV: "You connect with your left hook!"
assert any("You connect" in msg and "your" in msg for msg in atk_msgs)
# Defender POV: "Goku connects with his left hook!"
assert any("Goku connects" in msg and "his" in msg for msg in def_msgs)
@pytest.mark.asyncio
async def test_resolve_uses_resolve_miss_on_counter(punch):
"""Test resolve uses resolve_miss template when countered."""
atk_writer = _mock_writer()
def_writer = _mock_writer()
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer)
defender = Player(
name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer
)
dodge = CombatMove(
name="dodge right",
move_type="defense",
stamina_cost=3.0,
timing_window_ms=800,
)
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
encounter.defend(dodge)
# Advance to resolve
time.sleep(0.31)
await process_combat()
atk_writer.write.reset_mock()
def_writer.write.reset_mock()
time.sleep(0.85)
await process_combat()
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
def_msgs = [call[0][0] for call in def_writer.write.call_args_list]
# Should use resolve_miss template
# Attacker POV: "Vegeta counters your left hook!"
assert any("counter" in msg.lower() for msg in atk_msgs)
# Defender POV: "You counter Goku's left hook!"
assert any("You counter" in msg for msg in def_msgs)
@pytest.mark.asyncio
async def test_announce_color_applied(punch):
"""Test announce messages have no color wrap (default)."""
atk_writer = _mock_writer()
def_writer = _mock_writer()
# Set color depth to enable colors via caps
attacker = Player(
name="Goku",
x=0,
y=0,
pl=100.0,
stamina=50.0,
writer=atk_writer,
caps=ClientCaps(ansi=True),
)
defender = Player(
name="Vegeta",
x=0,
y=0,
pl=100.0,
stamina=50.0,
writer=def_writer,
caps=ClientCaps(ansi=True),
)
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
atk_writer.write.reset_mock()
def_writer.write.reset_mock()
time.sleep(0.31)
await process_combat()
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
# Announce has announce_color="" so should NOT have ANSI codes
# (just plain text)
announce_msgs = [msg for msg in atk_msgs if "throw" in msg.lower()]
assert len(announce_msgs) > 0
# Should not contain ANSI escape sequences for colors
# (no \033[ sequences for colors, may have for other reasons)
@pytest.mark.asyncio
async def test_resolve_bold_color_applied(punch):
"""Test resolve messages have bold color applied."""
atk_writer = _mock_writer()
def_writer = _mock_writer()
attacker = Player(
name="Goku",
x=0,
y=0,
pl=100.0,
stamina=50.0,
writer=atk_writer,
caps=ClientCaps(ansi=True),
)
defender = Player(
name="Vegeta",
x=0,
y=0,
pl=100.0,
stamina=50.0,
writer=def_writer,
caps=ClientCaps(ansi=True),
)
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
# Advance to resolve
time.sleep(0.31)
await process_combat()
atk_writer.write.reset_mock()
def_writer.write.reset_mock()
time.sleep(0.85)
await process_combat()
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
# Resolve has resolve_color="bold" so should have ANSI bold codes
resolve_msgs = [msg for msg in atk_msgs if "connect" in msg.lower()]
assert len(resolve_msgs) > 0
# Should contain ANSI bold sequence
assert any("\033[1m" in msg for msg in resolve_msgs)
@pytest.mark.asyncio
async def test_no_announce_on_idle_to_telegraph():
"""Test no announce message sent on IDLE→TELEGRAPH transition."""
atk_writer = _mock_writer()
def_writer = _mock_writer()
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer)
defender = Player(
name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer
)
punch = CombatMove(
name="punch left",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
damage_pct=0.15,
announce="{attacker} throw{s} a left hook at {defender}!",
)
encounter = start_encounter(attacker, defender)
atk_writer.write.reset_mock()
def_writer.write.reset_mock()
encounter.attack(punch)
# Process combat immediately (still in TELEGRAPH)
await process_combat()
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
# Should NOT see announce message yet
assert not any("throw" in msg.lower() for msg in atk_msgs)

259
tests/test_unconscious.py Normal file
View file

@ -0,0 +1,259 @@
"""Tests for unconscious state mechanics."""
import pytest
from mudlib.combat.engine import active_encounters, start_encounter
from mudlib.combat.moves import CombatMove
from mudlib.player import Player, players
from mudlib.unconscious import process_unconscious
class MockWriter:
"""Mock writer that captures output."""
def __init__(self):
self.written = []
self.closed = False
# Mock option negotiation state
from unittest.mock import MagicMock
self.local_option = MagicMock()
self.local_option.enabled = MagicMock(return_value=False)
self.remote_option = MagicMock()
self.remote_option.enabled = MagicMock(return_value=False)
def write(self, data):
if isinstance(data, str):
self.written.append(data)
async def drain(self):
pass
def close(self):
self.closed = True
def is_closing(self):
return self.closed
@pytest.fixture
def clear_state():
"""Clear global state before each test."""
players.clear()
active_encounters.clear()
yield
players.clear()
active_encounters.clear()
@pytest.fixture
def mock_writer():
"""Create a mock writer for testing."""
return MockWriter()
def test_unconscious_when_pl_zero(clear_state, mock_writer):
"""Test that entity becomes unconscious when PL reaches 0."""
player = Player(name="TestPlayer", x=0, y=0, pl=100.0, writer=mock_writer)
# Player should start conscious
assert player.posture != "unconscious"
# Drop PL to 0
player.pl = 0.0
# Player should now be unconscious
assert player.posture == "unconscious"
def test_unconscious_when_stamina_zero(clear_state, mock_writer):
"""Test that entity becomes unconscious when stamina reaches 0."""
player = Player(name="TestPlayer", x=0, y=0, stamina=100.0, writer=mock_writer)
# Player should start conscious
assert player.posture != "unconscious"
# Drop stamina to 0
player.stamina = 0.0
# Player should now be unconscious
assert player.posture == "unconscious"
@pytest.mark.asyncio
async def test_unconscious_recovery(clear_state, mock_writer):
"""Test that unconscious player recovers after ticks."""
player = Player(
name="TestPlayer", x=0, y=0, pl=0.0, stamina=0.0, writer=mock_writer
)
players["TestPlayer"] = player
# Player should start unconscious
assert player.posture == "unconscious"
# Process recovery ticks until player is conscious
# Recovery rate is 0.1 per tick for both PL and stamina
# Need 1 tick to get above 0
await process_unconscious()
# After one tick, both should be above 0
assert player.pl > 0.0
assert player.stamina > 0.0
assert player.posture != "unconscious"
@pytest.mark.asyncio
async def test_come_to_message(clear_state, mock_writer):
"""Test that 'come to' message is sent on recovery."""
player = Player(
name="TestPlayer", x=0, y=0, pl=0.0, stamina=0.0, writer=mock_writer
)
players["TestPlayer"] = player
# Clear any initial messages
mock_writer.written.clear()
# Process recovery
await process_unconscious()
# Check for come to message
assert any("come to" in msg.lower() for msg in mock_writer.written)
@pytest.mark.asyncio
async def test_snap_neck_requires_unconscious(clear_state):
"""Test that snap neck only works on unconscious target."""
from mudlib.commands.snapneck import cmd_snap_neck
attacker_writer = MockWriter()
defender_writer = MockWriter()
attacker = Player(name="Attacker", x=0, y=0, writer=attacker_writer)
defender = Player(name="Defender", x=0, y=0, writer=defender_writer, pl=100.0)
players["Attacker"] = attacker
players["Defender"] = defender
# Start encounter first
start_encounter(attacker, defender)
# Try to snap neck on conscious target
attacker_writer.written.clear()
await cmd_snap_neck(attacker, "Defender")
# Should fail with "not unconscious" message
assert any("unconscious" in msg.lower() for msg in attacker_writer.written)
@pytest.mark.asyncio
async def test_snap_neck_success(clear_state):
"""Test that snap neck works on unconscious target."""
from mudlib.commands.snapneck import cmd_snap_neck
attacker_writer = MockWriter()
defender_writer = MockWriter()
attacker = Player(name="Attacker", x=0, y=0, writer=attacker_writer)
defender = Player(name="Defender", x=0, y=0, writer=defender_writer, pl=0.0)
players["Attacker"] = attacker
players["Defender"] = defender
# Start encounter
encounter = start_encounter(attacker, defender)
# Clear messages
attacker_writer.written.clear()
defender_writer.written.clear()
# Snap neck on unconscious target
await cmd_snap_neck(attacker, "Defender")
# Should succeed with dramatic message
assert any("snap" in msg.lower() for msg in attacker_writer.written)
# Encounter should end
assert encounter not in active_encounters
@pytest.mark.asyncio
async def test_snap_neck_message_sent_to_both(clear_state):
"""Test that snap neck message is sent to both parties."""
from mudlib.commands.snapneck import cmd_snap_neck
attacker_writer = MockWriter()
defender_writer = MockWriter()
attacker = Player(name="Attacker", x=0, y=0, writer=attacker_writer)
defender = Player(name="Defender", x=0, y=0, writer=defender_writer, pl=0.0)
players["Attacker"] = attacker
players["Defender"] = defender
# Start encounter
start_encounter(attacker, defender)
# Clear messages
attacker_writer.written.clear()
defender_writer.written.clear()
# Snap neck
await cmd_snap_neck(attacker, "Defender")
# Both should receive messages
assert len(attacker_writer.written) > 0
assert len(defender_writer.written) > 0
@pytest.mark.asyncio
async def test_knockout_ends_combat(clear_state, mock_writer):
"""Test that combat ends when a player is knocked out."""
from mudlib.combat.encounter import CombatEncounter, CombatState
attacker = Player(name="Attacker", x=0, y=0, writer=mock_writer)
defender = Player(name="Defender", x=0, y=0, writer=mock_writer, pl=1.0)
# Create a simple punch move for testing
move = CombatMove(
name="test punch",
command="testpunch",
move_type="attack",
damage_pct=0.5,
stamina_cost=10.0,
timing_window_ms=850,
countered_by=[],
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.state = CombatState.RESOLVE
encounter.current_move = move
# Resolve should detect knockout
result = encounter.resolve()
# Combat should end
assert result.combat_ended
@pytest.mark.asyncio
async def test_partial_recovery(clear_state, mock_writer):
"""Test that only PL or stamina being above 0 doesn't wake player."""
player = Player(
name="TestPlayer", x=0, y=0, pl=5.0, stamina=0.0, writer=mock_writer
)
players["TestPlayer"] = player
# Player should be unconscious (stamina is 0)
assert player.posture == "unconscious"
# Process recovery - only stamina should increase
await process_unconscious()
# Check that PL wasn't affected (started at 5.0, should stay at 5.0)
assert player.pl == 5.0
# Stamina should have increased
assert player.stamina > 0.0
# Player should now be conscious (both > 0)
assert player.posture != "unconscious"