From dbb976be24e42a21beadf5afb8063ebbcf42573a Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 7 Feb 2026 20:49:05 -0500 Subject: [PATCH] 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). --- content/combat/dodge_left.toml | 8 + content/combat/dodge_right.toml | 8 + content/combat/duck.toml | 8 + content/combat/jump.toml | 8 + content/combat/parry_high.toml | 8 + content/combat/parry_low.toml | 8 + content/combat/punch_left.toml | 8 + content/combat/punch_right.toml | 8 + content/combat/roundhouse.toml | 8 + content/combat/sweep.toml | 8 + src/mudlib/combat/__init__.py | 1 + src/mudlib/combat/commands.py | 176 ++++++++++++++++ src/mudlib/combat/encounter.py | 124 ++++++++++++ src/mudlib/combat/engine.py | 94 +++++++++ src/mudlib/combat/moves.py | 121 +++++++++++ src/mudlib/entity.py | 4 + src/mudlib/player.py | 4 +- src/mudlib/server.py | 10 + tests/test_combat_commands.py | 227 +++++++++++++++++++++ tests/test_combat_encounter.py | 260 ++++++++++++++++++++++++ tests/test_combat_engine.py | 268 ++++++++++++++++++++++++ tests/test_combat_moves.py | 348 ++++++++++++++++++++++++++++++++ tests/test_entity.py | 86 ++++++++ 23 files changed, 1801 insertions(+), 2 deletions(-) create mode 100644 content/combat/dodge_left.toml create mode 100644 content/combat/dodge_right.toml create mode 100644 content/combat/duck.toml create mode 100644 content/combat/jump.toml create mode 100644 content/combat/parry_high.toml create mode 100644 content/combat/parry_low.toml create mode 100644 content/combat/punch_left.toml create mode 100644 content/combat/punch_right.toml create mode 100644 content/combat/roundhouse.toml create mode 100644 content/combat/sweep.toml create mode 100644 src/mudlib/combat/commands.py create mode 100644 src/mudlib/combat/encounter.py create mode 100644 src/mudlib/combat/engine.py create mode 100644 src/mudlib/combat/moves.py create mode 100644 tests/test_combat_commands.py create mode 100644 tests/test_combat_encounter.py create mode 100644 tests/test_combat_engine.py create mode 100644 tests/test_combat_moves.py create mode 100644 tests/test_entity.py diff --git a/content/combat/dodge_left.toml b/content/combat/dodge_left.toml new file mode 100644 index 0000000..0ffbb9c --- /dev/null +++ b/content/combat/dodge_left.toml @@ -0,0 +1,8 @@ +name = "dodge left" +aliases = ["dl"] +move_type = "defense" +stamina_cost = 3.0 +telegraph = "" +timing_window_ms = 800 +damage_pct = 0.0 +countered_by = [] diff --git a/content/combat/dodge_right.toml b/content/combat/dodge_right.toml new file mode 100644 index 0000000..b1abd8d --- /dev/null +++ b/content/combat/dodge_right.toml @@ -0,0 +1,8 @@ +name = "dodge right" +aliases = ["dr"] +move_type = "defense" +stamina_cost = 3.0 +telegraph = "" +timing_window_ms = 800 +damage_pct = 0.0 +countered_by = [] diff --git a/content/combat/duck.toml b/content/combat/duck.toml new file mode 100644 index 0000000..480546a --- /dev/null +++ b/content/combat/duck.toml @@ -0,0 +1,8 @@ +name = "duck" +aliases = [] +move_type = "defense" +stamina_cost = 3.0 +telegraph = "" +timing_window_ms = 700 +damage_pct = 0.0 +countered_by = [] diff --git a/content/combat/jump.toml b/content/combat/jump.toml new file mode 100644 index 0000000..988430d --- /dev/null +++ b/content/combat/jump.toml @@ -0,0 +1,8 @@ +name = "jump" +aliases = [] +move_type = "defense" +stamina_cost = 4.0 +telegraph = "" +timing_window_ms = 700 +damage_pct = 0.0 +countered_by = [] diff --git a/content/combat/parry_high.toml b/content/combat/parry_high.toml new file mode 100644 index 0000000..7afb437 --- /dev/null +++ b/content/combat/parry_high.toml @@ -0,0 +1,8 @@ +name = "parry high" +aliases = ["f"] +move_type = "defense" +stamina_cost = 4.0 +telegraph = "" +timing_window_ms = 500 +damage_pct = 0.0 +countered_by = [] diff --git a/content/combat/parry_low.toml b/content/combat/parry_low.toml new file mode 100644 index 0000000..5923807 --- /dev/null +++ b/content/combat/parry_low.toml @@ -0,0 +1,8 @@ +name = "parry low" +aliases = ["v"] +move_type = "defense" +stamina_cost = 4.0 +telegraph = "" +timing_window_ms = 500 +damage_pct = 0.0 +countered_by = [] diff --git a/content/combat/punch_left.toml b/content/combat/punch_left.toml new file mode 100644 index 0000000..6755332 --- /dev/null +++ b/content/combat/punch_left.toml @@ -0,0 +1,8 @@ +name = "punch left" +aliases = ["pl"] +move_type = "attack" +stamina_cost = 5.0 +telegraph = "{attacker} winds up a left hook!" +timing_window_ms = 800 +damage_pct = 0.15 +countered_by = ["dodge right", "parry high"] diff --git a/content/combat/punch_right.toml b/content/combat/punch_right.toml new file mode 100644 index 0000000..e5101d6 --- /dev/null +++ b/content/combat/punch_right.toml @@ -0,0 +1,8 @@ +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"] diff --git a/content/combat/roundhouse.toml b/content/combat/roundhouse.toml new file mode 100644 index 0000000..572f4e2 --- /dev/null +++ b/content/combat/roundhouse.toml @@ -0,0 +1,8 @@ +name = "roundhouse" +aliases = ["rh"] +move_type = "attack" +stamina_cost = 8.0 +telegraph = "{attacker} spins into a roundhouse kick!" +timing_window_ms = 600 +damage_pct = 0.25 +countered_by = ["duck", "parry high", "parry low"] diff --git a/content/combat/sweep.toml b/content/combat/sweep.toml new file mode 100644 index 0000000..761179b --- /dev/null +++ b/content/combat/sweep.toml @@ -0,0 +1,8 @@ +name = "sweep" +aliases = ["sw"] +move_type = "attack" +stamina_cost = 6.0 +telegraph = "{attacker} drops low for a leg sweep!" +timing_window_ms = 700 +damage_pct = 0.18 +countered_by = ["jump", "parry low"] diff --git a/src/mudlib/combat/__init__.py b/src/mudlib/combat/__init__.py index e69de29..ea32fd2 100644 --- a/src/mudlib/combat/__init__.py +++ b/src/mudlib/combat/__init__.py @@ -0,0 +1 @@ +"""Combat system for the MUD.""" diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py new file mode 100644 index 0000000..2c71462 --- /dev/null +++ b/src/mudlib/combat/commands.py @@ -0,0 +1,176 @@ +"""Combat command handlers.""" + +from pathlib import Path + +from mudlib.combat.engine import get_encounter, start_encounter +from mudlib.combat.moves import CombatMove, load_moves +from mudlib.commands import CommandDefinition, register +from mudlib.player import Player, players + +# Combat moves will be injected after loading +combat_moves: dict[str, CombatMove] = {} + + +async def cmd_attack(player: Player, args: str) -> None: + """Handle attack commands. + + Args: + player: The attacking player + args: Command arguments (move name and optional target name) + """ + # Get or create encounter + encounter = get_encounter(player) + + # Parse arguments: move name (possibly multi-word) and optional target name + parts = args.strip().split() + if not parts: + await player.send("Attack with what move?\r\n") + return + + # If not in combat, last word might be target name + target = None + move_name = args.strip() + + if encounter is None and len(parts) > 1: + # Try to extract target from last word + target_name = parts[-1] + target = players.get(target_name) + + if target is not None: + # Remove target name from move_name + move_name = " ".join(parts[:-1]) + + # Look up the move + move = combat_moves.get(move_name.lower()) + if move is None: + await player.send(f"Unknown move: {move_name}\r\n") + return + + # Check if it's an attack move + if move.move_type != "attack": + await player.send(f"{move.name} is not an attack move.\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") + return + + if encounter is None: + # Not in combat - need a target + if target is None: + await player.send("You need a target to start combat.\r\n") + return + + # Start new encounter + try: + encounter = start_encounter(player, target) + player.mode_stack.append("combat") + await player.send(f"You engage {target.name} in combat!\r\n") + + # Send telegraph to defender if they can receive messages + if hasattr(target, "send") and move.telegraph: + telegraph = move.telegraph.format(attacker=player.name) + await target.send(f"{telegraph}\r\n") + + except ValueError as e: + await player.send(f"Cannot start combat: {e}\r\n") + return + else: + # Already in combat - send telegraph to defender + if encounter.attacker is player: + defender = encounter.defender + else: + defender = encounter.attacker + if hasattr(defender, "send") and move.telegraph: + telegraph = move.telegraph.format(attacker=player.name) + await defender.send(f"{telegraph}\r\n") + + # Execute the attack + encounter.attack(move) + await player.send(f"You use {move.name}!\r\n") + + +async def cmd_defend(player: Player, args: str) -> None: + """Handle defense commands. + + Args: + player: The defending player + args: Command arguments (move name) + """ + # Check if in combat + encounter = get_encounter(player) + if encounter is None: + await player.send("You're not in combat.\r\n") + return + + # Parse move name + move_name = args.strip() + if not move_name: + await player.send("Defend with what move?\r\n") + return + + # Look up the move + move = combat_moves.get(move_name.lower()) + if move is None: + await player.send(f"Unknown move: {move_name}\r\n") + return + + # Check if it's a defense move + if move.move_type != "defense": + await player.send(f"{move.name} is not a defense move.\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") + return + + # Queue the defense + encounter.defend(move) + await player.send(f"You attempt to {move.name}!\r\n") + + +def register_combat_commands(content_dir: Path) -> None: + """Load and register all combat moves as commands. + + Args: + content_dir: Path to directory containing combat move TOML files + """ + global combat_moves + + # Load all moves from content directory + combat_moves = load_moves(content_dir) + + # Track which moves we've registered (don't register aliases separately) + registered_moves: set[str] = set() + + for _move_name, move in combat_moves.items(): + # Only register each move once (by its canonical name) + if move.name in registered_moves: + continue + + registered_moves.add(move.name) + + if move.move_type == "attack": + # Attack moves work from any mode (can initiate combat) + register( + CommandDefinition( + name=move.name, + handler=cmd_attack, + aliases=move.aliases, + mode="*", + help=f"Attack with {move.name}", + ) + ) + elif move.move_type == "defense": + # Defense moves only work in combat mode + register( + CommandDefinition( + name=move.name, + handler=cmd_defend, + aliases=move.aliases, + mode="combat", + help=f"Defend with {move.name}", + ) + ) diff --git a/src/mudlib/combat/encounter.py b/src/mudlib/combat/encounter.py new file mode 100644 index 0000000..5f853cc --- /dev/null +++ b/src/mudlib/combat/encounter.py @@ -0,0 +1,124 @@ +"""Combat encounter and state machine.""" + +import time +from dataclasses import dataclass +from enum import Enum + +from mudlib.combat.moves import CombatMove +from mudlib.entity import Entity + + +class CombatState(Enum): + """States of the combat state machine.""" + + IDLE = "idle" + TELEGRAPH = "telegraph" + WINDOW = "window" + RESOLVE = "resolve" + + +# Telegraph phase duration in seconds (3 game ticks at 100ms/tick) +TELEGRAPH_DURATION = 0.3 + + +@dataclass +class CombatEncounter: + """Represents an active combat encounter between two entities.""" + + attacker: Entity + defender: Entity + state: CombatState = CombatState.IDLE + current_move: CombatMove | None = None + move_started_at: float = 0.0 + pending_defense: CombatMove | None = None + + def attack(self, move: CombatMove) -> None: + """Initiate an attack move. + + Args: + move: The attack move to execute + """ + self.current_move = move + self.state = CombatState.TELEGRAPH + self.move_started_at = time.monotonic() + + # Apply stamina cost + self.attacker.stamina -= move.stamina_cost + + def defend(self, move: CombatMove) -> None: + """Queue a defense move. + + Args: + move: The defense move to attempt + """ + self.pending_defense = move + + # Apply stamina cost + self.defender.stamina -= move.stamina_cost + + def tick(self, now: float) -> None: + """Advance the state machine based on current time. + + Args: + now: Current time from monotonic clock + """ + if self.state == CombatState.TELEGRAPH: + # Check if telegraph phase is over + elapsed = now - self.move_started_at + if elapsed >= TELEGRAPH_DURATION: + self.state = CombatState.WINDOW + + elif self.state == CombatState.WINDOW: + # Check if timing window has expired + if self.current_move is None: + return + + elapsed = now - self.move_started_at + window_seconds = self.current_move.timing_window_ms / 1000.0 + total_time = TELEGRAPH_DURATION + window_seconds + + if elapsed >= total_time: + self.state = CombatState.RESOLVE + + def resolve(self) -> tuple[str, bool]: + """Resolve the combat exchange and return result message. + + Returns: + Tuple of (result message, combat_ended flag) + """ + if self.current_move is None: + return ("No active move to resolve.", False) + + # Check if defense counters attack + defense_succeeds = ( + self.pending_defense + and self.pending_defense.name in self.current_move.countered_by + ) + if defense_succeeds: + # Successful counter - no damage + result = f"{self.defender.name} countered the attack!" + elif self.pending_defense: + # Wrong defense - normal damage + damage = self.attacker.pl * self.current_move.damage_pct + self.defender.pl -= damage + result = ( + f"{self.attacker.name} hit {self.defender.name} " + f"for {damage:.1f} damage!" + ) + else: + # No defense - increased damage + damage = self.attacker.pl * self.current_move.damage_pct * 1.5 + self.defender.pl -= damage + result = ( + f"{self.defender.name} took the hit full force for {damage:.1f} damage!" + ) + + # Check for combat end conditions + combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0 + + # Reset to IDLE + self.state = CombatState.IDLE + self.current_move = None + self.pending_defense = None + + return (result, combat_ended) diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py new file mode 100644 index 0000000..4000c18 --- /dev/null +++ b/src/mudlib/combat/engine.py @@ -0,0 +1,94 @@ +"""Combat encounter management and processing.""" + +import time + +from mudlib.combat.encounter import CombatEncounter, CombatState +from mudlib.entity import Entity + +# Global list of active combat encounters +active_encounters: list[CombatEncounter] = [] + + +def start_encounter(attacker: Entity, defender: Entity) -> CombatEncounter: + """Start a new combat encounter. + + Args: + attacker: The entity initiating combat + defender: The target entity + + Returns: + The created CombatEncounter + + Raises: + ValueError: If either entity is already in combat + """ + # Check if either entity is already in combat + if get_encounter(attacker) is not None: + msg = f"{attacker.name} is already in combat" + raise ValueError(msg) + + if get_encounter(defender) is not None: + msg = f"{defender.name} is already in combat" + raise ValueError(msg) + + # Create and register the encounter + encounter = CombatEncounter(attacker=attacker, defender=defender) + active_encounters.append(encounter) + + return encounter + + +def get_encounter(entity: Entity) -> CombatEncounter | None: + """Find the active encounter for an entity. + + Args: + entity: The entity to search for + + Returns: + CombatEncounter if entity is in combat, None otherwise + """ + for encounter in active_encounters: + if encounter.attacker is entity or encounter.defender is entity: + return encounter + return None + + +def end_encounter(encounter: CombatEncounter) -> None: + """End and remove an encounter from the active list. + + Args: + encounter: The encounter to end + """ + if encounter in active_encounters: + active_encounters.remove(encounter) + + +def process_combat() -> None: + """Process all active combat encounters. + + This should be called each game loop tick to advance combat state machines. + """ + now = time.monotonic() + + for encounter in active_encounters[:]: # Copy list to allow modification + # Tick the state machine + encounter.tick(now) + + # Auto-resolve if in RESOLVE state + if encounter.state == CombatState.RESOLVE: + _result, combat_ended = encounter.resolve() + + if combat_ended: + # Pop combat mode from both entities if they're Players + from mudlib.player import Player + + attacker = encounter.attacker + if isinstance(attacker, Player) and attacker.mode == "combat": + attacker.mode_stack.pop() + + defender = encounter.defender + if isinstance(defender, Player) and defender.mode == "combat": + defender.mode_stack.pop() + + # Remove encounter from active list + end_encounter(encounter) diff --git a/src/mudlib/combat/moves.py b/src/mudlib/combat/moves.py new file mode 100644 index 0000000..576f940 --- /dev/null +++ b/src/mudlib/combat/moves.py @@ -0,0 +1,121 @@ +"""Combat move definitions and TOML loading.""" + +import logging +import tomllib +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +log = logging.getLogger(__name__) + +CommandHandler = Callable[..., Any] + + +@dataclass +class CombatMove: + """Defines a combat move with its properties and counters.""" + + name: str + move_type: str # "attack" or "defense" + stamina_cost: float + timing_window_ms: int + aliases: list[str] = field(default_factory=list) + telegraph: str = "" + damage_pct: float = 0.0 + countered_by: list[str] = field(default_factory=list) + handler: CommandHandler | None = None + + +def load_move(path: Path) -> CombatMove: + """Load a combat move from a TOML file. + + Args: + path: Path to TOML file + + Returns: + CombatMove instance + + Raises: + ValueError: If required fields are missing + """ + with open(path, "rb") as f: + data = tomllib.load(f) + + # Required fields + required_fields = ["name", "move_type", "stamina_cost", "timing_window_ms"] + for field_name in required_fields: + if field_name not in data: + msg = f"missing required field: {field_name}" + raise ValueError(msg) + + # Build move with defaults for optional fields + return CombatMove( + name=data["name"], + move_type=data["move_type"], + stamina_cost=data["stamina_cost"], + timing_window_ms=data["timing_window_ms"], + aliases=data.get("aliases", []), + telegraph=data.get("telegraph", ""), + damage_pct=data.get("damage_pct", 0.0), + countered_by=data.get("countered_by", []), + handler=None, # Future: parse handler reference + ) + + +def load_moves(directory: Path) -> dict[str, CombatMove]: + """Load all combat moves from TOML files in a directory. + + Creates a lookup dict keyed by both move names and their aliases. + All aliases point to the same CombatMove object. + + Args: + directory: Path to directory containing .toml files + + Returns: + Dict mapping move names and aliases to CombatMove instances + + Raises: + ValueError: If duplicate names or aliases are found + """ + moves: dict[str, CombatMove] = {} + seen_names: set[str] = set() + seen_aliases: set[str] = set() + all_moves: list[CombatMove] = [] + + for toml_file in sorted(directory.glob("*.toml")): + move = load_move(toml_file) + + # Check for name collisions + if move.name in seen_names: + msg = f"duplicate move name: {move.name}" + raise ValueError(msg) + seen_names.add(move.name) + + # Check for alias collisions + for alias in move.aliases: + if alias in seen_aliases or alias in seen_names: + msg = f"duplicate move alias: {alias}" + raise ValueError(msg) + seen_aliases.add(alias) + + # Add to dict by name + moves[move.name] = move + + # Add to dict by all aliases (pointing to same object) + for alias in move.aliases: + moves[alias] = move + + all_moves.append(move) + + # Validate countered_by references + for move in all_moves: + for counter_name in move.countered_by: + if counter_name not in moves: + log.warning( + "Move '%s' references non-existent counter '%s' in countered_by", + move.name, + counter_name, + ) + + return moves diff --git a/src/mudlib/entity.py b/src/mudlib/entity.py index e011c33..b0a4311 100644 --- a/src/mudlib/entity.py +++ b/src/mudlib/entity.py @@ -10,6 +10,10 @@ class Entity: name: str x: int y: int + # Combat stats + pl: float = 100.0 # power level (health and damage multiplier) + stamina: float = 100.0 # current stamina + max_stamina: float = 100.0 # stamina ceiling async def send(self, message: str) -> None: """Send a message to this entity. Base implementation is a no-op.""" diff --git a/src/mudlib/player.py b/src/mudlib/player.py index f75463d..553399f 100644 --- a/src/mudlib/player.py +++ b/src/mudlib/player.py @@ -10,8 +10,8 @@ from mudlib.entity import Entity class Player(Entity): """Represents a connected player.""" - writer: Any # telnetlib3 TelnetWriter for sending output - reader: Any # telnetlib3 TelnetReader for reading input + writer: Any = None # telnetlib3 TelnetWriter for sending output + reader: Any = None # telnetlib3 TelnetReader for reading input flying: bool = False mode_stack: list[str] = field(default_factory=lambda: ["normal"]) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 8459f3c..864af57 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -15,6 +15,8 @@ import mudlib.commands.fly import mudlib.commands.look import mudlib.commands.movement import mudlib.commands.quit +from mudlib.combat.commands import register_combat_commands +from mudlib.combat.engine import process_combat from mudlib.content import load_commands from mudlib.effects import clear_expired from mudlib.player import Player, players @@ -44,6 +46,7 @@ async def game_loop() -> None: while True: t0 = asyncio.get_event_loop().time() clear_expired() + process_combat() elapsed = asyncio.get_event_loop().time() - t0 sleep_time = TICK_INTERVAL - elapsed if sleep_time > 0: @@ -199,6 +202,13 @@ async def run_server() -> None: mudlib.commands.register(cmd_def) log.debug("registered content command: %s", cmd_def.name) + # Load combat moves and register as commands + combat_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "combat" + if combat_dir.exists(): + log.info("loading combat moves from %s", combat_dir) + register_combat_commands(combat_dir) + log.info("registered combat commands") + # connect_maxwait: how long to wait for telnet option negotiation (CHARSET # etc) before starting the shell. default is 4.0s which is painful. # MUD clients like tintin++ reject CHARSET immediately via MTTS, but diff --git a/tests/test_combat_commands.py b/tests/test_combat_commands.py new file mode 100644 index 0000000..63ae81d --- /dev/null +++ b/tests/test_combat_commands.py @@ -0,0 +1,227 @@ +"""Tests for combat commands.""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.combat import commands as combat_commands +from mudlib.combat.engine import active_encounters, get_encounter +from mudlib.combat.moves import load_moves +from mudlib.player import Player, players + + +@pytest.fixture(autouse=True) +def clear_state(): + """Clear encounters and players before and after each test.""" + active_encounters.clear() + players.clear() + yield + active_encounters.clear() + players.clear() + + +@pytest.fixture +def mock_writer(): + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer + + +@pytest.fixture +def mock_reader(): + return MagicMock() + + +@pytest.fixture +def player(mock_reader, mock_writer): + p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) + players[p.name] = p + return p + + +@pytest.fixture +def target(mock_reader, mock_writer): + t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer) + players[t.name] = t + return t + + +@pytest.fixture +def moves(): + """Load combat moves from content directory.""" + content_dir = Path(__file__).parent.parent / "content" / "combat" + return load_moves(content_dir) + + +@pytest.fixture(autouse=True) +def inject_moves(moves): + """Inject loaded moves into combat commands module.""" + combat_commands.combat_moves = moves + yield + combat_commands.combat_moves = {} + + +@pytest.mark.asyncio +async def test_attack_starts_combat_with_target(player, target): + """Test attack command with target starts combat encounter.""" + await combat_commands.cmd_attack(player, "punch right Vegeta") + + encounter = get_encounter(player) + assert encounter is not None + assert encounter.attacker is player + assert encounter.defender is target + assert player.mode == "combat" + + +@pytest.mark.asyncio +async def test_attack_without_target_when_not_in_combat(player): + """Test attack without target when not in combat gives error.""" + await combat_commands.cmd_attack(player, "punch right") + + player.writer.write.assert_called() + message = player.writer.write.call_args[0][0] + assert "not in combat" in message.lower() or "need a target" in message.lower() + + +@pytest.mark.asyncio +async def test_attack_without_target_when_in_combat(player, target): + """Test attack without target when in combat uses implicit target.""" + # Start combat first + await combat_commands.cmd_attack(player, "punch right Vegeta") + + # Reset mock to track new calls + player.writer.write.reset_mock() + + # Attack without target should work + await combat_commands.cmd_attack(player, "punch left") + + encounter = get_encounter(player) + assert encounter is not None + assert encounter.current_move is not None + assert encounter.current_move.name == "punch left" + + +@pytest.mark.asyncio +async def test_attack_unknown_move(player, target): + """Test attack with unknown move name gives error.""" + await combat_commands.cmd_attack(player, "kamehameha Vegeta") + + player.writer.write.assert_called() + message = player.writer.write.call_args[0][0] + assert "unknown" in message.lower() or "don't know" in message.lower() + + +@pytest.mark.asyncio +async def test_attack_insufficient_stamina(player, target): + """Test attack with insufficient stamina gives error.""" + player.stamina = 1.0 # Not enough for punch (costs 5) + + await combat_commands.cmd_attack(player, "punch right Vegeta") + + player.writer.write.assert_called() + message = player.writer.write.call_args[0][0] + assert "stamina" in message.lower() or "exhausted" in message.lower() + + +@pytest.mark.asyncio +async def test_attack_sends_telegraph_to_defender(player, target): + """Test attack sends telegraph message to defender.""" + await combat_commands.cmd_attack(player, "punch right Vegeta") + + # Check that encounter has the move + encounter = get_encounter(player) + assert encounter is not None + assert encounter.current_move is not None + assert encounter.current_move.name == "punch right" + + +@pytest.mark.asyncio +async def test_defense_only_in_combat(player): + """Test defense command only works in combat mode.""" + await combat_commands.cmd_defend(player, "dodge left") + + player.writer.write.assert_called() + message = player.writer.write.call_args[0][0] + assert "not in combat" in message.lower() + + +@pytest.mark.asyncio +async def test_defense_records_pending_defense(player, target): + """Test defense command records the defense move.""" + # Start combat + await combat_commands.cmd_attack(player, "punch right Vegeta") + player.writer.write.reset_mock() + + # Switch to defender's perspective + target.writer = player.writer + target.mode_stack = ["combat"] + + # Defend + await combat_commands.cmd_defend(target, "dodge left") + + encounter = get_encounter(target) + assert encounter is not None + assert encounter.pending_defense is not None + assert encounter.pending_defense.name == "dodge left" + + +@pytest.mark.asyncio +async def test_defense_unknown_move(player, target): + """Test defense with unknown move gives error.""" + # Start combat + await combat_commands.cmd_attack(player, "punch right Vegeta") + target.writer = player.writer + target.mode_stack = ["combat"] + + player.writer.write.reset_mock() + await combat_commands.cmd_defend(target, "teleport") + + target.writer.write.assert_called() + message = target.writer.write.call_args[0][0] + assert "unknown" in message.lower() or "don't know" in message.lower() + + +@pytest.mark.asyncio +async def test_defense_insufficient_stamina(player, target): + """Test defense with insufficient stamina gives error.""" + # Start combat + await combat_commands.cmd_attack(player, "punch right Vegeta") + target.writer = player.writer + target.mode_stack = ["combat"] + target.stamina = 1.0 # Not enough for dodge (costs 3) + + player.writer.write.reset_mock() + await combat_commands.cmd_defend(target, "dodge left") + + target.writer.write.assert_called() + message = target.writer.write.call_args[0][0] + assert "stamina" in message.lower() or "exhausted" in message.lower() + + +@pytest.mark.asyncio +async def test_attack_alias_works(player, target): + """Test attack using alias (pr for punch right).""" + await combat_commands.cmd_attack(player, "pr Vegeta") + + encounter = get_encounter(player) + assert encounter is not None + assert encounter.current_move is not None + assert encounter.current_move.name == "punch right" + + +@pytest.mark.asyncio +async def test_defense_alias_works(player, target): + """Test defense using alias (dl for dodge left).""" + # Start combat + await combat_commands.cmd_attack(player, "punch right Vegeta") + target.writer = player.writer + target.mode_stack = ["combat"] + + await combat_commands.cmd_defend(target, "dl") + + encounter = get_encounter(target) + assert encounter is not None + assert encounter.pending_defense is not None + assert encounter.pending_defense.name == "dodge left" diff --git a/tests/test_combat_encounter.py b/tests/test_combat_encounter.py new file mode 100644 index 0000000..70ee9ce --- /dev/null +++ b/tests/test_combat_encounter.py @@ -0,0 +1,260 @@ +"""Tests for combat encounter and state machine.""" + +import time + +import pytest + +from mudlib.combat.encounter import CombatEncounter, CombatState +from mudlib.combat.moves import CombatMove +from mudlib.entity import Entity + + +@pytest.fixture +def attacker(): + return Entity(name="Goku", x=0, y=0, pl=100.0, stamina=50.0) + + +@pytest.fixture +def defender(): + return Entity(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0) + + +@pytest.fixture +def punch(): + return CombatMove( + name="punch right", + move_type="attack", + stamina_cost=5.0, + timing_window_ms=800, + damage_pct=0.15, + countered_by=["dodge left", "parry high"], + ) + + +@pytest.fixture +def dodge(): + return CombatMove( + name="dodge left", + move_type="defense", + stamina_cost=3.0, + timing_window_ms=800, + ) + + +@pytest.fixture +def wrong_dodge(): + return CombatMove( + name="dodge right", + move_type="defense", + stamina_cost=3.0, + timing_window_ms=800, + ) + + +def test_combat_encounter_initial_state(attacker, defender): + """Test encounter starts in IDLE state.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + assert encounter.state == CombatState.IDLE + assert encounter.current_move is None + assert encounter.pending_defense is None + assert encounter.move_started_at == 0.0 + + +def test_attack_transitions_to_telegraph(attacker, defender, punch): + """Test attacking transitions to TELEGRAPH state.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + encounter.attack(punch) + + assert encounter.state == CombatState.TELEGRAPH + assert encounter.current_move is punch + assert encounter.move_started_at > 0.0 + + +def test_attack_applies_stamina_cost(attacker, defender, punch): + """Test attacking costs stamina.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + initial_stamina = attacker.stamina + encounter.attack(punch) + + assert attacker.stamina == initial_stamina - punch.stamina_cost + + +def test_defend_records_pending_defense(attacker, defender, punch, dodge): + """Test defend records the defense move.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + encounter.attack(punch) + encounter.defend(dodge) + + assert encounter.pending_defense is dodge + + +def test_defend_applies_stamina_cost(attacker, defender, punch, dodge): + """Test defending costs stamina.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + encounter.attack(punch) + + initial_stamina = defender.stamina + encounter.defend(dodge) + + assert defender.stamina == initial_stamina - dodge.stamina_cost + + +def test_tick_telegraph_to_window(attacker, defender, punch): + """Test tick advances from TELEGRAPH to WINDOW after brief delay.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + encounter.attack(punch) + + # Wait for telegraph phase (300ms) + time.sleep(0.31) + now = time.monotonic() + encounter.tick(now) + + assert encounter.state == CombatState.WINDOW + + +def test_tick_window_to_resolve(attacker, defender, punch): + """Test tick advances from WINDOW to RESOLVE after timing window.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + encounter.attack(punch) + + # Skip to WINDOW state + time.sleep(0.31) + encounter.tick(time.monotonic()) + + # Wait for timing window to expire (800ms) + time.sleep(0.85) + now = time.monotonic() + encounter.tick(now) + + assert encounter.state == CombatState.RESOLVE + + +def test_resolve_successful_counter(attacker, defender, punch, dodge): + """Test resolve with successful counter does no damage.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + encounter.attack(punch) + encounter.defend(dodge) + + initial_pl = defender.pl + result, combat_ended = encounter.resolve() + + assert defender.pl == initial_pl + assert "countered" in result.lower() + assert encounter.state == CombatState.IDLE + assert combat_ended is False + + +def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge): + """Test resolve with wrong defense move deals damage.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + encounter.attack(punch) + encounter.defend(wrong_dodge) + + initial_pl = defender.pl + result, combat_ended = encounter.resolve() + + expected_damage = attacker.pl * punch.damage_pct + assert defender.pl == initial_pl - expected_damage + assert "hit" in result.lower() + assert encounter.state == CombatState.IDLE + assert combat_ended is False + + +def test_resolve_no_defense(attacker, defender, punch): + """Test resolve with no defense deals increased damage.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + encounter.attack(punch) + + initial_pl = defender.pl + result, combat_ended = encounter.resolve() + + # No defense = 1.5x damage + expected_damage = attacker.pl * punch.damage_pct * 1.5 + assert defender.pl == initial_pl - expected_damage + assert "full force" in result.lower() + assert encounter.state == CombatState.IDLE + assert combat_ended is False + + +def test_resolve_clears_pending_defense(attacker, defender, punch, dodge): + """Test resolve clears pending defense.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + encounter.attack(punch) + encounter.defend(dodge) + + _result, _combat_ended = encounter.resolve() + + assert encounter.pending_defense is None + assert encounter.current_move is None + + +def test_full_state_machine_cycle(attacker, defender, punch): + """Test complete state machine cycle from IDLE to IDLE.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + + # IDLE → TELEGRAPH + encounter.attack(punch) + assert encounter.state == CombatState.TELEGRAPH + + # TELEGRAPH → WINDOW + time.sleep(0.31) + encounter.tick(time.monotonic()) + assert encounter.state == CombatState.WINDOW + + # WINDOW → RESOLVE + time.sleep(0.85) + encounter.tick(time.monotonic()) + assert encounter.state == CombatState.RESOLVE + + # RESOLVE → IDLE + _result, _combat_ended = encounter.resolve() + assert encounter.state == CombatState.IDLE + + +def test_combat_state_enum(): + """Test CombatState enum values.""" + assert CombatState.IDLE.value == "idle" + assert CombatState.TELEGRAPH.value == "telegraph" + assert CombatState.WINDOW.value == "window" + assert CombatState.RESOLVE.value == "resolve" + + +def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch): + """Test resolve returns combat_ended=True when defender PL <= 0.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + + # Set defender to low PL so attack will knock them out + defender.pl = 10.0 + + encounter.attack(punch) + result, combat_ended = encounter.resolve() + + assert defender.pl <= 0 + assert combat_ended is True + assert "damage" in result.lower() + + +def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch): + """Test resolve returns combat_ended=True when attacker stamina <= 0.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + + # Set attacker stamina to exactly the cost so attack depletes it + attacker.stamina = punch.stamina_cost + + encounter.attack(punch) + result, combat_ended = encounter.resolve() + + assert attacker.stamina <= 0 + assert combat_ended is True + + +def test_resolve_returns_combat_continues_normally(attacker, defender, punch): + """Test resolve returns combat_ended=False when both have resources.""" + encounter = CombatEncounter(attacker=attacker, defender=defender) + encounter.attack(punch) + + result, combat_ended = encounter.resolve() + + assert attacker.stamina > 0 + assert defender.pl > 0 + assert combat_ended is False diff --git a/tests/test_combat_engine.py b/tests/test_combat_engine.py new file mode 100644 index 0000000..4554182 --- /dev/null +++ b/tests/test_combat_engine.py @@ -0,0 +1,268 @@ +"""Tests for combat engine and encounter management.""" + +import time + +import pytest + +from mudlib.combat.encounter import CombatState +from mudlib.combat.engine import ( + active_encounters, + end_encounter, + get_encounter, + process_combat, + start_encounter, +) +from mudlib.combat.moves import CombatMove +from mudlib.entity import Entity + + +@pytest.fixture(autouse=True) +def clear_encounters(): + """Clear encounters before and after each test.""" + active_encounters.clear() + yield + active_encounters.clear() + + +@pytest.fixture +def attacker(): + return Entity(name="Goku", x=0, y=0, pl=100.0, stamina=50.0) + + +@pytest.fixture +def defender(): + return Entity(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0) + + +@pytest.fixture +def punch(): + return CombatMove( + name="punch right", + move_type="attack", + stamina_cost=5.0, + timing_window_ms=800, + damage_pct=0.15, + countered_by=["dodge left"], + ) + + +def test_start_encounter_creates_encounter(attacker, defender): + """Test starting an encounter creates and registers it.""" + encounter = start_encounter(attacker, defender) + + assert encounter is not None + assert encounter.attacker is attacker + assert encounter.defender is defender + assert encounter in active_encounters + + +def test_get_encounter_finds_by_attacker(attacker, defender): + """Test get_encounter finds encounter by attacker.""" + encounter = start_encounter(attacker, defender) + found = get_encounter(attacker) + + assert found is encounter + + +def test_get_encounter_finds_by_defender(attacker, defender): + """Test get_encounter finds encounter by defender.""" + encounter = start_encounter(attacker, defender) + found = get_encounter(defender) + + assert found is encounter + + +def test_get_encounter_returns_none_if_not_in_combat(attacker): + """Test get_encounter returns None for entity not in combat.""" + found = get_encounter(attacker) + assert found is None + + +def test_end_encounter_removes_from_active_list(attacker, defender): + """Test ending encounter removes it from active list.""" + encounter = start_encounter(attacker, defender) + end_encounter(encounter) + + assert encounter not in active_encounters + assert get_encounter(attacker) is None + assert get_encounter(defender) is None + + +def test_process_combat_advances_encounters(attacker, defender, punch): + """Test process_combat advances all active encounters.""" + encounter = start_encounter(attacker, defender) + encounter.attack(punch) + + # Process combat should advance state from TELEGRAPH to WINDOW + time.sleep(0.31) + process_combat() + + assert encounter.state == CombatState.WINDOW + + +def test_process_combat_handles_multiple_encounters(punch): + """Test process_combat handles multiple simultaneous encounters.""" + e1_attacker = Entity(name="A1", x=0, y=0) + e1_defender = Entity(name="D1", x=0, y=0) + e2_attacker = Entity(name="A2", x=10, y=10) + e2_defender = Entity(name="D2", x=10, y=10) + + enc1 = start_encounter(e1_attacker, e1_defender) + enc2 = start_encounter(e2_attacker, e2_defender) + + enc1.attack(punch) + enc2.attack(punch) + + time.sleep(0.31) + process_combat() + + assert enc1.state == CombatState.WINDOW + assert enc2.state == CombatState.WINDOW + + +def test_process_combat_auto_resolves_expired_windows(attacker, defender, punch): + """Test process_combat auto-resolves when window expires.""" + encounter = start_encounter(attacker, defender) + encounter.attack(punch) + initial_pl = defender.pl + + # Skip past telegraph and window + time.sleep(0.31) # Telegraph + process_combat() + assert encounter.state == CombatState.WINDOW + + time.sleep(0.85) # Window + process_combat() + # Should auto-resolve and return to IDLE + assert encounter.state == CombatState.IDLE + # Damage should have been applied (no defense = 1.5x damage) + assert defender.pl < initial_pl + + +def test_start_encounter_when_already_in_combat(attacker, defender): + """Test starting encounter when attacker already in combat raises error.""" + start_encounter(attacker, defender) + + other = Entity(name="Other", x=5, y=5) + with pytest.raises(ValueError, match="already in combat"): + start_encounter(attacker, other) + + +def test_start_encounter_when_defender_in_combat(attacker, defender): + """Test starting encounter when defender already in combat raises error.""" + start_encounter(attacker, defender) + + other = Entity(name="Other", x=5, y=5) + with pytest.raises(ValueError, match="already in combat"): + start_encounter(other, defender) + + +def test_active_encounters_list(): + """Test active_encounters is a list.""" + assert isinstance(active_encounters, list) + + +def test_process_combat_with_no_encounters(): + """Test process_combat handles empty encounter list.""" + process_combat() # Should not raise + + +def test_encounter_cleanup_after_resolution(attacker, defender, punch): + """Test encounter can be ended after resolution.""" + encounter = start_encounter(attacker, defender) + encounter.attack(punch) + + # Advance to resolution + time.sleep(0.31) + process_combat() + time.sleep(0.85) + process_combat() + + # Resolve + encounter.resolve() + + # Now end it + end_encounter(encounter) + assert get_encounter(attacker) is None + + +def test_process_combat_ends_encounter_on_knockout(punch): + """Test process_combat ends encounter when defender is knocked out.""" + from mudlib.player import Player + + attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0) + defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0) + + # Push combat mode onto both stacks + attacker.mode_stack.append("combat") + defender.mode_stack.append("combat") + + encounter = start_encounter(attacker, defender) + encounter.attack(punch) + + # Advance to resolution + time.sleep(0.31) + process_combat() + time.sleep(0.85) + process_combat() + + # Combat should have ended and been cleaned up + assert get_encounter(attacker) is None + assert get_encounter(defender) is None + # Mode stacks should have combat popped + assert attacker.mode_stack == ["normal"] + assert defender.mode_stack == ["normal"] + + +def test_process_combat_ends_encounter_on_exhaustion(punch): + """Test process_combat ends encounter when attacker is exhausted.""" + from mudlib.player import Player + + attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=punch.stamina_cost) + defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0) + + # Push combat mode onto both stacks + attacker.mode_stack.append("combat") + defender.mode_stack.append("combat") + + encounter = start_encounter(attacker, defender) + encounter.attack(punch) + + # Advance to resolution + time.sleep(0.31) + process_combat() + time.sleep(0.85) + process_combat() + + # Combat should have ended + assert get_encounter(attacker) is None + assert get_encounter(defender) is None + assert attacker.mode_stack == ["normal"] + assert defender.mode_stack == ["normal"] + + +def test_process_combat_continues_with_resources(punch): + """Test process_combat continues encounter when both have resources.""" + from mudlib.player import Player + + attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0) + defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0) + + # Push combat mode onto both stacks + attacker.mode_stack.append("combat") + defender.mode_stack.append("combat") + + encounter = start_encounter(attacker, defender) + encounter.attack(punch) + + # Advance to resolution + time.sleep(0.31) + process_combat() + time.sleep(0.85) + process_combat() + + # Combat should still be active (but in IDLE state) + assert get_encounter(attacker) is encounter + assert get_encounter(defender) is encounter + assert attacker.mode_stack == ["normal", "combat"] + assert defender.mode_stack == ["normal", "combat"] diff --git a/tests/test_combat_moves.py b/tests/test_combat_moves.py new file mode 100644 index 0000000..0a46f25 --- /dev/null +++ b/tests/test_combat_moves.py @@ -0,0 +1,348 @@ +"""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 diff --git a/tests/test_entity.py b/tests/test_entity.py new file mode 100644 index 0000000..0699b08 --- /dev/null +++ b/tests/test_entity.py @@ -0,0 +1,86 @@ +"""Tests for entity combat stats.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.entity import Entity, Mob +from mudlib.player import Player + + +@pytest.fixture +def mock_writer(): + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer + + +@pytest.fixture +def mock_reader(): + return MagicMock() + + +def test_entity_has_combat_stats(): + """Test that Entity has PL and stamina stats.""" + entity = Entity(name="Test", x=0, y=0) + assert entity.pl == 100.0 + assert entity.stamina == 100.0 + assert entity.max_stamina == 100.0 + + +def test_entity_combat_stats_can_be_customized(): + """Test that combat stats can be set on initialization.""" + entity = Entity(name="Weak", x=0, y=0, pl=50.0, stamina=30.0, max_stamina=30.0) + assert entity.pl == 50.0 + assert entity.stamina == 30.0 + assert entity.max_stamina == 30.0 + + +def test_mob_inherits_combat_stats(): + """Test that Mob inherits combat stats from Entity.""" + mob = Mob(name="Goku", x=10, y=10, description="A powerful fighter") + assert mob.pl == 100.0 + assert mob.stamina == 100.0 + assert mob.max_stamina == 100.0 + + +def test_mob_combat_stats_can_be_customized(): + """Test that Mob can have custom combat stats.""" + mob = Mob( + name="Boss", + x=5, + y=5, + description="Strong", + pl=200.0, + max_stamina=150.0, + stamina=150.0, + ) + assert mob.pl == 200.0 + assert mob.stamina == 150.0 + assert mob.max_stamina == 150.0 + + +def test_player_inherits_combat_stats(mock_reader, mock_writer): + """Test that Player inherits combat stats from Entity.""" + player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer) + assert player.pl == 100.0 + assert player.stamina == 100.0 + assert player.max_stamina == 100.0 + + +def test_player_combat_stats_can_be_customized(mock_reader, mock_writer): + """Test that Player can have custom combat stats.""" + player = Player( + name="Veteran", + x=0, + y=0, + reader=mock_reader, + writer=mock_writer, + pl=150.0, + stamina=120.0, + max_stamina=120.0, + ) + assert player.pl == 150.0 + assert player.stamina == 120.0 + assert player.max_stamina == 120.0