Combat moves defined as TOML content files in content/combat/, not engine code. State machine (IDLE > TELEGRAPH > WINDOW > RESOLVE) processes timing-based exchanges. Counter relationships, stamina costs, damage formulas all tunable from data files. Moves: punch right/left, roundhouse, sweep, dodge right/left, parry high/low, duck, jump. Combat ends on knockout (PL <= 0) or exhaustion (stamina <= 0).
348 lines
8.8 KiB
Python
348 lines
8.8 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!",
|
|
timing_window_ms=800,
|
|
damage_pct=0.15,
|
|
countered_by=["dodge left", "parry high"],
|
|
)
|
|
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.timing_window_ms == 800
|
|
assert move.damage_pct == 0.15
|
|
assert move.countered_by == ["dodge left", "parry high"]
|
|
assert move.handler is None
|
|
|
|
|
|
def test_combat_move_minimal():
|
|
"""Test CombatMove with minimal required fields."""
|
|
move = CombatMove(
|
|
name="test move",
|
|
move_type="attack",
|
|
stamina_cost=10.0,
|
|
timing_window_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.timing_window_ms == 500
|
|
assert move.damage_pct == 0.0
|
|
assert move.countered_by == []
|
|
|
|
|
|
def test_load_move_from_toml(tmp_path):
|
|
"""Test loading a combat move from TOML file."""
|
|
toml_content = """
|
|
name = "punch right"
|
|
aliases = ["pr"]
|
|
move_type = "attack"
|
|
stamina_cost = 5.0
|
|
telegraph = "{attacker} winds up a right hook!"
|
|
timing_window_ms = 800
|
|
damage_pct = 0.15
|
|
countered_by = ["dodge left", "parry high"]
|
|
"""
|
|
toml_file = tmp_path / "punch_right.toml"
|
|
toml_file.write_text(toml_content)
|
|
|
|
move = load_move(toml_file)
|
|
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.timing_window_ms == 800
|
|
assert move.damage_pct == 0.15
|
|
assert move.countered_by == ["dodge left", "parry high"]
|
|
|
|
|
|
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
|
|
timing_window_ms = 600
|
|
"""
|
|
toml_file = tmp_path / "basic.toml"
|
|
toml_file.write_text(toml_content)
|
|
|
|
move = load_move(toml_file)
|
|
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
|
|
timing_window_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"
|
|
timing_window_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_timing_window(tmp_path):
|
|
"""Test loading move without timing_window_ms raises error."""
|
|
toml_content = """
|
|
name = "test"
|
|
move_type = "attack"
|
|
stamina_cost = 5.0
|
|
"""
|
|
toml_file = tmp_path / "bad.toml"
|
|
toml_file.write_text(toml_content)
|
|
|
|
with pytest.raises(ValueError, match="missing required field.*timing_window_ms"):
|
|
load_move(toml_file)
|
|
|
|
|
|
def test_load_moves_from_directory(tmp_path):
|
|
"""Test loading all moves from a directory."""
|
|
# Create multiple TOML files
|
|
punch_toml = tmp_path / "punch_right.toml"
|
|
punch_toml.write_text(
|
|
"""
|
|
name = "punch right"
|
|
aliases = ["pr"]
|
|
move_type = "attack"
|
|
stamina_cost = 5.0
|
|
telegraph = "{attacker} winds up a right hook!"
|
|
timing_window_ms = 800
|
|
damage_pct = 0.15
|
|
countered_by = ["dodge left", "parry high"]
|
|
"""
|
|
)
|
|
|
|
dodge_toml = tmp_path / "dodge_left.toml"
|
|
dodge_toml.write_text(
|
|
"""
|
|
name = "dodge left"
|
|
aliases = ["dl"]
|
|
move_type = "defense"
|
|
stamina_cost = 3.0
|
|
telegraph = ""
|
|
timing_window_ms = 500
|
|
damage_pct = 0.0
|
|
countered_by = []
|
|
"""
|
|
)
|
|
|
|
# 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 both names and all aliases
|
|
assert "punch right" in moves
|
|
assert "pr" in moves
|
|
assert "dodge left" in moves
|
|
assert "dl" in moves
|
|
|
|
# All aliases should point to the same object
|
|
assert moves["punch right"] is moves["pr"]
|
|
assert moves["dodge left"] is moves["dl"]
|
|
|
|
# Check move properties
|
|
assert moves["punch right"].name == "punch right"
|
|
assert moves["dodge left"].name == "dodge left"
|
|
|
|
|
|
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
|
|
timing_window_ms = 800
|
|
"""
|
|
)
|
|
|
|
move2_toml = tmp_path / "move2.toml"
|
|
move2_toml.write_text(
|
|
"""
|
|
name = "move two"
|
|
aliases = ["m"]
|
|
move_type = "defense"
|
|
stamina_cost = 3.0
|
|
timing_window_ms = 500
|
|
"""
|
|
)
|
|
|
|
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
|
|
timing_window_ms = 800
|
|
"""
|
|
)
|
|
|
|
move2_toml = tmp_path / "move2.toml"
|
|
move2_toml.write_text(
|
|
"""
|
|
name = "punch"
|
|
move_type = "attack"
|
|
stamina_cost = 5.0
|
|
timing_window_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
|
|
|
|
# Create a move with an invalid counter reference
|
|
punch_toml = tmp_path / "punch_right.toml"
|
|
punch_toml.write_text(
|
|
"""
|
|
name = "punch right"
|
|
move_type = "attack"
|
|
stamina_cost = 5.0
|
|
timing_window_ms = 800
|
|
damage_pct = 0.15
|
|
countered_by = ["dodge left", "nonexistent move"]
|
|
"""
|
|
)
|
|
|
|
dodge_toml = tmp_path / "dodge_left.toml"
|
|
dodge_toml.write_text(
|
|
"""
|
|
name = "dodge left"
|
|
move_type = "defense"
|
|
stamina_cost = 3.0
|
|
timing_window_ms = 500
|
|
"""
|
|
)
|
|
|
|
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
|
|
|
|
# Create moves with valid counter references
|
|
punch_toml = tmp_path / "punch_right.toml"
|
|
punch_toml.write_text(
|
|
"""
|
|
name = "punch right"
|
|
move_type = "attack"
|
|
stamina_cost = 5.0
|
|
timing_window_ms = 800
|
|
damage_pct = 0.15
|
|
countered_by = ["dodge left", "parry high"]
|
|
"""
|
|
)
|
|
|
|
dodge_toml = tmp_path / "dodge_left.toml"
|
|
dodge_toml.write_text(
|
|
"""
|
|
name = "dodge left"
|
|
move_type = "defense"
|
|
stamina_cost = 3.0
|
|
timing_window_ms = 500
|
|
"""
|
|
)
|
|
|
|
parry_toml = tmp_path / "parry_high.toml"
|
|
parry_toml.write_text(
|
|
"""
|
|
name = "parry high"
|
|
move_type = "defense"
|
|
stamina_cost = 3.0
|
|
timing_window_ms = 500
|
|
"""
|
|
)
|
|
|
|
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
|