From 14dc2424efaacf63c110670420373ddd7684fb8a Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 11:33:30 -0500 Subject: [PATCH] Show unlock requirements in help for locked moves help displays lock status and what's needed to unlock when the move has an unlock_condition the player hasn't met. --- src/mudlib/commands/help.py | 28 +++++++ tests/test_help_unlock.py | 152 ++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 tests/test_help_unlock.py diff --git a/src/mudlib/commands/help.py b/src/mudlib/commands/help.py index 64f3b26..f53404e 100644 --- a/src/mudlib/commands/help.py +++ b/src/mudlib/commands/help.py @@ -86,6 +86,20 @@ async def _show_single_command( lines = [defn.name] + # Check if move is locked + if move is not None and move.unlock_condition: + base = move.command or move.name + if base not in player.unlocked_moves: + cond = move.unlock_condition + if cond.type == "kill_count": + lock_msg = f"[LOCKED] Defeat {cond.threshold} enemies to unlock." + elif cond.type == "mob_kills": + mob = cond.mob_name + lock_msg = f"[LOCKED] Defeat {cond.threshold} {mob}s to unlock." + else: + lock_msg = "[LOCKED]" + lines.append(f" {lock_msg}") + # Show description first for combat moves (most important context) if move is not None and move.description: lines.append(f" {move.description}") @@ -139,6 +153,20 @@ async def _show_variant_overview( lines = [defn.name] + # Check if any variant is locked + if variants and variants[0].unlock_condition: + base = variants[0].command or variants[0].name.split()[0] + if base not in player.unlocked_moves: + cond = variants[0].unlock_condition + if cond.type == "kill_count": + lock_msg = f"[LOCKED] Defeat {cond.threshold} enemies to unlock." + elif cond.type == "mob_kills": + mob = cond.mob_name + lock_msg = f"[LOCKED] Defeat {cond.threshold} {mob}s to unlock." + else: + lock_msg = "[LOCKED]" + lines.append(f" {lock_msg}") + # Show description from first variant (they all share the same one) if variants and variants[0].description: lines.append(f" {variants[0].description}") diff --git a/tests/test_help_unlock.py b/tests/test_help_unlock.py new file mode 100644 index 0000000..9da27a2 --- /dev/null +++ b/tests/test_help_unlock.py @@ -0,0 +1,152 @@ +"""Tests for help command showing unlock status for combat moves.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from mudlib.combat.moves import CombatMove, UnlockCondition +from mudlib.commands import dispatch, help # noqa: F401 +from mudlib.player import Player, players + + +@pytest.fixture(autouse=True) +def clear_state(): + """Clear players before and after each test.""" + players.clear() + yield + players.clear() + + +@pytest.fixture +def mock_writer(): + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer + + +@pytest.fixture +def player(mock_writer): + return Player(name="Test", writer=mock_writer) + + +@pytest.fixture +def mock_move_kill_count(): + """Mock move with kill_count unlock condition.""" + return CombatMove( + name="roundhouse", + move_type="attack", + stamina_cost=30.0, + timing_window_ms=850, + aliases=["rh"], + description="A powerful spinning kick", + damage_pct=0.35, + unlock_condition=UnlockCondition(type="kill_count", threshold=5), + ) + + +@pytest.fixture +def mock_move_mob_kills(): + """Mock move with mob_kills unlock condition.""" + return CombatMove( + name="goblin slayer", + move_type="attack", + stamina_cost=25.0, + timing_window_ms=800, + description="Specialized technique against goblins", + damage_pct=0.40, + unlock_condition=UnlockCondition( + type="mob_kills", threshold=3, mob_name="goblin" + ), + ) + + +@pytest.fixture +def mock_move_no_unlock(): + """Mock move without unlock condition.""" + return CombatMove( + name="jab", + move_type="attack", + stamina_cost=10.0, + timing_window_ms=600, + description="A quick straight punch", + damage_pct=0.15, + ) + + +@pytest.mark.asyncio +async def test_help_locked_move_shows_kill_count_requirement( + player, mock_move_kill_count +): + """Help for locked move shows kill count requirement.""" + player.unlocked_moves = set() + + moves = {"roundhouse": mock_move_kill_count, "rh": mock_move_kill_count} + with patch("mudlib.combat.commands.combat_moves", moves): + await dispatch(player, "help roundhouse") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + assert "LOCKED" in output or "Locked" in output + assert "5" in output + assert "enemies" in output.lower() or "kills" in output.lower() + + +@pytest.mark.asyncio +async def test_help_locked_move_shows_mob_kills_requirement( + player, mock_move_mob_kills +): + """Help for locked move shows mob-specific kill requirement.""" + player.unlocked_moves = set() + + moves = {"goblin slayer": mock_move_mob_kills} + with patch("mudlib.combat.commands.combat_moves", moves): + await dispatch(player, "help goblin slayer") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + assert "LOCKED" in output or "Locked" in output + assert "3" in output + assert "goblin" in output.lower() + + +@pytest.mark.asyncio +async def test_help_unlocked_move_no_lock_notice(player, mock_move_kill_count): + """Help for unlocked move shows normal help without lock notice.""" + # Add move to unlocked_moves + player.unlocked_moves = {"roundhouse"} + + moves = {"roundhouse": mock_move_kill_count, "rh": mock_move_kill_count} + with patch("mudlib.combat.commands.combat_moves", moves): + await dispatch(player, "help roundhouse") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + assert "LOCKED" not in output and "Locked" not in output + assert "roundhouse" in output + assert "A powerful spinning kick" in output + + +@pytest.mark.asyncio +async def test_help_move_without_unlock_condition(player, mock_move_no_unlock): + """Help for move without unlock condition shows normal help.""" + player.unlocked_moves = set() + + with patch("mudlib.combat.commands.combat_moves", {"jab": mock_move_no_unlock}): + await dispatch(player, "help jab") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + assert "LOCKED" not in output and "Locked" not in output + assert "jab" in output + assert "A quick straight punch" in output + + +@pytest.mark.asyncio +async def test_help_locked_move_via_alias(player, mock_move_kill_count): + """Help via alias for locked move shows unlock requirement.""" + player.unlocked_moves = set() + + moves = {"roundhouse": mock_move_kill_count, "rh": mock_move_kill_count} + with patch("mudlib.combat.commands.combat_moves", moves): + await dispatch(player, "help rh") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + assert "LOCKED" in output or "Locked" in output + assert "5" in output