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:
Jared Miller 2026-02-14 11:27:01 -05:00
parent 085a19a564
commit a2efd16390
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 431 additions and 0 deletions

View file

@ -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")

View file

@ -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

View file

@ -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,
)
]

View 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
View 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"]