diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index e97fcb1..fc5abab 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -26,6 +26,12 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: target_args: Remaining args after move resolution (just the target name) move: The resolved combat move """ + # Check unlock gating + base_command = move.command or move.name + if move.unlock_condition is not None and base_command not in player.unlocked_moves: + await player.send("You haven't learned that yet.\r\n") + return + encounter = get_encounter(player) # Parse target from args @@ -124,6 +130,12 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None: _args: Unused (defense moves don't take a target) move: The resolved combat move """ + # Check unlock gating + base_command = move.command or move.name + if move.unlock_condition is not None and base_command not in player.unlocked_moves: + await player.send("You haven't learned that yet.\r\n") + return + # Check stamina if player.stamina < move.stamina_cost: await player.send("You don't have enough stamina for that move.\r\n") diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py index c803d92..98e5adf 100644 --- a/src/mudlib/combat/engine.py +++ b/src/mudlib/combat/engine.py @@ -174,6 +174,14 @@ async def process_combat() -> None: winner.mob_kills.get(loser.name, 0) + 1 ) + # Check for new unlocks + from mudlib.combat.commands import combat_moves + from mudlib.combat.unlock import check_unlocks + + newly_unlocked = check_unlocks(winner, combat_moves) + for move_name in newly_unlocked: + await winner.send(f"You have learned {move_name}!\r\n") + if isinstance(loser, Player): loser.deaths += 1 diff --git a/src/mudlib/combat/moves.py b/src/mudlib/combat/moves.py index 57a4a4c..2058e39 100644 --- a/src/mudlib/combat/moves.py +++ b/src/mudlib/combat/moves.py @@ -12,6 +12,15 @@ log = logging.getLogger(__name__) CommandHandler = Callable[..., Any] +@dataclass +class UnlockCondition: + """Condition that must be met to unlock a combat move.""" + + type: str # "kill_count" or "mob_kills" + threshold: int = 0 + mob_name: str = "" # for mob_kills type + + @dataclass class CombatMove: """Defines a combat move with its properties and counters.""" @@ -37,6 +46,7 @@ class CombatMove: telegraph_color: str = "dim" # color tag for telegraph announce_color: str = "" # color tag for announce (default/none) resolve_color: str = "bold" # color tag for resolve + unlock_condition: UnlockCondition | None = None def load_move(path: Path) -> list[CombatMove]: @@ -68,6 +78,16 @@ def load_move(path: Path) -> list[CombatMove]: base_name = data["name"] variants = data.get("variants") + # Parse optional unlock condition + unlock_data = data.get("unlock") + unlock_condition = None + if unlock_data: + unlock_condition = UnlockCondition( + type=unlock_data["type"], + threshold=unlock_data.get("threshold", 0), + mob_name=unlock_data.get("mob_name", ""), + ) + if variants: moves = [] for variant_key, variant_data in variants.items(): @@ -108,6 +128,7 @@ def load_move(path: Path) -> list[CombatMove]: resolve_color=variant_data.get( "resolve_color", data.get("resolve_color", "bold") ), + unlock_condition=unlock_condition, ) ) return moves @@ -133,6 +154,7 @@ def load_move(path: Path) -> list[CombatMove]: telegraph_color=data.get("telegraph_color", "dim"), announce_color=data.get("announce_color", ""), resolve_color=data.get("resolve_color", "bold"), + unlock_condition=unlock_condition, ) ] diff --git a/src/mudlib/combat/unlock.py b/src/mudlib/combat/unlock.py new file mode 100644 index 0000000..376a321 --- /dev/null +++ b/src/mudlib/combat/unlock.py @@ -0,0 +1,44 @@ +"""Skill unlock checking.""" + +from mudlib.combat.moves import CombatMove +from mudlib.player import Player + + +def check_unlocks(player: Player, moves: dict[str, CombatMove]) -> list[str]: + """Check if player has met unlock conditions for any locked moves. + + Returns list of newly unlocked move names (base command names, not variants). + """ + newly_unlocked = [] + + # Deduplicate by base command (variants share unlock condition) + seen_commands = set() + + for move in moves.values(): + if move.unlock_condition is None: + continue + + # Use base command name for unlock tracking + base = move.command or move.name + if base in seen_commands: + continue + seen_commands.add(base) + + # Already unlocked + if base in player.unlocked_moves: + continue + + condition = move.unlock_condition + unlocked = False + + if condition.type == "kill_count": + unlocked = player.kills >= condition.threshold + elif condition.type == "mob_kills": + count = player.mob_kills.get(condition.mob_name, 0) + unlocked = count >= condition.threshold + + if unlocked: + player.unlocked_moves.add(base) + newly_unlocked.append(base) + + return newly_unlocked diff --git a/tests/test_unlock_system.py b/tests/test_unlock_system.py new file mode 100644 index 0000000..0b67efc --- /dev/null +++ b/tests/test_unlock_system.py @@ -0,0 +1,345 @@ +"""Tests for the skill unlock system.""" + +from pathlib import Path +from tempfile import NamedTemporaryFile +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.combat.moves import CombatMove, UnlockCondition, load_move +from mudlib.combat.unlock import check_unlocks +from mudlib.player import Player + + +def _mock_writer(): + """Create a mock writer with all required attributes.""" + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + writer.send_gmcp = MagicMock() + # Add local_option and remote_option for gmcp_enabled property + writer.local_option = MagicMock() + writer.remote_option = MagicMock() + writer.local_option.enabled = MagicMock(return_value=False) + writer.remote_option.enabled = MagicMock(return_value=False) + return writer + + +def test_unlock_condition_dataclass(): + """UnlockCondition stores type, threshold, and mob_name.""" + condition = UnlockCondition(type="kill_count", threshold=5) + assert condition.type == "kill_count" + assert condition.threshold == 5 + assert condition.mob_name == "" + + +def test_combat_move_with_unlock_condition(): + """CombatMove can have an unlock_condition field.""" + condition = UnlockCondition(type="kill_count", threshold=10) + move = CombatMove( + name="roundhouse", + move_type="attack", + stamina_cost=20.0, + timing_window_ms=500, + unlock_condition=condition, + ) + assert move.unlock_condition is condition + assert move.unlock_condition.threshold == 10 + + +def test_combat_move_unlock_condition_defaults_to_none(): + """CombatMove.unlock_condition defaults to None when not provided.""" + move = CombatMove( + name="punch", + move_type="attack", + stamina_cost=10.0, + timing_window_ms=300, + ) + assert move.unlock_condition is None + + +def test_check_unlocks_grants_move_on_kill_threshold(): + """check_unlocks adds move to unlocked_moves when kill threshold met.""" + player = Player(name="test", x=0, y=0) + player.kills = 5 + player.unlocked_moves = set() + + move = CombatMove( + name="roundhouse", + move_type="attack", + stamina_cost=20.0, + timing_window_ms=500, + command="roundhouse", + unlock_condition=UnlockCondition(type="kill_count", threshold=5), + ) + moves = {"roundhouse": move} + + newly_unlocked = check_unlocks(player, moves) + + assert "roundhouse" in player.unlocked_moves + assert newly_unlocked == ["roundhouse"] + + +def test_check_unlocks_grants_move_on_mob_kills_threshold(): + """check_unlocks unlocks move when specific mob kill count met.""" + player = Player(name="test", x=0, y=0) + player.mob_kills = {"goblin": 3} + player.unlocked_moves = set() + + move = CombatMove( + name="goblin_slayer", + move_type="attack", + stamina_cost=15.0, + timing_window_ms=400, + command="goblin_slayer", + unlock_condition=UnlockCondition( + type="mob_kills", mob_name="goblin", threshold=3 + ), + ) + moves = {"goblin_slayer": move} + + newly_unlocked = check_unlocks(player, moves) + + assert "goblin_slayer" in player.unlocked_moves + assert newly_unlocked == ["goblin_slayer"] + + +def test_check_unlocks_does_not_unlock_when_threshold_not_met(): + """check_unlocks does not add move when kill threshold not reached.""" + player = Player(name="test", x=0, y=0) + player.kills = 2 + player.unlocked_moves = set() + + move = CombatMove( + name="roundhouse", + move_type="attack", + stamina_cost=20.0, + timing_window_ms=500, + command="roundhouse", + unlock_condition=UnlockCondition(type="kill_count", threshold=5), + ) + moves = {"roundhouse": move} + + newly_unlocked = check_unlocks(player, moves) + + assert "roundhouse" not in player.unlocked_moves + assert newly_unlocked == [] + + +def test_check_unlocks_returns_empty_list_if_nothing_new(): + """check_unlocks returns empty list when no new unlocks.""" + player = Player(name="test", x=0, y=0) + player.kills = 10 + player.unlocked_moves = {"roundhouse"} # already unlocked + + move = CombatMove( + name="roundhouse", + move_type="attack", + stamina_cost=20.0, + timing_window_ms=500, + command="roundhouse", + unlock_condition=UnlockCondition(type="kill_count", threshold=5), + ) + moves = {"roundhouse": move} + + newly_unlocked = check_unlocks(player, moves) + + assert newly_unlocked == [] + + +def test_load_toml_with_unlock_condition(): + """load_move parses [unlock] section from TOML.""" + toml_content = """ +name = "roundhouse" +move_type = "attack" +stamina_cost = 20.0 +timing_window_ms = 500 + +[unlock] +type = "kill_count" +threshold = 5 +""" + with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + temp_path = Path(f.name) + + try: + moves = load_move(temp_path) + assert len(moves) == 1 + move = moves[0] + assert move.unlock_condition is not None + assert move.unlock_condition.type == "kill_count" + assert move.unlock_condition.threshold == 5 + assert move.unlock_condition.mob_name == "" + finally: + temp_path.unlink() + + +def test_load_toml_without_unlock_section(): + """load_move sets unlock_condition to None when no [unlock] section.""" + toml_content = """ +name = "punch" +move_type = "attack" +stamina_cost = 10.0 +timing_window_ms = 300 +""" + with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + temp_path = Path(f.name) + + try: + moves = load_move(temp_path) + assert len(moves) == 1 + move = moves[0] + assert move.unlock_condition is None + finally: + temp_path.unlink() + + +def test_load_toml_with_mob_kills_unlock(): + """load_move parses mob_kills unlock condition with mob_name.""" + toml_content = """ +name = "goblin_slayer" +move_type = "attack" +stamina_cost = 15.0 +timing_window_ms = 400 + +[unlock] +type = "mob_kills" +mob_name = "goblin" +threshold = 3 +""" + with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + temp_path = Path(f.name) + + try: + moves = load_move(temp_path) + assert len(moves) == 1 + move = moves[0] + assert move.unlock_condition is not None + assert move.unlock_condition.type == "mob_kills" + assert move.unlock_condition.mob_name == "goblin" + assert move.unlock_condition.threshold == 3 + finally: + temp_path.unlink() + + +@pytest.mark.asyncio +async def test_gating_locked_move_rejected_in_do_attack(): + """Locked move with unlock_condition rejects player without unlock.""" + from mudlib.combat.commands import do_attack + + writer = _mock_writer() + player = Player(name="test", x=0, y=0, writer=writer) + player.unlocked_moves = set() # no unlocks + + move = CombatMove( + name="roundhouse", + move_type="attack", + stamina_cost=20.0, + timing_window_ms=500, + command="roundhouse", + unlock_condition=UnlockCondition(type="kill_count", threshold=5), + ) + + await do_attack(player, "", move) + + # Check that the rejection message was sent + writer.write.assert_called() + calls = [str(call) for call in writer.write.call_args_list] + assert any("You haven't learned that yet." in str(call) for call in calls) + + +@pytest.mark.asyncio +async def test_gating_unlocked_move_works_normally(): + """Unlocked move executes when player has it in unlocked_moves.""" + from mudlib.combat.commands import do_attack + + writer = _mock_writer() + player = Player(name="test", x=0, y=0, writer=writer) + player.unlocked_moves = {"roundhouse"} # unlocked + player.stamina = 100.0 + + move = CombatMove( + name="roundhouse", + move_type="attack", + stamina_cost=20.0, + timing_window_ms=500, + command="roundhouse", + unlock_condition=UnlockCondition(type="kill_count", threshold=5), + ) + + # Try to attack without target (will fail to find target, but won't + # reject for unlock) + await do_attack(player, "", move) + + # Should NOT reject for unlock (gating check passes) + calls = [str(call) for call in writer.write.call_args_list] + assert not any("You haven't learned that yet." in str(call) for call in calls) + # Will get "need a target" message instead + assert any("You need a target" in str(call) for call in calls) + + +@pytest.mark.asyncio +async def test_gating_move_without_unlock_condition_always_works(): + """Moves without unlock_condition work without gating check.""" + from mudlib.combat.commands import do_attack + + writer = _mock_writer() + player = Player(name="test", x=0, y=0, writer=writer) + player.unlocked_moves = set() # empty, but move has no unlock condition + player.stamina = 100.0 + + move = CombatMove( + name="punch", + move_type="attack", + stamina_cost=10.0, + timing_window_ms=300, + command="punch", + unlock_condition=None, # no unlock needed + ) + + # Try to attack without target (will fail to find target, but won't + # reject for unlock) + await do_attack(player, "", move) + + # Should work normally (no unlock rejection) + calls = [str(call) for call in writer.write.call_args_list] + assert not any("You haven't learned that yet." in str(call) for call in calls) + # Will get "need a target" message instead + assert any("You need a target" in str(call) for call in calls) + + +def test_check_unlocks_deduplicates_variants(): + """check_unlocks only checks base command once for variant moves.""" + player = Player(name="test", x=0, y=0) + player.kills = 5 + player.unlocked_moves = set() + + # Two variants of "punch" with same unlock condition + punch_left = CombatMove( + name="punch left", + move_type="attack", + stamina_cost=10.0, + timing_window_ms=300, + command="punch", + variant="left", + unlock_condition=UnlockCondition(type="kill_count", threshold=5), + ) + punch_right = CombatMove( + name="punch right", + move_type="attack", + stamina_cost=10.0, + timing_window_ms=300, + command="punch", + variant="right", + unlock_condition=UnlockCondition(type="kill_count", threshold=5), + ) + moves = {"punch left": punch_left, "punch right": punch_right} + + newly_unlocked = check_unlocks(player, moves) + + # Should only unlock "punch" once, not twice + assert "punch" in player.unlocked_moves + assert newly_unlocked == ["punch"]