Compare commits
19 commits
72b877c5d1
...
be63a1cbde
| Author | SHA1 | Date | |
|---|---|---|---|
| be63a1cbde | |||
| 8bb87965d7 | |||
| 894a0b7396 | |||
| 1dbc3a1c68 | |||
| 8d31eeafaf | |||
| 5629f052a4 | |||
| b4dea1d349 | |||
| d6d62abdb8 | |||
| 4da8d41b45 | |||
| d8cd880b61 | |||
| 36fcbecc12 | |||
| a4c9f31056 | |||
| 2a546a3171 | |||
| afe99ceff5 | |||
| 47534b1514 | |||
| 4e8459df5f | |||
| 15cc0d1ae0 | |||
| 2b21257d26 | |||
| 292557e5fd |
39 changed files with 3042 additions and 67 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
5
content/commands/sleep.toml
Normal file
5
content/commands/sleep.toml
Normal 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"]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
]
|
||||
|
||||
|
|
|
|||
73
src/mudlib/combat/stamina.py
Normal file
73
src/mudlib/combat/stamina.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
212
src/mudlib/commands/power.py
Normal file
212
src/mudlib/commands/power.py
Normal 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"]))
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
43
src/mudlib/commands/sleep.py
Normal file
43
src/mudlib/commands/sleep.py
Normal 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"
|
||||
)
|
||||
100
src/mudlib/commands/snapneck.py
Normal file
100
src/mudlib/commands/snapneck.py
Normal 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)",
|
||||
)
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
105
src/mudlib/render/pov.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
40
src/mudlib/unconscious.py
Normal 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
69
tests/conftest.py
Normal 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
|
||||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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
224
tests/test_combat_zaxis.py
Normal 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!"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
222
tests/test_pov.py
Normal 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
298
tests/test_power.py
Normal 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()
|
||||
|
|
@ -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
48
tests/test_quit.py
Normal 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
|
||||
|
|
@ -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
158
tests/test_sleep.py
Normal 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)
|
||||
282
tests/test_stamina_cue_wiring.py
Normal file
282
tests/test_stamina_cue_wiring.py
Normal 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
231
tests/test_stamina_cues.py
Normal 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
314
tests/test_three_beat.py
Normal 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
259
tests/test_unconscious.py
Normal 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"
|
||||
Loading…
Reference in a new issue