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).
This commit is contained in:
parent
6173a165c2
commit
dbb976be24
23 changed files with 1801 additions and 2 deletions
8
content/combat/dodge_left.toml
Normal file
8
content/combat/dodge_left.toml
Normal file
|
|
@ -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 = []
|
||||||
8
content/combat/dodge_right.toml
Normal file
8
content/combat/dodge_right.toml
Normal file
|
|
@ -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 = []
|
||||||
8
content/combat/duck.toml
Normal file
8
content/combat/duck.toml
Normal file
|
|
@ -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 = []
|
||||||
8
content/combat/jump.toml
Normal file
8
content/combat/jump.toml
Normal file
|
|
@ -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 = []
|
||||||
8
content/combat/parry_high.toml
Normal file
8
content/combat/parry_high.toml
Normal file
|
|
@ -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 = []
|
||||||
8
content/combat/parry_low.toml
Normal file
8
content/combat/parry_low.toml
Normal file
|
|
@ -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 = []
|
||||||
8
content/combat/punch_left.toml
Normal file
8
content/combat/punch_left.toml
Normal file
|
|
@ -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"]
|
||||||
8
content/combat/punch_right.toml
Normal file
8
content/combat/punch_right.toml
Normal file
|
|
@ -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"]
|
||||||
8
content/combat/roundhouse.toml
Normal file
8
content/combat/roundhouse.toml
Normal file
|
|
@ -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"]
|
||||||
8
content/combat/sweep.toml
Normal file
8
content/combat/sweep.toml
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Combat system for the MUD."""
|
||||||
176
src/mudlib/combat/commands.py
Normal file
176
src/mudlib/combat/commands.py
Normal file
|
|
@ -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}",
|
||||||
|
)
|
||||||
|
)
|
||||||
124
src/mudlib/combat/encounter.py
Normal file
124
src/mudlib/combat/encounter.py
Normal file
|
|
@ -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)
|
||||||
94
src/mudlib/combat/engine.py
Normal file
94
src/mudlib/combat/engine.py
Normal file
|
|
@ -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)
|
||||||
121
src/mudlib/combat/moves.py
Normal file
121
src/mudlib/combat/moves.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -10,6 +10,10 @@ class Entity:
|
||||||
name: str
|
name: str
|
||||||
x: int
|
x: int
|
||||||
y: 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:
|
async def send(self, message: str) -> None:
|
||||||
"""Send a message to this entity. Base implementation is a no-op."""
|
"""Send a message to this entity. Base implementation is a no-op."""
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ from mudlib.entity import Entity
|
||||||
class Player(Entity):
|
class Player(Entity):
|
||||||
"""Represents a connected player."""
|
"""Represents a connected player."""
|
||||||
|
|
||||||
writer: Any # telnetlib3 TelnetWriter for sending output
|
writer: Any = None # telnetlib3 TelnetWriter for sending output
|
||||||
reader: Any # telnetlib3 TelnetReader for reading input
|
reader: Any = None # telnetlib3 TelnetReader for reading input
|
||||||
flying: bool = False
|
flying: bool = False
|
||||||
mode_stack: list[str] = field(default_factory=lambda: ["normal"])
|
mode_stack: list[str] = field(default_factory=lambda: ["normal"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import mudlib.commands.fly
|
||||||
import mudlib.commands.look
|
import mudlib.commands.look
|
||||||
import mudlib.commands.movement
|
import mudlib.commands.movement
|
||||||
import mudlib.commands.quit
|
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.content import load_commands
|
||||||
from mudlib.effects import clear_expired
|
from mudlib.effects import clear_expired
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
|
|
@ -44,6 +46,7 @@ async def game_loop() -> None:
|
||||||
while True:
|
while True:
|
||||||
t0 = asyncio.get_event_loop().time()
|
t0 = asyncio.get_event_loop().time()
|
||||||
clear_expired()
|
clear_expired()
|
||||||
|
process_combat()
|
||||||
elapsed = asyncio.get_event_loop().time() - t0
|
elapsed = asyncio.get_event_loop().time() - t0
|
||||||
sleep_time = TICK_INTERVAL - elapsed
|
sleep_time = TICK_INTERVAL - elapsed
|
||||||
if sleep_time > 0:
|
if sleep_time > 0:
|
||||||
|
|
@ -199,6 +202,13 @@ async def run_server() -> None:
|
||||||
mudlib.commands.register(cmd_def)
|
mudlib.commands.register(cmd_def)
|
||||||
log.debug("registered content command: %s", cmd_def.name)
|
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
|
# connect_maxwait: how long to wait for telnet option negotiation (CHARSET
|
||||||
# etc) before starting the shell. default is 4.0s which is painful.
|
# etc) before starting the shell. default is 4.0s which is painful.
|
||||||
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
||||||
|
|
|
||||||
227
tests/test_combat_commands.py
Normal file
227
tests/test_combat_commands.py
Normal file
|
|
@ -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"
|
||||||
260
tests/test_combat_encounter.py
Normal file
260
tests/test_combat_encounter.py
Normal file
|
|
@ -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
|
||||||
268
tests/test_combat_engine.py
Normal file
268
tests/test_combat_engine.py
Normal file
|
|
@ -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"]
|
||||||
348
tests/test_combat_moves.py
Normal file
348
tests/test_combat_moves.py
Normal file
|
|
@ -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
|
||||||
86
tests/test_entity.py
Normal file
86
tests/test_entity.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue