Wire stamina cues into combat and power loops

This commit is contained in:
Jared Miller 2026-02-14 00:29:50 -05:00
parent 894a0b7396
commit 8bb87965d7
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
7 changed files with 308 additions and 2 deletions

View file

@ -7,6 +7,7 @@ from pathlib import Path
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter, start_encounter from mudlib.combat.engine import get_encounter, start_encounter
from mudlib.combat.moves import CombatMove, load_moves from mudlib.combat.moves import CombatMove, load_moves
from mudlib.combat.stamina import check_stamina_cues
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.render.colors import colorize from mudlib.render.colors import colorize
@ -107,6 +108,9 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
send_char_vitals(player) send_char_vitals(player)
# Check stamina cues after attack cost
await check_stamina_cues(player)
if switching: if switching:
await player.send(f"You switch to {move.name}!\r\n") await player.send(f"You switch to {move.name}!\r\n")
else: else:
@ -136,6 +140,9 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
send_char_vitals(player) send_char_vitals(player)
# Check stamina cues after defense cost
await check_stamina_cues(player)
# If in combat, queue the defense on the encounter # If in combat, queue the defense on the encounter
encounter = get_encounter(player) encounter = get_encounter(player)
if encounter is not None: if encounter is not None:

View file

@ -3,6 +3,7 @@
import time import time
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
from mudlib.combat.stamina import check_stamina_cues
from mudlib.entity import Entity, Mob from mudlib.entity import Entity, Mob
from mudlib.gmcp import send_char_status, send_char_vitals from mudlib.gmcp import send_char_status, send_char_vitals
from mudlib.render.colors import colorize from mudlib.render.colors import colorize
@ -154,6 +155,10 @@ async def process_combat() -> None:
if isinstance(entity, Player): if isinstance(entity, Player):
send_char_vitals(entity) 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: if result.combat_ended:
# Determine winner/loser # Determine winner/loser
if encounter.defender.pl <= 0: if encounter.defender.pl <= 0:

View file

@ -65,8 +65,9 @@ async def check_stamina_cues(entity: Entity) -> None:
# Send self-directed message # Send self-directed message
await entity.send(f"{self_msg}\r\n") await entity.send(f"{self_msg}\r\n")
# Broadcast to nearby players # Broadcast to nearby players (only if entity has a location)
await send_nearby_message(entity, entity.x, entity.y, f"{nearby_msg}\r\n") if entity.location is not None:
await send_nearby_message(entity, entity.x, entity.y, f"{nearby_msg}\r\n")
# Update tracking # Update tracking
entity._last_stamina_cue = current_threshold entity._last_stamina_cue = current_threshold

View file

@ -3,6 +3,7 @@
import asyncio import asyncio
from mudlib.combat.engine import get_encounter from mudlib.combat.engine import get_encounter
from mudlib.combat.stamina import check_stamina_cues
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.commands.movement import send_nearby_message from mudlib.commands.movement import send_nearby_message
from mudlib.gmcp import send_char_vitals from mudlib.gmcp import send_char_vitals
@ -57,6 +58,9 @@ async def power_up_loop(player: Player, target_pl: float | None = None) -> None:
# Send GMCP update # Send GMCP update
send_char_vitals(player) send_char_vitals(player)
# Check stamina cues after deduction
await check_stamina_cues(player)
# Send periodic aura message (every ~0.2s) # Send periodic aura message (every ~0.2s)
if int(old_pl / 20) != int(player.pl / 20): if int(old_pl / 20) != int(player.pl / 20):
await player.send("Your aura flares!\r\n") await player.send("Your aura flares!\r\n")

View file

@ -30,6 +30,7 @@ async def process_resting() -> None:
was_sleeping = player.sleeping was_sleeping = player.sleeping
player.resting = False player.resting = False
player.sleeping = False player.sleeping = False
player._last_stamina_cue = 1.0 # Reset stamina cues on full recovery
send_char_status(player) send_char_status(player)
send_char_vitals(player) send_char_vitals(player)
message = ( message = (

View file

@ -34,6 +34,7 @@ async def process_unconscious() -> None:
# Check if player is now conscious # Check if player is now conscious
if was_unconscious and player.pl > 0 and player.stamina > 0: if was_unconscious and player.pl > 0 and player.stamina > 0:
# Player regained consciousness # Player regained consciousness
player._last_stamina_cue = 1.0 # Reset stamina cues on recovery
send_char_status(player) send_char_status(player)
send_char_vitals(player) send_char_vitals(player)
await player.send("You come to.\r\n") await player.send("You come to.\r\n")

View file

@ -0,0 +1,287 @@
"""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 mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def test_zone():
return Zone(name="test", width=10, height=10)
@pytest.fixture
def player(mock_writer, test_zone):
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):
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