mud/tests/test_unlock_system.py
Jared Miller 8e9e6f8245
Add unlock conditions to roundhouse and sweep moves
Roundhouse requires 5 total kills, sweep requires defeating
3 goblins. Basic moves (punch, dodge, duck, parry, jump)
remain available from the start.
2026-02-14 11:40:46 -05:00

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