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:
Jared Miller 2026-02-14 00:11:32 -05:00
parent d6d62abdb8
commit b4dea1d349
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 308 additions and 1 deletions

View file

@ -27,6 +27,7 @@ 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
@ -60,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
@ -97,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:

39
src/mudlib/unconscious.py Normal file
View 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")

View file

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

259
tests/test_unconscious.py Normal file
View file

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