Wire stamina cues into combat and power loops
This commit is contained in:
parent
894a0b7396
commit
8bb87965d7
7 changed files with 308 additions and 2 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
287
tests/test_stamina_cue_wiring.py
Normal file
287
tests/test_stamina_cue_wiring.py
Normal 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
|
||||||
Loading…
Reference in a new issue