"""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"] def test_content_combat_moves_unlock_conditions(): """Combat moves from content/ have expected unlock conditions.""" from mudlib.combat.moves import load_moves content_dir = Path(__file__).parent.parent / "content" / "combat" moves = load_moves(content_dir) # Roundhouse has kill_count unlock (5 total kills) roundhouse = moves.get("roundhouse") assert roundhouse is not None assert roundhouse.unlock_condition is not None assert roundhouse.unlock_condition.type == "kill_count" assert roundhouse.unlock_condition.threshold == 5 # Sweep has mob_kills unlock (3 goblins) sweep = moves.get("sweep") assert sweep is not None assert sweep.unlock_condition is not None assert sweep.unlock_condition.type == "mob_kills" assert sweep.unlock_condition.mob_name == "goblin" assert sweep.unlock_condition.threshold == 3 # Punch (variants) are starter moves with no unlock punch_left = moves.get("punch left") punch_right = moves.get("punch right") assert punch_left is not None assert punch_left.unlock_condition is None assert punch_right is not None assert punch_right.unlock_condition is None