mud/tests/test_combat_moves.py
Jared Miller edbad4666f
Rework combat state machine
PENDING phase, defense active/recovery windows
2026-02-16 12:17:34 -05:00

485 lines
12 KiB
Python

"""Tests for combat move definitions and TOML loading."""
import pytest
from mudlib.combat.moves import CombatMove, load_move, load_moves
def test_combat_move_dataclass():
"""Test CombatMove can be instantiated with all fields."""
move = CombatMove(
name="punch right",
move_type="attack",
aliases=["pr"],
stamina_cost=5.0,
telegraph="{attacker} winds up a right hook!",
hit_time_ms=800,
damage_pct=0.15,
countered_by=["dodge left", "parry high"],
command="punch",
variant="right",
)
assert move.name == "punch right"
assert move.move_type == "attack"
assert move.aliases == ["pr"]
assert move.stamina_cost == 5.0
assert move.telegraph == "{attacker} winds up a right hook!"
assert move.hit_time_ms == 800
assert move.damage_pct == 0.15
assert move.countered_by == ["dodge left", "parry high"]
assert move.handler is None
assert move.command == "punch"
assert move.variant == "right"
def test_combat_move_minimal():
"""Test CombatMove with minimal required fields."""
move = CombatMove(
name="test move",
move_type="attack",
stamina_cost=10.0,
hit_time_ms=500,
)
assert move.name == "test move"
assert move.move_type == "attack"
assert move.aliases == []
assert move.stamina_cost == 10.0
assert move.telegraph == ""
assert move.hit_time_ms == 500
assert move.damage_pct == 0.0
assert move.countered_by == []
assert move.command == ""
assert move.variant == ""
def test_load_simple_move_from_toml(tmp_path):
"""Test loading a simple combat move from TOML file."""
toml_content = """
name = "roundhouse"
aliases = ["rh"]
move_type = "attack"
stamina_cost = 8.0
telegraph = "{attacker} spins into a roundhouse kick!"
hit_time_ms = 600
damage_pct = 0.25
countered_by = ["duck", "parry high", "parry low"]
"""
toml_file = tmp_path / "roundhouse.toml"
toml_file.write_text(toml_content)
moves = load_move(toml_file)
assert len(moves) == 1
move = moves[0]
assert move.name == "roundhouse"
assert move.command == "roundhouse"
assert move.variant == ""
assert move.aliases == ["rh"]
assert move.damage_pct == 0.25
def test_load_variant_move_from_toml(tmp_path):
"""Test loading a variant combat move from TOML file."""
toml_content = """
name = "punch"
move_type = "attack"
stamina_cost = 5.0
hit_time_ms = 800
damage_pct = 0.15
[variants.left]
aliases = ["pl"]
telegraph = "{attacker} winds up a left hook!"
countered_by = ["dodge right", "parry high"]
[variants.right]
aliases = ["pr"]
telegraph = "{attacker} winds up a right hook!"
countered_by = ["dodge left", "parry high"]
"""
toml_file = tmp_path / "punch.toml"
toml_file.write_text(toml_content)
moves = load_move(toml_file)
assert len(moves) == 2
by_name = {m.name: m for m in moves}
assert "punch left" in by_name
assert "punch right" in by_name
left = by_name["punch left"]
assert left.command == "punch"
assert left.variant == "left"
assert left.aliases == ["pl"]
assert left.telegraph == "{attacker} winds up a left hook!"
assert left.countered_by == ["dodge right", "parry high"]
# Inherited from parent
assert left.stamina_cost == 5.0
assert left.hit_time_ms == 800
assert left.damage_pct == 0.15
right = by_name["punch right"]
assert right.command == "punch"
assert right.variant == "right"
assert right.aliases == ["pr"]
assert right.countered_by == ["dodge left", "parry high"]
def test_variant_inherits_shared_properties(tmp_path):
"""Test that variants inherit shared properties and can override them."""
toml_content = """
name = "kick"
move_type = "attack"
stamina_cost = 5.0
hit_time_ms = 800
damage_pct = 0.10
[variants.low]
aliases = ["kl"]
damage_pct = 0.08
hit_time_ms = 600
[variants.high]
aliases = ["kh"]
damage_pct = 0.15
"""
toml_file = tmp_path / "kick.toml"
toml_file.write_text(toml_content)
moves = load_move(toml_file)
by_name = {m.name: m for m in moves}
low = by_name["kick low"]
assert low.damage_pct == 0.08
assert low.hit_time_ms == 600 # overridden
assert low.stamina_cost == 5.0 # inherited
high = by_name["kick high"]
assert high.damage_pct == 0.15
assert high.hit_time_ms == 800 # inherited
assert high.stamina_cost == 5.0 # inherited
def test_load_move_with_defaults(tmp_path):
"""Test loading move with only required fields uses defaults."""
toml_content = """
name = "basic move"
move_type = "defense"
stamina_cost = 3.0
active_ms = 600
recovery_ms = 2700
"""
toml_file = tmp_path / "basic.toml"
toml_file.write_text(toml_content)
moves = load_move(toml_file)
assert len(moves) == 1
move = moves[0]
assert move.name == "basic move"
assert move.aliases == []
assert move.telegraph == ""
assert move.damage_pct == 0.0
assert move.countered_by == []
def test_load_move_missing_name(tmp_path):
"""Test loading move without name raises error."""
toml_content = """
move_type = "attack"
stamina_cost = 5.0
hit_time_ms = 800
"""
toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content)
with pytest.raises(ValueError, match="missing required field.*name"):
load_move(toml_file)
def test_load_move_missing_move_type(tmp_path):
"""Test loading move without move_type raises error."""
toml_content = """
name = "test"
stamina_cost = 5.0
timing_window_ms = 800
"""
toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content)
with pytest.raises(ValueError, match="missing required field.*move_type"):
load_move(toml_file)
def test_load_move_missing_stamina_cost(tmp_path):
"""Test loading move without stamina_cost raises error."""
toml_content = """
name = "test"
move_type = "attack"
hit_time_ms = 800
"""
toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content)
with pytest.raises(ValueError, match="missing required field.*stamina_cost"):
load_move(toml_file)
def test_load_move_missing_hit_time(tmp_path):
"""Test loading move without hit_time_ms defaults to 0."""
toml_content = """
name = "test"
move_type = "attack"
stamina_cost = 5.0
"""
toml_file = tmp_path / "bad.toml"
toml_file.write_text(toml_content)
moves = load_move(toml_file)
assert len(moves) == 1
assert moves[0].hit_time_ms == 0
def test_load_moves_from_directory(tmp_path):
"""Test loading all moves from a directory."""
# Create a variant move
punch_toml = tmp_path / "punch.toml"
punch_toml.write_text(
"""
name = "punch"
move_type = "attack"
stamina_cost = 5.0
hit_time_ms = 800
damage_pct = 0.15
[variants.right]
aliases = ["pr"]
telegraph = "{attacker} winds up a right hook!"
countered_by = ["dodge left"]
"""
)
# Create a simple move
dodge_toml = tmp_path / "dodge.toml"
dodge_toml.write_text(
"""
name = "duck"
move_type = "defense"
stamina_cost = 3.0
active_ms = 500
recovery_ms = 2700
"""
)
# Create a non-TOML file that should be ignored
other_file = tmp_path / "readme.txt"
other_file.write_text("This should be ignored")
moves = load_moves(tmp_path)
# Should have entries for variant qualified names, aliases, and simple moves
assert "punch right" in moves
assert "pr" in moves
assert "duck" in moves
# Aliases should point to the same object
assert moves["punch right"] is moves["pr"]
# Check variant properties
assert moves["punch right"].command == "punch"
assert moves["punch right"].variant == "right"
assert moves["duck"].command == "duck"
assert moves["duck"].variant == ""
def test_load_moves_empty_directory(tmp_path):
"""Test loading from empty directory returns empty dict."""
moves = load_moves(tmp_path)
assert moves == {}
def test_load_moves_alias_collision(tmp_path):
"""Test that duplicate aliases are detected."""
# Create two moves with same alias
move1_toml = tmp_path / "move1.toml"
move1_toml.write_text(
"""
name = "move one"
aliases = ["m"]
move_type = "attack"
stamina_cost = 5.0
hit_time_ms = 800
"""
)
move2_toml = tmp_path / "move2.toml"
move2_toml.write_text(
"""
name = "move two"
aliases = ["m"]
move_type = "defense"
stamina_cost = 3.0
active_ms = 500
recovery_ms = 2700
"""
)
with pytest.raises(ValueError, match="duplicate.*alias.*m"):
load_moves(tmp_path)
def test_load_moves_name_collision(tmp_path):
"""Test that duplicate move names are detected."""
move1_toml = tmp_path / "move1.toml"
move1_toml.write_text(
"""
name = "punch"
move_type = "attack"
stamina_cost = 5.0
hit_time_ms = 800
"""
)
move2_toml = tmp_path / "move2.toml"
move2_toml.write_text(
"""
name = "punch"
move_type = "attack"
stamina_cost = 5.0
hit_time_ms = 800
"""
)
with pytest.raises(ValueError, match="duplicate.*name.*punch"):
load_moves(tmp_path)
def test_load_moves_validates_countered_by_refs(tmp_path, caplog):
"""Test that invalid countered_by references log warnings."""
import logging
punch_toml = tmp_path / "punch.toml"
punch_toml.write_text(
"""
name = "punch"
move_type = "attack"
stamina_cost = 5.0
hit_time_ms = 800
damage_pct = 0.15
[variants.right]
countered_by = ["dodge left", "nonexistent move"]
"""
)
dodge_toml = tmp_path / "dodge.toml"
dodge_toml.write_text(
"""
name = "dodge"
move_type = "defense"
stamina_cost = 3.0
active_ms = 500
recovery_ms = 2700
[variants.left]
aliases = ["dl"]
"""
)
with caplog.at_level(logging.WARNING):
moves = load_moves(tmp_path)
# Should still load successfully
assert "punch right" in moves
assert "dodge left" in moves
# Should have logged a warning about the invalid reference
assert any("nonexistent move" in record.message for record in caplog.records)
assert any("punch right" in record.message for record in caplog.records)
def test_load_moves_valid_countered_by_refs_no_warning(tmp_path, caplog):
"""Test that valid countered_by references don't log warnings."""
import logging
punch_toml = tmp_path / "punch.toml"
punch_toml.write_text(
"""
name = "punch"
move_type = "attack"
stamina_cost = 5.0
hit_time_ms = 800
damage_pct = 0.15
[variants.right]
countered_by = ["dodge left", "parry high"]
"""
)
dodge_toml = tmp_path / "dodge.toml"
dodge_toml.write_text(
"""
name = "dodge"
move_type = "defense"
stamina_cost = 3.0
active_ms = 500
recovery_ms = 2700
[variants.left]
aliases = ["dl"]
"""
)
parry_toml = tmp_path / "parry.toml"
parry_toml.write_text(
"""
name = "parry"
move_type = "defense"
stamina_cost = 3.0
active_ms = 500
recovery_ms = 2700
[variants.high]
aliases = ["f"]
"""
)
with caplog.at_level(logging.WARNING):
moves = load_moves(tmp_path)
# Should load successfully
assert "punch right" in moves
assert "dodge left" in moves
assert "parry high" in moves
# Should have no warnings
assert len(caplog.records) == 0
def test_load_content_combat_directory():
"""Test loading the actual content/combat directory."""
from pathlib import Path
content_dir = Path(__file__).parent.parent / "content" / "combat"
moves = load_moves(content_dir)
# Verify variant moves have correct structure
assert "punch left" in moves
assert "punch right" in moves
assert moves["punch left"].command == "punch"
assert moves["punch left"].variant == "left"
assert "dodge left" in moves
assert "dodge right" in moves
assert moves["dodge left"].command == "dodge"
assert "parry high" in moves
assert "parry low" in moves
assert moves["parry high"].command == "parry"
# Verify simple moves
assert "roundhouse" in moves
assert moves["roundhouse"].command == "roundhouse"
assert moves["roundhouse"].variant == ""
assert "sweep" in moves
assert "duck" in moves
assert "jump" in moves