Add skill unlock system with TOML conditions and gating
UnlockCondition on CombatMove, parsed from [unlock] TOML section. check_unlocks evaluates kill_count and mob_kills thresholds. Locked moves rejected with "You haven't learned that yet." in do_attack/do_defend. New unlocks announced after kills.
This commit is contained in:
parent
085a19a564
commit
a2efd16390
5 changed files with 431 additions and 0 deletions
|
|
@ -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)
|
target_args: Remaining args after move resolution (just the target name)
|
||||||
move: The resolved combat move
|
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)
|
encounter = get_encounter(player)
|
||||||
|
|
||||||
# Parse target from args
|
# 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)
|
_args: Unused (defense moves don't take a target)
|
||||||
move: The resolved combat move
|
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
|
# Check stamina
|
||||||
if player.stamina < move.stamina_cost:
|
if player.stamina < move.stamina_cost:
|
||||||
await player.send("You don't have enough stamina for that move.\r\n")
|
await player.send("You don't have enough stamina for that move.\r\n")
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,14 @@ async def process_combat() -> None:
|
||||||
winner.mob_kills.get(loser.name, 0) + 1
|
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):
|
if isinstance(loser, Player):
|
||||||
loser.deaths += 1
|
loser.deaths += 1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@ log = logging.getLogger(__name__)
|
||||||
CommandHandler = Callable[..., Any]
|
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
|
@dataclass
|
||||||
class CombatMove:
|
class CombatMove:
|
||||||
"""Defines a combat move with its properties and counters."""
|
"""Defines a combat move with its properties and counters."""
|
||||||
|
|
@ -37,6 +46,7 @@ class CombatMove:
|
||||||
telegraph_color: str = "dim" # color tag for telegraph
|
telegraph_color: str = "dim" # color tag for telegraph
|
||||||
announce_color: str = "" # color tag for announce (default/none)
|
announce_color: str = "" # color tag for announce (default/none)
|
||||||
resolve_color: str = "bold" # color tag for resolve
|
resolve_color: str = "bold" # color tag for resolve
|
||||||
|
unlock_condition: UnlockCondition | None = None
|
||||||
|
|
||||||
|
|
||||||
def load_move(path: Path) -> list[CombatMove]:
|
def load_move(path: Path) -> list[CombatMove]:
|
||||||
|
|
@ -68,6 +78,16 @@ def load_move(path: Path) -> list[CombatMove]:
|
||||||
base_name = data["name"]
|
base_name = data["name"]
|
||||||
variants = data.get("variants")
|
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:
|
if variants:
|
||||||
moves = []
|
moves = []
|
||||||
for variant_key, variant_data in variants.items():
|
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=variant_data.get(
|
||||||
"resolve_color", data.get("resolve_color", "bold")
|
"resolve_color", data.get("resolve_color", "bold")
|
||||||
),
|
),
|
||||||
|
unlock_condition=unlock_condition,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return moves
|
return moves
|
||||||
|
|
@ -133,6 +154,7 @@ def load_move(path: Path) -> list[CombatMove]:
|
||||||
telegraph_color=data.get("telegraph_color", "dim"),
|
telegraph_color=data.get("telegraph_color", "dim"),
|
||||||
announce_color=data.get("announce_color", ""),
|
announce_color=data.get("announce_color", ""),
|
||||||
resolve_color=data.get("resolve_color", "bold"),
|
resolve_color=data.get("resolve_color", "bold"),
|
||||||
|
unlock_condition=unlock_condition,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
44
src/mudlib/combat/unlock.py
Normal file
44
src/mudlib/combat/unlock.py
Normal file
|
|
@ -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
|
||||||
345
tests/test_unlock_system.py
Normal file
345
tests/test_unlock_system.py
Normal file
|
|
@ -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"]
|
||||||
Loading…
Reference in a new issue