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