mud/tests/test_combat_moves.py
Jared Miller dbb976be24
Add data-driven combat system with TOML move definitions
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).
2026-02-07 21:16:12 -05:00

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