Compare commits

..

No commits in common. "f6686fe52c0aab9d75fd54ad1d139dba5746f8e9" and "910597e92dc66a5bbcf5a4377d75ff662834ff5c" have entirely different histories.

25 changed files with 2 additions and 2046 deletions

View file

@ -1,8 +0,0 @@
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

@ -1,8 +0,0 @@
name = "dodge right"
aliases = ["dr"]
move_type = "defense"
stamina_cost = 3.0
telegraph = ""
timing_window_ms = 800
damage_pct = 0.0
countered_by = []

View file

@ -1,8 +0,0 @@
name = "duck"
aliases = []
move_type = "defense"
stamina_cost = 3.0
telegraph = ""
timing_window_ms = 700
damage_pct = 0.0
countered_by = []

View file

@ -1,8 +0,0 @@
name = "jump"
aliases = []
move_type = "defense"
stamina_cost = 4.0
telegraph = ""
timing_window_ms = 700
damage_pct = 0.0
countered_by = []

View file

@ -1,8 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,235 +0,0 @@
combat system — fighting as content
===================================
combat in this MUD is designed around the principle that moves are content,
not engine code. the engine provides a state machine and timing system. moves
are data files that define telegraphs, windows, and counters. this keeps the
combat system extensible without touching python.
core design principles
======================
1. combat moves are CONTENT (TOML files in content/combat/)
2. the engine is a STATE MACHINE that processes timing and resolution
3. direction matters — punch right is countered by dodge left
4. no "fight" command — first attack initiates combat
5. after combat starts, target is implicit until encounter ends
the state machine
=================
IDLE → TELEGRAPH → WINDOW → RESOLVE → IDLE
IDLE:
no active move. attacker can initiate attack. defender can do nothing
combat-specific.
TELEGRAPH:
attacker has declared a move. defender sees the telegraph message.
"goku winds up a right hook!"
defender can queue a defense during this phase.
duration: brief (implementation decides, not move-defined).
WINDOW:
the timing window opens. defender can still queue defense.
if defender queued correct counter during TELEGRAPH or WINDOW, they succeed.
duration: move.timing_window_ms (defined in TOML).
RESOLVE:
timing window closes. check if defense counters attack.
calculate damage based on attacker PL, damage_pct, and defense success.
apply stamina costs.
check for knockouts (PL = 0) or exhaustion (stamina = 0).
return to IDLE.
entity stats
============
each Entity (player, mob, NPC) has:
pl: float = 100.0
power level. acts as both health AND damage multiplier.
when PL hits 0, entity is knocked out.
stamina: float = 100.0
current stamina. each move costs stamina.
when stamina hits 0, entity passes out.
max_stamina: float = 100.0
stamina ceiling. can be affected by training, items, etc (future).
the move set
============
ATTACKS:
punch right (pr)
basic right hook. fast, low cost.
punch left (pl)
basic left hook. fast, low cost.
roundhouse (rh)
big spinning kick. higher damage, tighter parry window.
sweep (sw)
leg sweep. knocks down if not countered.
DEFENSES:
dodge right (dr)
sidestep to the right. counters left-side attacks.
dodge left (dl)
sidestep to the left. counters right-side attacks.
parry high (f)
block high attacks. tighter timing window than dodge.
parry low (v)
block low attacks. tighter timing window than dodge.
duck
crouch under high attacks. counters roundhouse.
jump
leap over low attacks. counters sweep.
counter relationships
=====================
defined in each move's TOML file via the "countered_by" field.
punch right → countered_by: [dodge left, parry high]
punch left → countered_by: [dodge right, parry high]
roundhouse → countered_by: [duck, parry high, parry low]
sweep → countered_by: [jump, parry low]
the engine doesn't know these relationships. it just checks:
"is defender's move in attacker's move.countered_by list?"
damage formula
==============
if defense succeeds (is in countered_by list):
damage = 0
defender gets "you countered the attack!" message
if defense fails (wrong move or no move):
damage = attacker.pl * move.damage_pct
defender.pl -= damage
if no defense was attempted:
damage = attacker.pl * move.damage_pct * 1.5
defender.pl -= damage
defender gets "you took the hit full force!" message
stamina costs are always applied, regardless of outcome:
attacker.stamina -= move.stamina_cost
if defender attempted defense:
defender.stamina -= defense_move.stamina_cost
starting combat
===============
NO explicit "fight" command. instead:
punch right goku
if player is not in combat: starts encounter with goku, pushes "combat" mode
if player is already in combat: error, already fighting someone else
after combat starts, target is implicit:
punch left
dodge right
roundhouse
all commands assume the current opponent.
exiting combat
==============
combat ends when:
- one combatant's PL reaches 0 (knockout)
- one combatant's stamina reaches 0 (exhaustion)
- one combatant flees (future: flee command)
- one combatant is killed (future: lethal combat toggle)
when combat ends, both entities' mode stacks pop back to "normal".
TOML move definition
=====================
example: content/combat/punch_right.toml
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"]
move_type: "attack" or "defense"
telegraph: shown to defender during TELEGRAPH phase. {attacker} replaced with name
timing_window_ms: how long defender has to respond
damage_pct: fraction of attacker's PL dealt as damage
countered_by: list of move names that counter this move
future features (NOT YET IMPLEMENTED)
======================================
stuns:
successful parry stuns opponent for 1 tick.
during stun, engine shows combo sequence in shorthand:
"you can follow up with: pr/pl/sw or pr/pl/rh"
player has narrow window to input the sequence.
combos:
if player executes shown sequence during stun window, bonus damage applied.
after successful combo, chance to chain (diminishing probability).
longer chains = exponentially harder timing.
special moves:
moves that require conditions (opponent stunned, player at high PL, etc).
defined via "requires" field in TOML.
lethal combat:
toggle that makes PL=0 mean death, not knockout.
affects NPC encounters in dangerous zones.
multi-combatant:
extend encounter to support more than 1v1.
each attacker/defender pair still follows same state machine.
implementation notes
====================
the engine provides:
- CombatMove dataclass + TOML loader
- CombatEncounter state machine (attack/defend/tick/resolve)
- global encounter list + management functions
- process_combat() called each game loop tick
- command handlers that look up moves and call encounter methods
content provides:
- TOML files for each move in content/combat/
- balance tuning (stamina costs, damage_pct, timing windows)
the command registry makes moves available as commands automatically.
"punch right" is both a move name AND a command name. the command handler
looks up the move definition and feeds it to the encounter.
this keeps the python side generic. adding a new move = add a TOML file,
no code changes. balance changes = edit TOML values, no restart needed
(future: hot-reload content).

10
mud.tin
View file

@ -11,13 +11,3 @@
#alias {fnw} {fly northwest}
#alias {fse} {fly southeast}
#alias {fsw} {fly southwest}
#NOP combat aliases
#alias {pr} {punch right}
#alias {pl} {punch left}
#alias {o} {sweep}
#alias {r} {roundhouse}
#alias {f} {parry high}
#alias {v} {parry low}
#alias {dr} {dodge right}
#alias {dl} {dodge left}

View file

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

View file

@ -1,176 +0,0 @@
"""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

@ -1,124 +0,0 @@
"""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

@ -1,94 +0,0 @@
"""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)

View file

@ -1,121 +0,0 @@
"""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,10 +10,6 @@ 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 = None # telnetlib3 TelnetWriter for sending output
reader: Any = None # telnetlib3 TelnetReader for reading input
writer: Any # telnetlib3 TelnetWriter for sending output
reader: Any # telnetlib3 TelnetReader for reading input
flying: bool = False
mode_stack: list[str] = field(default_factory=lambda: ["normal"])

View file

@ -15,8 +15,6 @@ 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
@ -46,7 +44,6 @@ 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:
@ -202,13 +199,6 @@ 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

@ -1,227 +0,0 @@
"""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

@ -1,260 +0,0 @@
"""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

View file

@ -1,268 +0,0 @@
"""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"]

View file

@ -1,348 +0,0 @@
"""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

View file

@ -1,86 +0,0 @@
"""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