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:
Jared Miller 2026-02-07 20:49:05 -05:00
parent 6173a165c2
commit dbb976be24
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
23 changed files with 1801 additions and 2 deletions

View 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 = []

View 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
View 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
View 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 = []

View 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 = []

View 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 = []

View 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"]

View 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"]

View 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"]

View 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"]

View file

@ -0,0 +1 @@
"""Combat system for the MUD."""

View 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}",
)
)

View 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)

View 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
View 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

View file

@ -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."""

View file

@ -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"])

View file

@ -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

View 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"

View 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
View 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
View 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
View 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