Roundhouse requires 5 total kills, sweep requires defeating 3 goblins. Basic moves (punch, dodge, duck, parry, jump) remain available from the start.
376 lines
12 KiB
Python
376 lines
12 KiB
Python
"""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
|