Add unconscious state with automatic recovery
Players become unconscious when PL or stamina drops to 0. While unconscious, both stats slowly recover at 0.1 per tick (1.0 per second). When both reach above 0, player regains consciousness with a message. Recovery runs in the main game loop via process_unconscious.
This commit is contained in:
parent
d6d62abdb8
commit
b4dea1d349
4 changed files with 308 additions and 1 deletions
|
|
@ -27,6 +27,7 @@ import mudlib.commands.portals
|
||||||
import mudlib.commands.power
|
import mudlib.commands.power
|
||||||
import mudlib.commands.quit
|
import mudlib.commands.quit
|
||||||
import mudlib.commands.reload
|
import mudlib.commands.reload
|
||||||
|
import mudlib.commands.snapneck
|
||||||
import mudlib.commands.spawn
|
import mudlib.commands.spawn
|
||||||
import mudlib.commands.things
|
import mudlib.commands.things
|
||||||
import mudlib.commands.use
|
import mudlib.commands.use
|
||||||
|
|
@ -60,6 +61,7 @@ from mudlib.store import (
|
||||||
)
|
)
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
from mudlib.things import load_thing_templates, spawn_thing, thing_templates
|
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.world.terrain import World
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
from mudlib.zones import get_zone, load_zones, register_zone
|
from mudlib.zones import get_zone, load_zones, register_zone
|
||||||
|
|
@ -97,6 +99,7 @@ async def game_loop() -> None:
|
||||||
await process_combat()
|
await process_combat()
|
||||||
await process_mobs(mudlib.combat.commands.combat_moves)
|
await process_mobs(mudlib.combat.commands.combat_moves)
|
||||||
await process_resting()
|
await process_resting()
|
||||||
|
await process_unconscious()
|
||||||
|
|
||||||
# MSDP updates once per second (every TICK_RATE ticks)
|
# MSDP updates once per second (every TICK_RATE ticks)
|
||||||
if tick_count % TICK_RATE == 0:
|
if tick_count % TICK_RATE == 0:
|
||||||
|
|
|
||||||
39
src/mudlib/unconscious.py
Normal file
39
src/mudlib/unconscious.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""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
|
||||||
|
send_char_status(player)
|
||||||
|
send_char_vitals(player)
|
||||||
|
await player.send("You come to.\r\n")
|
||||||
|
|
@ -169,7 +169,13 @@ def test_game_loop_exists():
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_game_loop_calls_clear_expired():
|
async def test_game_loop_calls_clear_expired():
|
||||||
"""Game loop calls clear_expired each tick."""
|
"""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())
|
task = asyncio.create_task(server.game_loop())
|
||||||
await asyncio.sleep(0.25)
|
await asyncio.sleep(0.25)
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
|
||||||
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