Compare commits
No commits in common. "f6686fe52c0aab9d75fd54ad1d139dba5746f8e9" and "910597e92dc66a5bbcf5a4377d75ff662834ff5c" have entirely different histories.
f6686fe52c
...
910597e92d
25 changed files with 2 additions and 2046 deletions
|
|
@ -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 = []
|
||||
|
|
@ -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 = []
|
||||
|
|
@ -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 = []
|
||||
|
|
@ -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 = []
|
||||
|
|
@ -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 = []
|
||||
|
|
@ -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 = []
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
10
mud.tin
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
"""Combat system for the MUD."""
|
||||
|
|
@ -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}",
|
||||
)
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue