diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 3a4dfc0..65e68b1 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -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: diff --git a/src/mudlib/unconscious.py b/src/mudlib/unconscious.py new file mode 100644 index 0000000..aa6952d --- /dev/null +++ b/src/mudlib/unconscious.py @@ -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") diff --git a/tests/test_server.py b/tests/test_server.py index 60f7cdf..47a8b23 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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() diff --git a/tests/test_unconscious.py b/tests/test_unconscious.py new file mode 100644 index 0000000..46d9d23 --- /dev/null +++ b/tests/test_unconscious.py @@ -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"