Rework combat state machine
PENDING phase, defense active/recovery windows
This commit is contained in:
parent
312da1dbac
commit
edbad4666f
24 changed files with 488 additions and 238 deletions
|
|
@ -1,6 +1,5 @@
|
||||||
"""Combat command handlers."""
|
"""Combat command handlers."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -102,10 +101,7 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||||
await defender.send(f"{telegraph}\r\n")
|
await defender.send(f"{telegraph}\r\n")
|
||||||
|
|
||||||
# Detect switch before attack() modifies state
|
# Detect switch before attack() modifies state
|
||||||
switching = encounter.state in (
|
switching = encounter.state == CombatState.PENDING
|
||||||
CombatState.TELEGRAPH,
|
|
||||||
CombatState.WINDOW,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Execute the attack (deducts stamina)
|
# Execute the attack (deducts stamina)
|
||||||
encounter.attack(move)
|
encounter.attack(move)
|
||||||
|
|
@ -127,8 +123,8 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||||
async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
|
async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
|
||||||
"""Core defense logic with a resolved move.
|
"""Core defense logic with a resolved move.
|
||||||
|
|
||||||
Works both in and outside combat. Applies a recovery lock
|
Works both in and outside combat. The encounter tracks active/recovery
|
||||||
(based on timing_window_ms) so defenses have commitment.
|
windows internally.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
player: The defending player
|
player: The defending player
|
||||||
|
|
@ -156,7 +152,7 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
|
||||||
# Check stamina cues after defense cost
|
# Check stamina cues after defense cost
|
||||||
await check_stamina_cues(player)
|
await check_stamina_cues(player)
|
||||||
|
|
||||||
# If in combat, queue the defense on the encounter
|
# If in combat, queue/activate the defense on the encounter
|
||||||
encounter = get_encounter(player)
|
encounter = get_encounter(player)
|
||||||
if encounter is not None:
|
if encounter is not None:
|
||||||
encounter.defend(move)
|
encounter.defend(move)
|
||||||
|
|
@ -171,9 +167,6 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
|
||||||
f"{player.name} {move.command}s!\r\n",
|
f"{player.name} {move.command}s!\r\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Commitment: block for the timing window (inputs queue naturally)
|
|
||||||
await asyncio.sleep(move.timing_window_ms / 1000.0)
|
|
||||||
|
|
||||||
if encounter is not None:
|
if encounter is not None:
|
||||||
await player.send(f"You {move.name}!\r\n")
|
await player.send(f"You {move.name}!\r\n")
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,10 @@ class CombatState(Enum):
|
||||||
"""States of the combat state machine."""
|
"""States of the combat state machine."""
|
||||||
|
|
||||||
IDLE = "idle"
|
IDLE = "idle"
|
||||||
TELEGRAPH = "telegraph"
|
PENDING = "pending"
|
||||||
WINDOW = "window"
|
|
||||||
RESOLVE = "resolve"
|
RESOLVE = "resolve"
|
||||||
|
|
||||||
|
|
||||||
# Telegraph phase duration in seconds (3 game ticks at 100ms/tick)
|
|
||||||
TELEGRAPH_DURATION = 0.3
|
|
||||||
|
|
||||||
# Seconds since last landed damage before combat fizzles out
|
# Seconds since last landed damage before combat fizzles out
|
||||||
IDLE_TIMEOUT = 30.0
|
IDLE_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
|
@ -44,45 +40,60 @@ class CombatEncounter:
|
||||||
current_move: CombatMove | None = None
|
current_move: CombatMove | None = None
|
||||||
move_started_at: float = 0.0
|
move_started_at: float = 0.0
|
||||||
pending_defense: CombatMove | None = None
|
pending_defense: CombatMove | None = None
|
||||||
|
defense_activated_at: float | None = None
|
||||||
|
defense_recovery_until: float | None = None
|
||||||
|
queued_defense: CombatMove | None = None
|
||||||
# Monotonic timestamp of most recent landed damage in this encounter.
|
# Monotonic timestamp of most recent landed damage in this encounter.
|
||||||
last_action_at: float = 0.0
|
last_action_at: float = 0.0
|
||||||
|
|
||||||
def attack(self, move: CombatMove) -> None:
|
def attack(self, move: CombatMove) -> None:
|
||||||
"""Initiate or switch an attack move.
|
"""Initiate or switch an attack move.
|
||||||
|
|
||||||
If called during TELEGRAPH or WINDOW, switches to the new move
|
If called during PENDING, switches to the new move and restarts
|
||||||
without resetting the timer. Refunds old move's stamina cost.
|
the timer. Refunds old move's stamina cost.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
move: The attack move to execute
|
move: The attack move to execute
|
||||||
"""
|
"""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|
||||||
if self.state in (CombatState.TELEGRAPH, CombatState.WINDOW):
|
if self.state == CombatState.PENDING and self.current_move:
|
||||||
# Switching — refund old cost, keep timer
|
# Switching — refund old cost
|
||||||
if self.current_move:
|
self.attacker.stamina = min(
|
||||||
self.attacker.stamina = min(
|
self.attacker.stamina + self.current_move.stamina_cost,
|
||||||
self.attacker.stamina + self.current_move.stamina_cost,
|
self.attacker.max_stamina,
|
||||||
self.attacker.max_stamina,
|
)
|
||||||
)
|
|
||||||
else:
|
|
||||||
# First attack — start timer
|
|
||||||
self.move_started_at = now
|
|
||||||
|
|
||||||
|
# Always restart timer
|
||||||
|
self.move_started_at = now
|
||||||
self.current_move = move
|
self.current_move = move
|
||||||
self.attacker.stamina -= move.stamina_cost
|
self.attacker.stamina -= move.stamina_cost
|
||||||
if self.state == CombatState.IDLE:
|
if self.state == CombatState.IDLE:
|
||||||
self.state = CombatState.TELEGRAPH
|
self.state = CombatState.PENDING
|
||||||
|
|
||||||
def defend(self, move: CombatMove) -> None:
|
def defend(self, move: CombatMove) -> None:
|
||||||
"""Queue a defense move on the encounter.
|
"""Queue or activate a defense move.
|
||||||
|
|
||||||
Stamina cost and lock are handled by the command layer (do_defend).
|
If in recovery, queues the defense. Otherwise activates immediately.
|
||||||
|
Stamina cost is handled by the command layer (do_defend).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
move: The defense move to attempt
|
move: The defense move to attempt
|
||||||
"""
|
"""
|
||||||
self.pending_defense = move
|
now = time.monotonic()
|
||||||
|
|
||||||
|
# Check if in recovery
|
||||||
|
if (
|
||||||
|
self.defense_recovery_until is not None
|
||||||
|
and now < self.defense_recovery_until
|
||||||
|
):
|
||||||
|
# Queue for later activation
|
||||||
|
self.queued_defense = move
|
||||||
|
else:
|
||||||
|
# Activate immediately
|
||||||
|
self.pending_defense = move
|
||||||
|
self.defense_activated_at = now
|
||||||
|
self.queued_defense = None
|
||||||
|
|
||||||
def tick(self, now: float) -> None:
|
def tick(self, now: float) -> None:
|
||||||
"""Advance the state machine based on current time.
|
"""Advance the state machine based on current time.
|
||||||
|
|
@ -90,23 +101,45 @@ class CombatEncounter:
|
||||||
Args:
|
Args:
|
||||||
now: Current time from monotonic clock
|
now: Current time from monotonic clock
|
||||||
"""
|
"""
|
||||||
if self.state == CombatState.TELEGRAPH:
|
# Check if queued defense should activate
|
||||||
# Check if telegraph phase is over
|
if (
|
||||||
elapsed = now - self.move_started_at
|
self.queued_defense is not None
|
||||||
if elapsed >= TELEGRAPH_DURATION:
|
and self.defense_recovery_until is not None
|
||||||
self.state = CombatState.WINDOW
|
and now >= self.defense_recovery_until
|
||||||
|
):
|
||||||
|
# Activate queued defense
|
||||||
|
self.pending_defense = self.queued_defense
|
||||||
|
self.defense_activated_at = now
|
||||||
|
self.queued_defense = None
|
||||||
|
self.defense_recovery_until = None
|
||||||
|
|
||||||
elif self.state == CombatState.WINDOW:
|
# Check PENDING -> RESOLVE transition
|
||||||
# Check if timing window has expired
|
if self.state == CombatState.PENDING:
|
||||||
if self.current_move is None:
|
if self.current_move is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
elapsed = now - self.move_started_at
|
elapsed = now - self.move_started_at
|
||||||
window_seconds = self.current_move.timing_window_ms / 1000.0
|
hit_time_seconds = self.current_move.hit_time_ms / 1000.0
|
||||||
total_time = TELEGRAPH_DURATION + window_seconds
|
|
||||||
|
|
||||||
if elapsed >= total_time:
|
if elapsed >= hit_time_seconds:
|
||||||
self.state = CombatState.RESOLVE
|
self.state = CombatState.RESOLVE
|
||||||
|
# Don't expire defense here - resolve() will handle it after checking
|
||||||
|
|
||||||
|
# Only expire defense if NOT in RESOLVE state
|
||||||
|
# (resolve() will clear defense after checking for counters)
|
||||||
|
if (
|
||||||
|
self.state != CombatState.RESOLVE
|
||||||
|
and self.defense_activated_at is not None
|
||||||
|
and self.pending_defense is not None
|
||||||
|
):
|
||||||
|
active_duration = now - self.defense_activated_at
|
||||||
|
active_seconds = self.pending_defense.active_ms / 1000.0
|
||||||
|
if active_duration >= active_seconds:
|
||||||
|
# Defense window expired, enter recovery
|
||||||
|
recovery_seconds = self.pending_defense.recovery_ms / 1000.0
|
||||||
|
self.defense_recovery_until = now + recovery_seconds
|
||||||
|
self.defense_activated_at = None
|
||||||
|
self.pending_defense = None
|
||||||
|
|
||||||
def resolve(self) -> ResolveResult:
|
def resolve(self) -> ResolveResult:
|
||||||
"""Resolve the combat exchange and return result.
|
"""Resolve the combat exchange and return result.
|
||||||
|
|
@ -140,8 +173,16 @@ class CombatEncounter:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if defense counters attack
|
# Check if defense counters attack
|
||||||
|
# Defense must be active (in active window) to succeed
|
||||||
|
defense_is_active = False
|
||||||
|
if self.defense_activated_at is not None and self.pending_defense is not None:
|
||||||
|
active_duration = time.monotonic() - self.defense_activated_at
|
||||||
|
active_window = self.pending_defense.active_ms / 1000.0
|
||||||
|
defense_is_active = active_duration < active_window
|
||||||
|
|
||||||
defense_succeeds = (
|
defense_succeeds = (
|
||||||
self.pending_defense
|
defense_is_active
|
||||||
|
and self.pending_defense is not None
|
||||||
and self.pending_defense.name in self.current_move.countered_by
|
and self.pending_defense.name in self.current_move.countered_by
|
||||||
)
|
)
|
||||||
if defense_succeeds:
|
if defense_succeeds:
|
||||||
|
|
@ -178,10 +219,12 @@ class CombatEncounter:
|
||||||
self.last_action_at = time.monotonic()
|
self.last_action_at = time.monotonic()
|
||||||
combat_ended = False
|
combat_ended = False
|
||||||
|
|
||||||
# Reset to IDLE
|
# Reset to IDLE and clear defense state
|
||||||
|
# Note: defense_recovery_until persists across attacks
|
||||||
self.state = CombatState.IDLE
|
self.state = CombatState.IDLE
|
||||||
self.current_move = None
|
self.current_move = None
|
||||||
self.pending_defense = None
|
self.pending_defense = None
|
||||||
|
self.defense_activated_at = None
|
||||||
|
|
||||||
return ResolveResult(
|
return ResolveResult(
|
||||||
resolve_template=template,
|
resolve_template=template,
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,10 @@ async def process_combat() -> None:
|
||||||
# Tick the state machine
|
# Tick the state machine
|
||||||
encounter.tick(now)
|
encounter.tick(now)
|
||||||
|
|
||||||
# Send announce message on TELEGRAPH → WINDOW transition
|
# Send announce message on PENDING → RESOLVE transition
|
||||||
if (
|
if (
|
||||||
previous_state == CombatState.TELEGRAPH
|
previous_state == CombatState.PENDING
|
||||||
and encounter.state == CombatState.WINDOW
|
and encounter.state == CombatState.RESOLVE
|
||||||
and encounter.current_move
|
and encounter.current_move
|
||||||
and encounter.current_move.announce
|
and encounter.current_move.announce
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ class CombatMove:
|
||||||
name: str
|
name: str
|
||||||
move_type: str # "attack" or "defense"
|
move_type: str # "attack" or "defense"
|
||||||
stamina_cost: float
|
stamina_cost: float
|
||||||
timing_window_ms: int
|
hit_time_ms: int = 0 # for attacks: ms from initiation to impact
|
||||||
|
active_ms: int = 0 # for defenses: how long defense blocks once activated
|
||||||
|
recovery_ms: int = 0 # for defenses: lockout after active window ends
|
||||||
aliases: list[str] = field(default_factory=list)
|
aliases: list[str] = field(default_factory=list)
|
||||||
telegraph: str = ""
|
telegraph: str = ""
|
||||||
damage_pct: float = 0.0
|
damage_pct: float = 0.0
|
||||||
|
|
@ -69,7 +71,7 @@ def load_move(path: Path) -> list[CombatMove]:
|
||||||
data = tomllib.load(f)
|
data = tomllib.load(f)
|
||||||
|
|
||||||
# Required fields
|
# Required fields
|
||||||
required_fields = ["name", "move_type", "stamina_cost", "timing_window_ms"]
|
required_fields = ["name", "move_type", "stamina_cost"]
|
||||||
for field_name in required_fields:
|
for field_name in required_fields:
|
||||||
if field_name not in data:
|
if field_name not in data:
|
||||||
msg = f"missing required field: {field_name}"
|
msg = f"missing required field: {field_name}"
|
||||||
|
|
@ -97,8 +99,12 @@ def load_move(path: Path) -> list[CombatMove]:
|
||||||
name=qualified_name,
|
name=qualified_name,
|
||||||
move_type=data["move_type"],
|
move_type=data["move_type"],
|
||||||
stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]),
|
stamina_cost=variant_data.get("stamina_cost", data["stamina_cost"]),
|
||||||
timing_window_ms=variant_data.get(
|
hit_time_ms=variant_data.get(
|
||||||
"timing_window_ms", data["timing_window_ms"]
|
"hit_time_ms", data.get("hit_time_ms", 0)
|
||||||
|
),
|
||||||
|
active_ms=variant_data.get("active_ms", data.get("active_ms", 0)),
|
||||||
|
recovery_ms=variant_data.get(
|
||||||
|
"recovery_ms", data.get("recovery_ms", 0)
|
||||||
),
|
),
|
||||||
aliases=variant_data.get("aliases", []),
|
aliases=variant_data.get("aliases", []),
|
||||||
telegraph=variant_data.get("telegraph", data.get("telegraph", "")),
|
telegraph=variant_data.get("telegraph", data.get("telegraph", "")),
|
||||||
|
|
@ -139,7 +145,9 @@ def load_move(path: Path) -> list[CombatMove]:
|
||||||
name=base_name,
|
name=base_name,
|
||||||
move_type=data["move_type"],
|
move_type=data["move_type"],
|
||||||
stamina_cost=data["stamina_cost"],
|
stamina_cost=data["stamina_cost"],
|
||||||
timing_window_ms=data["timing_window_ms"],
|
hit_time_ms=data.get("hit_time_ms", 0),
|
||||||
|
active_ms=data.get("active_ms", 0),
|
||||||
|
recovery_ms=data.get("recovery_ms", 0),
|
||||||
aliases=data.get("aliases", []),
|
aliases=data.get("aliases", []),
|
||||||
telegraph=data.get("telegraph", ""),
|
telegraph=data.get("telegraph", ""),
|
||||||
damage_pct=data.get("damage_pct", 0.0),
|
damage_pct=data.get("damage_pct", 0.0),
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,11 @@ async def _show_single_command(
|
||||||
# Combat move specific details
|
# Combat move specific details
|
||||||
if move is not None:
|
if move is not None:
|
||||||
lines.append(f" stamina: {move.stamina_cost}")
|
lines.append(f" stamina: {move.stamina_cost}")
|
||||||
lines.append(f" timing window: {move.timing_window_ms}ms")
|
if move.move_type == "attack":
|
||||||
|
lines.append(f" hit time: {move.hit_time_ms}ms")
|
||||||
|
else: # defense
|
||||||
|
lines.append(f" active window: {move.active_ms}ms")
|
||||||
|
lines.append(f" recovery: {move.recovery_ms}ms")
|
||||||
if move.damage_pct > 0:
|
if move.damage_pct > 0:
|
||||||
damage_pct = int(move.damage_pct * 100)
|
damage_pct = int(move.damage_pct * 100)
|
||||||
lines.append(f" damage: {damage_pct}%")
|
lines.append(f" damage: {damage_pct}%")
|
||||||
|
|
@ -188,7 +192,11 @@ async def _show_variant_overview(
|
||||||
lines.append(f" aliases: {aliases_str}")
|
lines.append(f" aliases: {aliases_str}")
|
||||||
|
|
||||||
lines.append(f" stamina: {move.stamina_cost}")
|
lines.append(f" stamina: {move.stamina_cost}")
|
||||||
lines.append(f" timing window: {move.timing_window_ms}ms")
|
if move.move_type == "attack":
|
||||||
|
lines.append(f" hit time: {move.hit_time_ms}ms")
|
||||||
|
else: # defense
|
||||||
|
lines.append(f" active window: {move.active_ms}ms")
|
||||||
|
lines.append(f" recovery: {move.recovery_ms}ms")
|
||||||
|
|
||||||
if move.damage_pct > 0:
|
if move.damage_pct > 0:
|
||||||
damage_pct = int(move.damage_pct * 100)
|
damage_pct = int(move.damage_pct * 100)
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,8 @@ async def process_mobs(combat_moves: dict[str, CombatMove]) -> None:
|
||||||
# Determine if mob is attacker or defender in this encounter
|
# Determine if mob is attacker or defender in this encounter
|
||||||
mob_is_defender = encounter.defender is mob
|
mob_is_defender = encounter.defender is mob
|
||||||
|
|
||||||
# Defense AI: react during TELEGRAPH or WINDOW when mob is defender
|
# Defense AI: react during PENDING when mob is defender
|
||||||
if mob_is_defender and encounter.state in (
|
if mob_is_defender and encounter.state == CombatState.PENDING:
|
||||||
CombatState.TELEGRAPH,
|
|
||||||
CombatState.WINDOW,
|
|
||||||
):
|
|
||||||
_try_defend(mob, encounter, combat_moves, now)
|
_try_defend(mob, encounter, combat_moves, now)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -345,14 +345,14 @@ async def test_switch_attack_sends_new_telegraph(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_defense_blocks_for_timing_window(player, dodge_left):
|
async def test_defense_does_not_block(player, dodge_left):
|
||||||
"""Test defense sleeps for timing_window_ms (commitment via blocking)."""
|
"""Test defense no longer blocks (encounter tracks active/recovery internally)."""
|
||||||
before = time.monotonic()
|
before = time.monotonic()
|
||||||
await combat_commands.do_defend(player, "", dodge_left)
|
await combat_commands.do_defend(player, "", dodge_left)
|
||||||
elapsed = time.monotonic() - before
|
elapsed = time.monotonic() - before
|
||||||
|
|
||||||
expected = dodge_left.timing_window_ms / 1000.0
|
# Should return immediately, not block for active_ms
|
||||||
assert elapsed >= expected - 0.05
|
assert elapsed < 0.1 # Allow for some overhead
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def punch():
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left", "parry high"],
|
countered_by=["dodge left", "parry high"],
|
||||||
)
|
)
|
||||||
|
|
@ -37,7 +37,8 @@ def dodge():
|
||||||
name="dodge left",
|
name="dodge left",
|
||||||
move_type="defense",
|
move_type="defense",
|
||||||
stamina_cost=3.0,
|
stamina_cost=3.0,
|
||||||
timing_window_ms=800,
|
active_ms=800,
|
||||||
|
recovery_ms=2700,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -47,7 +48,8 @@ def wrong_dodge():
|
||||||
name="dodge right",
|
name="dodge right",
|
||||||
move_type="defense",
|
move_type="defense",
|
||||||
stamina_cost=3.0,
|
stamina_cost=3.0,
|
||||||
timing_window_ms=800,
|
active_ms=800,
|
||||||
|
recovery_ms=2700,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -57,7 +59,7 @@ def sweep():
|
||||||
name="sweep",
|
name="sweep",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=8.0,
|
stamina_cost=8.0,
|
||||||
timing_window_ms=600,
|
hit_time_ms=600,
|
||||||
damage_pct=0.20,
|
damage_pct=0.20,
|
||||||
countered_by=["jump"],
|
countered_by=["jump"],
|
||||||
)
|
)
|
||||||
|
|
@ -72,12 +74,12 @@ def test_combat_encounter_initial_state(attacker, defender):
|
||||||
assert encounter.move_started_at == 0.0
|
assert encounter.move_started_at == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_attack_transitions_to_telegraph(attacker, defender, punch):
|
def test_attack_transitions_to_pending(attacker, defender, punch):
|
||||||
"""Test attacking transitions to TELEGRAPH state."""
|
"""Test attacking transitions to PENDING state."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
|
|
||||||
assert encounter.state == CombatState.TELEGRAPH
|
assert encounter.state == CombatState.PENDING
|
||||||
assert encounter.current_move is punch
|
assert encounter.current_move is punch
|
||||||
assert encounter.move_started_at > 0.0
|
assert encounter.move_started_at > 0.0
|
||||||
|
|
||||||
|
|
@ -92,12 +94,13 @@ def test_attack_applies_stamina_cost(attacker, defender, punch):
|
||||||
|
|
||||||
|
|
||||||
def test_defend_records_pending_defense(attacker, defender, punch, dodge):
|
def test_defend_records_pending_defense(attacker, defender, punch, dodge):
|
||||||
"""Test defend records the defense move."""
|
"""Test defend records the defense move and activates it."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
encounter.defend(dodge)
|
encounter.defend(dodge)
|
||||||
|
|
||||||
assert encounter.pending_defense is dodge
|
assert encounter.pending_defense is dodge
|
||||||
|
assert encounter.defense_activated_at is not None
|
||||||
|
|
||||||
|
|
||||||
def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
|
def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
|
||||||
|
|
@ -111,29 +114,12 @@ def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
|
||||||
assert defender.stamina == initial_stamina
|
assert defender.stamina == initial_stamina
|
||||||
|
|
||||||
|
|
||||||
def test_tick_telegraph_to_window(attacker, defender, punch):
|
def test_tick_pending_to_resolve(attacker, defender, punch):
|
||||||
"""Test tick advances from TELEGRAPH to WINDOW after brief delay."""
|
"""Test tick advances from PENDING to RESOLVE after hit time."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
|
|
||||||
# Wait for telegraph phase (300ms)
|
# Wait for hit_time_ms (800ms)
|
||||||
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)
|
time.sleep(0.85)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
encounter.tick(now)
|
encounter.tick(now)
|
||||||
|
|
@ -212,16 +198,11 @@ def test_full_state_machine_cycle(attacker, defender, punch):
|
||||||
"""Test complete state machine cycle from IDLE to IDLE."""
|
"""Test complete state machine cycle from IDLE to IDLE."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
|
||||||
# IDLE → TELEGRAPH
|
# IDLE → PENDING
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
assert encounter.state == CombatState.TELEGRAPH
|
assert encounter.state == CombatState.PENDING
|
||||||
|
|
||||||
# TELEGRAPH → WINDOW
|
# PENDING → RESOLVE (after hit_time_ms)
|
||||||
time.sleep(0.31)
|
|
||||||
encounter.tick(time.monotonic())
|
|
||||||
assert encounter.state == CombatState.WINDOW
|
|
||||||
|
|
||||||
# WINDOW → RESOLVE
|
|
||||||
time.sleep(0.85)
|
time.sleep(0.85)
|
||||||
encounter.tick(time.monotonic())
|
encounter.tick(time.monotonic())
|
||||||
assert encounter.state == CombatState.RESOLVE
|
assert encounter.state == CombatState.RESOLVE
|
||||||
|
|
@ -234,8 +215,7 @@ def test_full_state_machine_cycle(attacker, defender, punch):
|
||||||
def test_combat_state_enum():
|
def test_combat_state_enum():
|
||||||
"""Test CombatState enum values."""
|
"""Test CombatState enum values."""
|
||||||
assert CombatState.IDLE.value == "idle"
|
assert CombatState.IDLE.value == "idle"
|
||||||
assert CombatState.TELEGRAPH.value == "telegraph"
|
assert CombatState.PENDING.value == "pending"
|
||||||
assert CombatState.WINDOW.value == "window"
|
|
||||||
assert CombatState.RESOLVE.value == "resolve"
|
assert CombatState.RESOLVE.value == "resolve"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -324,41 +304,22 @@ def test_resolve_counter_template_indicates_counter(attacker, defender, punch, d
|
||||||
# --- Attack switching (feint) tests ---
|
# --- Attack switching (feint) tests ---
|
||||||
|
|
||||||
|
|
||||||
def test_switch_attack_during_telegraph(attacker, defender, punch, sweep):
|
def test_switch_attack_during_pending(attacker, defender, punch, sweep):
|
||||||
"""Test attack during TELEGRAPH replaces move and keeps timer."""
|
"""Test attack during PENDING replaces move and restarts timer."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
original_start = encounter.move_started_at
|
original_start = encounter.move_started_at
|
||||||
|
|
||||||
assert encounter.state == CombatState.TELEGRAPH
|
assert encounter.state == CombatState.PENDING
|
||||||
|
|
||||||
# Switch to sweep during telegraph
|
# Switch to sweep during pending
|
||||||
|
time.sleep(0.1) # Small delay to ensure timer would differ
|
||||||
encounter.attack(sweep)
|
encounter.attack(sweep)
|
||||||
|
|
||||||
assert encounter.current_move is sweep
|
assert encounter.current_move is sweep
|
||||||
assert encounter.state == CombatState.TELEGRAPH
|
assert encounter.state == CombatState.PENDING
|
||||||
# Timer should NOT restart
|
# Timer should restart on switch
|
||||||
assert encounter.move_started_at == original_start
|
assert encounter.move_started_at > original_start
|
||||||
|
|
||||||
|
|
||||||
def test_switch_attack_during_window(attacker, defender, punch, sweep):
|
|
||||||
"""Test attack during WINDOW replaces move and keeps timer."""
|
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
||||||
encounter.attack(punch)
|
|
||||||
original_start = encounter.move_started_at
|
|
||||||
|
|
||||||
# Advance to WINDOW
|
|
||||||
time.sleep(0.31)
|
|
||||||
encounter.tick(time.monotonic())
|
|
||||||
assert encounter.state == CombatState.WINDOW
|
|
||||||
|
|
||||||
# Switch to sweep during window
|
|
||||||
encounter.attack(sweep)
|
|
||||||
|
|
||||||
assert encounter.current_move is sweep
|
|
||||||
assert encounter.state == CombatState.WINDOW
|
|
||||||
# Timer should NOT restart
|
|
||||||
assert encounter.move_started_at == original_start
|
|
||||||
|
|
||||||
|
|
||||||
def test_switch_refunds_old_stamina(attacker, defender, punch, sweep):
|
def test_switch_refunds_old_stamina(attacker, defender, punch, sweep):
|
||||||
|
|
@ -449,3 +410,244 @@ def test_last_action_at_unchanged_when_attack_is_countered(
|
||||||
encounter.defend(dodge)
|
encounter.defend(dodge)
|
||||||
encounter.resolve()
|
encounter.resolve()
|
||||||
assert encounter.last_action_at == 10.0
|
assert encounter.last_action_at == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Defense active/recovery window tests ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_defense_expired_before_resolve(attacker, defender):
|
||||||
|
"""Defense activates, active_ms passes, attack resolves.
|
||||||
|
|
||||||
|
Defense should NOT counter (hit lands).
|
||||||
|
"""
|
||||||
|
# Create attack with 800ms hit time
|
||||||
|
quick_attack = CombatMove(
|
||||||
|
name="quick punch",
|
||||||
|
move_type="attack",
|
||||||
|
stamina_cost=5.0,
|
||||||
|
hit_time_ms=800,
|
||||||
|
damage_pct=0.15,
|
||||||
|
countered_by=["quick dodge"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create defense with short active window (200ms)
|
||||||
|
quick_dodge = CombatMove(
|
||||||
|
name="quick dodge",
|
||||||
|
move_type="defense",
|
||||||
|
stamina_cost=3.0,
|
||||||
|
active_ms=200,
|
||||||
|
recovery_ms=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
encounter.attack(quick_attack)
|
||||||
|
encounter.defend(quick_dodge)
|
||||||
|
|
||||||
|
# Wait for defense to expire (200ms) but before attack lands (800ms)
|
||||||
|
time.sleep(0.31)
|
||||||
|
encounter.tick(time.monotonic())
|
||||||
|
|
||||||
|
# Now resolve — defense should be expired
|
||||||
|
initial_pl = defender.pl
|
||||||
|
result = encounter.resolve()
|
||||||
|
|
||||||
|
# Defense expired, so attack should hit for full damage
|
||||||
|
expected_damage = attacker.pl * quick_attack.damage_pct * 1.5
|
||||||
|
assert defender.pl == initial_pl - expected_damage
|
||||||
|
assert result.damage == expected_damage
|
||||||
|
assert result.countered is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_defense_active_during_resolve(attacker, defender):
|
||||||
|
"""Defense activates within active_ms of resolve — defense SHOULD counter."""
|
||||||
|
# Create attack with 800ms hit time
|
||||||
|
quick_attack = CombatMove(
|
||||||
|
name="quick punch",
|
||||||
|
move_type="attack",
|
||||||
|
stamina_cost=5.0,
|
||||||
|
hit_time_ms=800,
|
||||||
|
damage_pct=0.15,
|
||||||
|
countered_by=["quick dodge"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create defense with long active window (1000ms)
|
||||||
|
quick_dodge = CombatMove(
|
||||||
|
name="quick dodge",
|
||||||
|
move_type="defense",
|
||||||
|
stamina_cost=3.0,
|
||||||
|
active_ms=1000,
|
||||||
|
recovery_ms=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
encounter.attack(quick_attack)
|
||||||
|
encounter.defend(quick_dodge)
|
||||||
|
|
||||||
|
# Immediately resolve — defense is still active
|
||||||
|
initial_pl = defender.pl
|
||||||
|
result = encounter.resolve()
|
||||||
|
|
||||||
|
# Defense is active, should counter successfully
|
||||||
|
assert defender.pl == initial_pl
|
||||||
|
assert result.damage == 0.0
|
||||||
|
assert result.countered is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_defense_queuing_during_recovery(attacker, defender):
|
||||||
|
"""defend() while in recovery should set queued_defense, not pending_defense."""
|
||||||
|
quick_dodge = CombatMove(
|
||||||
|
name="quick dodge",
|
||||||
|
move_type="defense",
|
||||||
|
stamina_cost=3.0,
|
||||||
|
active_ms=200,
|
||||||
|
recovery_ms=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
|
||||||
|
# First defend — activates immediately
|
||||||
|
encounter.defend(quick_dodge)
|
||||||
|
assert encounter.pending_defense is quick_dodge
|
||||||
|
assert encounter.defense_activated_at is not None
|
||||||
|
|
||||||
|
# Wait for defense to expire and enter recovery
|
||||||
|
time.sleep(0.31)
|
||||||
|
encounter.tick(time.monotonic())
|
||||||
|
|
||||||
|
assert encounter.pending_defense is None
|
||||||
|
assert encounter.defense_recovery_until is not None
|
||||||
|
|
||||||
|
# Try to defend during recovery — should queue
|
||||||
|
second_dodge = CombatMove(
|
||||||
|
name="second dodge",
|
||||||
|
move_type="defense",
|
||||||
|
stamina_cost=3.0,
|
||||||
|
active_ms=200,
|
||||||
|
recovery_ms=300,
|
||||||
|
)
|
||||||
|
encounter.defend(second_dodge)
|
||||||
|
|
||||||
|
# Should be queued, not pending
|
||||||
|
assert encounter.pending_defense is None
|
||||||
|
assert encounter.queued_defense is second_dodge
|
||||||
|
|
||||||
|
|
||||||
|
def test_queued_defense_activates_after_recovery(attacker, defender):
|
||||||
|
"""After recovery_ms passes, tick() should activate the queued defense."""
|
||||||
|
quick_dodge = CombatMove(
|
||||||
|
name="quick dodge",
|
||||||
|
move_type="defense",
|
||||||
|
stamina_cost=3.0,
|
||||||
|
active_ms=200,
|
||||||
|
recovery_ms=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
|
||||||
|
# First defend
|
||||||
|
encounter.defend(quick_dodge)
|
||||||
|
|
||||||
|
# Wait for defense to expire
|
||||||
|
time.sleep(0.31)
|
||||||
|
encounter.tick(time.monotonic())
|
||||||
|
|
||||||
|
# Queue second defense during recovery
|
||||||
|
second_dodge = CombatMove(
|
||||||
|
name="second dodge",
|
||||||
|
move_type="defense",
|
||||||
|
stamina_cost=3.0,
|
||||||
|
active_ms=200,
|
||||||
|
recovery_ms=300,
|
||||||
|
)
|
||||||
|
encounter.defend(second_dodge)
|
||||||
|
|
||||||
|
assert encounter.queued_defense is second_dodge
|
||||||
|
assert encounter.pending_defense is None
|
||||||
|
|
||||||
|
# Wait for recovery to finish
|
||||||
|
time.sleep(0.31)
|
||||||
|
encounter.tick(time.monotonic())
|
||||||
|
|
||||||
|
# Queued defense should now be active
|
||||||
|
assert encounter.pending_defense is second_dodge
|
||||||
|
assert encounter.queued_defense is None
|
||||||
|
assert encounter.defense_activated_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_persists_after_resolve(attacker, defender):
|
||||||
|
"""resolve() should NOT clear defense_recovery_until.
|
||||||
|
|
||||||
|
Recovery carries across attacks.
|
||||||
|
"""
|
||||||
|
quick_attack = CombatMove(
|
||||||
|
name="quick punch",
|
||||||
|
move_type="attack",
|
||||||
|
stamina_cost=5.0,
|
||||||
|
hit_time_ms=800,
|
||||||
|
damage_pct=0.15,
|
||||||
|
countered_by=["quick dodge"],
|
||||||
|
)
|
||||||
|
|
||||||
|
quick_dodge = CombatMove(
|
||||||
|
name="quick dodge",
|
||||||
|
move_type="defense",
|
||||||
|
stamina_cost=3.0,
|
||||||
|
active_ms=200,
|
||||||
|
recovery_ms=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
|
||||||
|
# Defend, then wait for defense to expire and enter recovery
|
||||||
|
encounter.defend(quick_dodge)
|
||||||
|
time.sleep(0.31)
|
||||||
|
encounter.tick(time.monotonic())
|
||||||
|
|
||||||
|
recovery_until = encounter.defense_recovery_until
|
||||||
|
assert recovery_until is not None
|
||||||
|
|
||||||
|
# Attack and resolve during recovery period
|
||||||
|
encounter.attack(quick_attack)
|
||||||
|
encounter.resolve()
|
||||||
|
|
||||||
|
# Recovery should persist after resolve
|
||||||
|
assert encounter.defense_recovery_until == recovery_until
|
||||||
|
|
||||||
|
|
||||||
|
def test_attack_switch_restarts_timer(attacker, defender):
|
||||||
|
"""attack(), then attack() again with different move.
|
||||||
|
|
||||||
|
move_started_at should reset.
|
||||||
|
"""
|
||||||
|
first_attack = CombatMove(
|
||||||
|
name="first punch",
|
||||||
|
move_type="attack",
|
||||||
|
stamina_cost=5.0,
|
||||||
|
hit_time_ms=800,
|
||||||
|
damage_pct=0.15,
|
||||||
|
countered_by=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
second_attack = CombatMove(
|
||||||
|
name="second punch",
|
||||||
|
move_type="attack",
|
||||||
|
stamina_cost=5.0,
|
||||||
|
hit_time_ms=800,
|
||||||
|
damage_pct=0.15,
|
||||||
|
countered_by=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
encounter.attack(first_attack)
|
||||||
|
first_start = encounter.move_started_at
|
||||||
|
|
||||||
|
# Small delay to ensure time difference
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# Switch to second attack
|
||||||
|
encounter.attack(second_attack)
|
||||||
|
second_start = encounter.move_started_at
|
||||||
|
|
||||||
|
# Timer should have restarted
|
||||||
|
assert second_start > first_start
|
||||||
|
assert encounter.current_move is second_attack
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ def punch():
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -107,7 +107,7 @@ async def test_process_combat_advances_encounters(attacker, defender, punch):
|
||||||
time.sleep(0.31)
|
time.sleep(0.31)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
assert encounter.state == CombatState.WINDOW
|
assert encounter.state == CombatState.PENDING
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -127,8 +127,8 @@ async def test_process_combat_handles_multiple_encounters(punch):
|
||||||
time.sleep(0.31)
|
time.sleep(0.31)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
assert enc1.state == CombatState.WINDOW
|
assert enc1.state == CombatState.PENDING
|
||||||
assert enc2.state == CombatState.WINDOW
|
assert enc2.state == CombatState.PENDING
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -141,7 +141,7 @@ async def test_process_combat_auto_resolves_expired_windows(attacker, defender,
|
||||||
# Skip past telegraph and window
|
# Skip past telegraph and window
|
||||||
time.sleep(0.31) # Telegraph
|
time.sleep(0.31) # Telegraph
|
||||||
await process_combat()
|
await process_combat()
|
||||||
assert encounter.state == CombatState.WINDOW
|
assert encounter.state == CombatState.PENDING
|
||||||
|
|
||||||
time.sleep(0.85) # Window
|
time.sleep(0.85) # Window
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ def test_combat_move_dataclass():
|
||||||
aliases=["pr"],
|
aliases=["pr"],
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
telegraph="{attacker} winds up a right hook!",
|
telegraph="{attacker} winds up a right hook!",
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left", "parry high"],
|
countered_by=["dodge left", "parry high"],
|
||||||
command="punch",
|
command="punch",
|
||||||
|
|
@ -24,7 +24,7 @@ def test_combat_move_dataclass():
|
||||||
assert move.aliases == ["pr"]
|
assert move.aliases == ["pr"]
|
||||||
assert move.stamina_cost == 5.0
|
assert move.stamina_cost == 5.0
|
||||||
assert move.telegraph == "{attacker} winds up a right hook!"
|
assert move.telegraph == "{attacker} winds up a right hook!"
|
||||||
assert move.timing_window_ms == 800
|
assert move.hit_time_ms == 800
|
||||||
assert move.damage_pct == 0.15
|
assert move.damage_pct == 0.15
|
||||||
assert move.countered_by == ["dodge left", "parry high"]
|
assert move.countered_by == ["dodge left", "parry high"]
|
||||||
assert move.handler is None
|
assert move.handler is None
|
||||||
|
|
@ -38,14 +38,14 @@ def test_combat_move_minimal():
|
||||||
name="test move",
|
name="test move",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=10.0,
|
stamina_cost=10.0,
|
||||||
timing_window_ms=500,
|
hit_time_ms=500,
|
||||||
)
|
)
|
||||||
assert move.name == "test move"
|
assert move.name == "test move"
|
||||||
assert move.move_type == "attack"
|
assert move.move_type == "attack"
|
||||||
assert move.aliases == []
|
assert move.aliases == []
|
||||||
assert move.stamina_cost == 10.0
|
assert move.stamina_cost == 10.0
|
||||||
assert move.telegraph == ""
|
assert move.telegraph == ""
|
||||||
assert move.timing_window_ms == 500
|
assert move.hit_time_ms == 500
|
||||||
assert move.damage_pct == 0.0
|
assert move.damage_pct == 0.0
|
||||||
assert move.countered_by == []
|
assert move.countered_by == []
|
||||||
assert move.command == ""
|
assert move.command == ""
|
||||||
|
|
@ -60,7 +60,7 @@ aliases = ["rh"]
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 8.0
|
stamina_cost = 8.0
|
||||||
telegraph = "{attacker} spins into a roundhouse kick!"
|
telegraph = "{attacker} spins into a roundhouse kick!"
|
||||||
timing_window_ms = 600
|
hit_time_ms = 600
|
||||||
damage_pct = 0.25
|
damage_pct = 0.25
|
||||||
countered_by = ["duck", "parry high", "parry low"]
|
countered_by = ["duck", "parry high", "parry low"]
|
||||||
"""
|
"""
|
||||||
|
|
@ -83,7 +83,7 @@ def test_load_variant_move_from_toml(tmp_path):
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
timing_window_ms = 800
|
hit_time_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
[variants.left]
|
[variants.left]
|
||||||
|
|
@ -114,7 +114,7 @@ countered_by = ["dodge left", "parry high"]
|
||||||
assert left.countered_by == ["dodge right", "parry high"]
|
assert left.countered_by == ["dodge right", "parry high"]
|
||||||
# Inherited from parent
|
# Inherited from parent
|
||||||
assert left.stamina_cost == 5.0
|
assert left.stamina_cost == 5.0
|
||||||
assert left.timing_window_ms == 800
|
assert left.hit_time_ms == 800
|
||||||
assert left.damage_pct == 0.15
|
assert left.damage_pct == 0.15
|
||||||
|
|
||||||
right = by_name["punch right"]
|
right = by_name["punch right"]
|
||||||
|
|
@ -130,13 +130,13 @@ def test_variant_inherits_shared_properties(tmp_path):
|
||||||
name = "kick"
|
name = "kick"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
timing_window_ms = 800
|
hit_time_ms = 800
|
||||||
damage_pct = 0.10
|
damage_pct = 0.10
|
||||||
|
|
||||||
[variants.low]
|
[variants.low]
|
||||||
aliases = ["kl"]
|
aliases = ["kl"]
|
||||||
damage_pct = 0.08
|
damage_pct = 0.08
|
||||||
timing_window_ms = 600
|
hit_time_ms = 600
|
||||||
|
|
||||||
[variants.high]
|
[variants.high]
|
||||||
aliases = ["kh"]
|
aliases = ["kh"]
|
||||||
|
|
@ -150,12 +150,12 @@ damage_pct = 0.15
|
||||||
|
|
||||||
low = by_name["kick low"]
|
low = by_name["kick low"]
|
||||||
assert low.damage_pct == 0.08
|
assert low.damage_pct == 0.08
|
||||||
assert low.timing_window_ms == 600 # overridden
|
assert low.hit_time_ms == 600 # overridden
|
||||||
assert low.stamina_cost == 5.0 # inherited
|
assert low.stamina_cost == 5.0 # inherited
|
||||||
|
|
||||||
high = by_name["kick high"]
|
high = by_name["kick high"]
|
||||||
assert high.damage_pct == 0.15
|
assert high.damage_pct == 0.15
|
||||||
assert high.timing_window_ms == 800 # inherited
|
assert high.hit_time_ms == 800 # inherited
|
||||||
assert high.stamina_cost == 5.0 # inherited
|
assert high.stamina_cost == 5.0 # inherited
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -165,7 +165,8 @@ def test_load_move_with_defaults(tmp_path):
|
||||||
name = "basic move"
|
name = "basic move"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
timing_window_ms = 600
|
active_ms = 600
|
||||||
|
recovery_ms = 2700
|
||||||
"""
|
"""
|
||||||
toml_file = tmp_path / "basic.toml"
|
toml_file = tmp_path / "basic.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
@ -185,7 +186,7 @@ def test_load_move_missing_name(tmp_path):
|
||||||
toml_content = """
|
toml_content = """
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
timing_window_ms = 800
|
hit_time_ms = 800
|
||||||
"""
|
"""
|
||||||
toml_file = tmp_path / "bad.toml"
|
toml_file = tmp_path / "bad.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
@ -213,7 +214,7 @@ def test_load_move_missing_stamina_cost(tmp_path):
|
||||||
toml_content = """
|
toml_content = """
|
||||||
name = "test"
|
name = "test"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
timing_window_ms = 800
|
hit_time_ms = 800
|
||||||
"""
|
"""
|
||||||
toml_file = tmp_path / "bad.toml"
|
toml_file = tmp_path / "bad.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
@ -222,8 +223,8 @@ timing_window_ms = 800
|
||||||
load_move(toml_file)
|
load_move(toml_file)
|
||||||
|
|
||||||
|
|
||||||
def test_load_move_missing_timing_window(tmp_path):
|
def test_load_move_missing_hit_time(tmp_path):
|
||||||
"""Test loading move without timing_window_ms raises error."""
|
"""Test loading move without hit_time_ms defaults to 0."""
|
||||||
toml_content = """
|
toml_content = """
|
||||||
name = "test"
|
name = "test"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
|
|
@ -232,8 +233,9 @@ stamina_cost = 5.0
|
||||||
toml_file = tmp_path / "bad.toml"
|
toml_file = tmp_path / "bad.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="missing required field.*timing_window_ms"):
|
moves = load_move(toml_file)
|
||||||
load_move(toml_file)
|
assert len(moves) == 1
|
||||||
|
assert moves[0].hit_time_ms == 0
|
||||||
|
|
||||||
|
|
||||||
def test_load_moves_from_directory(tmp_path):
|
def test_load_moves_from_directory(tmp_path):
|
||||||
|
|
@ -245,7 +247,7 @@ def test_load_moves_from_directory(tmp_path):
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
timing_window_ms = 800
|
hit_time_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
[variants.right]
|
[variants.right]
|
||||||
|
|
@ -262,7 +264,8 @@ countered_by = ["dodge left"]
|
||||||
name = "duck"
|
name = "duck"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
timing_window_ms = 500
|
active_ms = 500
|
||||||
|
recovery_ms = 2700
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -303,7 +306,7 @@ name = "move one"
|
||||||
aliases = ["m"]
|
aliases = ["m"]
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
timing_window_ms = 800
|
hit_time_ms = 800
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -314,7 +317,8 @@ name = "move two"
|
||||||
aliases = ["m"]
|
aliases = ["m"]
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
timing_window_ms = 500
|
active_ms = 500
|
||||||
|
recovery_ms = 2700
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -330,7 +334,7 @@ def test_load_moves_name_collision(tmp_path):
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
timing_window_ms = 800
|
hit_time_ms = 800
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -340,7 +344,7 @@ timing_window_ms = 800
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
timing_window_ms = 800
|
hit_time_ms = 800
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -358,7 +362,7 @@ def test_load_moves_validates_countered_by_refs(tmp_path, caplog):
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
timing_window_ms = 800
|
hit_time_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
[variants.right]
|
[variants.right]
|
||||||
|
|
@ -372,7 +376,8 @@ countered_by = ["dodge left", "nonexistent move"]
|
||||||
name = "dodge"
|
name = "dodge"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
timing_window_ms = 500
|
active_ms = 500
|
||||||
|
recovery_ms = 2700
|
||||||
|
|
||||||
[variants.left]
|
[variants.left]
|
||||||
aliases = ["dl"]
|
aliases = ["dl"]
|
||||||
|
|
@ -401,7 +406,7 @@ def test_load_moves_valid_countered_by_refs_no_warning(tmp_path, caplog):
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 5.0
|
stamina_cost = 5.0
|
||||||
timing_window_ms = 800
|
hit_time_ms = 800
|
||||||
damage_pct = 0.15
|
damage_pct = 0.15
|
||||||
|
|
||||||
[variants.right]
|
[variants.right]
|
||||||
|
|
@ -415,7 +420,8 @@ countered_by = ["dodge left", "parry high"]
|
||||||
name = "dodge"
|
name = "dodge"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
timing_window_ms = 500
|
active_ms = 500
|
||||||
|
recovery_ms = 2700
|
||||||
|
|
||||||
[variants.left]
|
[variants.left]
|
||||||
aliases = ["dl"]
|
aliases = ["dl"]
|
||||||
|
|
@ -428,7 +434,8 @@ aliases = ["dl"]
|
||||||
name = "parry"
|
name = "parry"
|
||||||
move_type = "defense"
|
move_type = "defense"
|
||||||
stamina_cost = 3.0
|
stamina_cost = 3.0
|
||||||
timing_window_ms = 500
|
active_ms = 500
|
||||||
|
recovery_ms = 2700
|
||||||
|
|
||||||
[variants.high]
|
[variants.high]
|
||||||
aliases = ["f"]
|
aliases = ["f"]
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ def attack_move():
|
||||||
variant="left",
|
variant="left",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5,
|
stamina_cost=5,
|
||||||
timing_window_ms=850,
|
hit_time_ms=850,
|
||||||
telegraph="telegraphs a left punch at {defender}",
|
telegraph="telegraphs a left punch at {defender}",
|
||||||
telegraph_color="yellow",
|
telegraph_color="yellow",
|
||||||
aliases=[],
|
aliases=[],
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ async def test_flying_during_window_causes_miss(player, target, punch_right):
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
|
|
||||||
# Advance to WINDOW phase
|
# Advance to WINDOW phase
|
||||||
encounter.state = CombatState.WINDOW
|
encounter.state = CombatState.PENDING
|
||||||
|
|
||||||
# Defender flies during window
|
# Defender flies during window
|
||||||
target.flying = True
|
target.flying = True
|
||||||
|
|
@ -159,7 +159,7 @@ async def test_both_flying_at_resolve_attack_lands(player, target, punch_right):
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
|
|
||||||
# Advance to WINDOW phase (no altitude change)
|
# Advance to WINDOW phase (no altitude change)
|
||||||
encounter.state = CombatState.WINDOW
|
encounter.state = CombatState.PENDING
|
||||||
|
|
||||||
# Resolve
|
# Resolve
|
||||||
result = encounter.resolve()
|
result = encounter.resolve()
|
||||||
|
|
@ -180,7 +180,7 @@ async def test_attacker_flies_during_window_causes_miss(player, target, punch_ri
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
|
|
||||||
# Advance to WINDOW phase
|
# Advance to WINDOW phase
|
||||||
encounter.state = CombatState.WINDOW
|
encounter.state = CombatState.PENDING
|
||||||
|
|
||||||
# Attacker flies during window
|
# Attacker flies during window
|
||||||
player.flying = True
|
player.flying = True
|
||||||
|
|
@ -205,7 +205,7 @@ async def test_flying_dodge_messages_correct_grammar(player, target, punch_right
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
|
|
||||||
# Advance to WINDOW phase
|
# Advance to WINDOW phase
|
||||||
encounter.state = CombatState.WINDOW
|
encounter.state = CombatState.PENDING
|
||||||
|
|
||||||
# Defender flies during window
|
# Defender flies during window
|
||||||
target.flying = True
|
target.flying = True
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ async def test_commands_detail_simple_combat_move(player, combat_moves):
|
||||||
assert "roundhouse" in output
|
assert "roundhouse" in output
|
||||||
assert "type: attack" in output
|
assert "type: attack" in output
|
||||||
assert "stamina: 8.0" in output
|
assert "stamina: 8.0" in output
|
||||||
assert "timing window: 2000ms" in output
|
assert "hit time: 3000ms" in output
|
||||||
assert "damage: 25%" in output
|
assert "damage: 25%" in output
|
||||||
assert "{attacker} shifts {his} weight back..." in output
|
assert "{attacker} shifts {his} weight back..." in output
|
||||||
assert "countered by: duck, parry high, parry low" in output
|
assert "countered by: duck, parry high, parry low" in output
|
||||||
|
|
@ -201,7 +201,7 @@ async def test_commands_detail_variant_base(player, combat_moves):
|
||||||
|
|
||||||
# Should show shared properties in each variant
|
# Should show shared properties in each variant
|
||||||
assert "stamina: 5.0" in output
|
assert "stamina: 5.0" in output
|
||||||
assert "timing window: 1800ms" in output
|
assert "hit time: 3000ms" in output
|
||||||
assert "damage: 15%" in output
|
assert "damage: 15%" in output
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -214,7 +214,7 @@ async def test_commands_detail_specific_variant(player, combat_moves):
|
||||||
assert "punch left" in output
|
assert "punch left" in output
|
||||||
assert "type: attack" in output
|
assert "type: attack" in output
|
||||||
assert "stamina: 5.0" in output
|
assert "stamina: 5.0" in output
|
||||||
assert "timing window: 1800ms" in output
|
assert "hit time: 3000ms" in output
|
||||||
assert "damage: 15%" in output
|
assert "damage: 15%" in output
|
||||||
assert "{attacker} retracts {his} left arm..." in output
|
assert "{attacker} retracts {his} left arm..." in output
|
||||||
assert "countered by: dodge right, parry high" in output
|
assert "countered by: dodge right, parry high" in output
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,7 @@ class TestCombatDeathCorpse:
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -334,7 +334,7 @@ class TestCombatDeathCorpse:
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -394,7 +394,7 @@ class TestCombatDeathCorpse:
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ async def test_edit_combat_move_opens_toml(player, tmp_path):
|
||||||
aliases = ["rh"]
|
aliases = ["rh"]
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 8.0
|
stamina_cost = 8.0
|
||||||
timing_window_ms = 2000
|
hit_time_ms = 2000
|
||||||
"""
|
"""
|
||||||
toml_file = tmp_path / "roundhouse.toml"
|
toml_file = tmp_path / "roundhouse.toml"
|
||||||
toml_file.write_text(toml_content)
|
toml_file.write_text(toml_content)
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,7 @@ async def test_char_vitals_sent_on_combat_resolve():
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ def mock_move_kill_count():
|
||||||
name="roundhouse",
|
name="roundhouse",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=30.0,
|
stamina_cost=30.0,
|
||||||
timing_window_ms=850,
|
hit_time_ms=850,
|
||||||
aliases=["rh"],
|
aliases=["rh"],
|
||||||
description="A powerful spinning kick",
|
description="A powerful spinning kick",
|
||||||
damage_pct=0.35,
|
damage_pct=0.35,
|
||||||
|
|
@ -52,7 +52,7 @@ def mock_move_mob_kills():
|
||||||
name="goblin slayer",
|
name="goblin slayer",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=25.0,
|
stamina_cost=25.0,
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
description="Specialized technique against goblins",
|
description="Specialized technique against goblins",
|
||||||
damage_pct=0.40,
|
damage_pct=0.40,
|
||||||
unlock_condition=UnlockCondition(
|
unlock_condition=UnlockCondition(
|
||||||
|
|
@ -68,7 +68,7 @@ def mock_move_no_unlock():
|
||||||
name="jab",
|
name="jab",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=10.0,
|
stamina_cost=10.0,
|
||||||
timing_window_ms=600,
|
hit_time_ms=600,
|
||||||
description="A quick straight punch",
|
description="A quick straight punch",
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ class TestMobAttackAI:
|
||||||
await process_mobs(moves)
|
await process_mobs(moves)
|
||||||
|
|
||||||
# Mob should have attacked — encounter state should be TELEGRAPH
|
# Mob should have attacked — encounter state should be TELEGRAPH
|
||||||
assert encounter.state == CombatState.TELEGRAPH
|
assert encounter.state == CombatState.PENDING
|
||||||
assert encounter.current_move is not None
|
assert encounter.current_move is not None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -286,7 +286,7 @@ class TestMobDefenseAI:
|
||||||
|
|
||||||
# Player attacks, putting encounter in TELEGRAPH
|
# Player attacks, putting encounter in TELEGRAPH
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
assert encounter.state == CombatState.TELEGRAPH
|
assert encounter.state == CombatState.PENDING
|
||||||
|
|
||||||
await process_mobs(moves)
|
await process_mobs(moves)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,7 @@ def test_move_shows_name_when_in_combat_with_active_move():
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -463,7 +463,7 @@ def test_combat_state_shows_state_when_in_combat():
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge left"],
|
countered_by=["dodge left"],
|
||||||
)
|
)
|
||||||
|
|
@ -473,7 +473,7 @@ def test_combat_state_shows_state_when_in_combat():
|
||||||
active_encounters.append(encounter)
|
active_encounters.append(encounter)
|
||||||
|
|
||||||
result = render_prompt(player)
|
result = render_prompt(player)
|
||||||
assert result == "[telegraph] > "
|
assert result == "[pending] > "
|
||||||
|
|
||||||
|
|
||||||
def test_terrain_variable_grass():
|
def test_terrain_variable_grass():
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ async def test_attack_blocked_in_safe_zone(safe_zone, mock_writer, mock_reader):
|
||||||
variant="left",
|
variant="left",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5,
|
stamina_cost=5,
|
||||||
timing_window_ms=850,
|
hit_time_ms=850,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -227,7 +227,7 @@ async def test_attack_allowed_in_unsafe_zone(unsafe_zone, mock_writer, mock_read
|
||||||
variant="left",
|
variant="left",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5,
|
stamina_cost=5,
|
||||||
timing_window_ms=850,
|
hit_time_ms=850,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ async def test_stamina_cue_after_combat_damage(player, defender):
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
damage_pct=0.5,
|
damage_pct=0.5,
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
timing_window_ms=100,
|
hit_time_ms=100,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start encounter
|
# Start encounter
|
||||||
|
|
@ -141,7 +141,7 @@ async def test_stamina_cue_after_attack_cost(player, defender):
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
damage_pct=0.3,
|
damage_pct=0.3,
|
||||||
stamina_cost=60.0,
|
stamina_cost=60.0,
|
||||||
timing_window_ms=1000,
|
hit_time_ms=1000,
|
||||||
)
|
)
|
||||||
|
|
||||||
player.stamina = 100.0
|
player.stamina = 100.0
|
||||||
|
|
@ -171,15 +171,17 @@ async def test_stamina_cue_after_defense_cost(player):
|
||||||
command="duck",
|
command="duck",
|
||||||
move_type="defense",
|
move_type="defense",
|
||||||
stamina_cost=30.0,
|
stamina_cost=30.0,
|
||||||
timing_window_ms=100,
|
active_ms=100,
|
||||||
|
recovery_ms=2700,
|
||||||
)
|
)
|
||||||
|
|
||||||
player.stamina = 100.0
|
player.stamina = 100.0
|
||||||
player.max_stamina = 100.0
|
player.max_stamina = 100.0
|
||||||
|
|
||||||
with patch("mudlib.combat.commands.check_stamina_cues") as mock_check:
|
with patch("mudlib.combat.commands.check_stamina_cues") as mock_check:
|
||||||
# Use a short timing window to avoid blocking too long
|
# Use a short active window to avoid blocking too long
|
||||||
move.timing_window_ms = 10
|
move.active_ms = 10
|
||||||
|
move.recovery_ms = 10
|
||||||
await do_defend(player, "", move)
|
await do_defend(player, "", move)
|
||||||
|
|
||||||
# check_stamina_cues should have been called
|
# check_stamina_cues should have been called
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ def punch():
|
||||||
name="punch left",
|
name="punch left",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
countered_by=["dodge right"],
|
countered_by=["dodge right"],
|
||||||
telegraph="{attacker} retracts {his} left arm...",
|
telegraph="{attacker} retracts {his} left arm...",
|
||||||
|
|
@ -51,8 +51,8 @@ def punch():
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_announce_sent_on_telegraph_to_window_transition(punch):
|
async def test_announce_sent_on_pending_to_resolve_transition(punch):
|
||||||
"""Test announce message sent when TELEGRAPH→WINDOW transition occurs."""
|
"""Test announce message sent when PENDING→RESOLVE transition occurs."""
|
||||||
atk_writer = _mock_writer()
|
atk_writer = _mock_writer()
|
||||||
def_writer = _mock_writer()
|
def_writer = _mock_writer()
|
||||||
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer)
|
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer)
|
||||||
|
|
@ -63,19 +63,19 @@ async def test_announce_sent_on_telegraph_to_window_transition(punch):
|
||||||
encounter = start_encounter(attacker, defender)
|
encounter = start_encounter(attacker, defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
|
|
||||||
# Should be in TELEGRAPH state
|
# Should be in PENDING state
|
||||||
assert encounter.state == CombatState.TELEGRAPH
|
assert encounter.state == CombatState.PENDING
|
||||||
|
|
||||||
# Reset mocks to ignore previous messages
|
# Reset mocks to ignore previous messages
|
||||||
atk_writer.write.reset_mock()
|
atk_writer.write.reset_mock()
|
||||||
def_writer.write.reset_mock()
|
def_writer.write.reset_mock()
|
||||||
|
|
||||||
# Wait for telegraph phase and process
|
# Wait for hit time (800ms) and process
|
||||||
time.sleep(0.31)
|
time.sleep(0.85)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
# Should have transitioned to WINDOW
|
# Should have auto-resolved and returned to IDLE
|
||||||
assert encounter.state == CombatState.WINDOW
|
assert encounter.state == CombatState.IDLE
|
||||||
|
|
||||||
# Check announce message was sent
|
# Check announce message was sent
|
||||||
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
|
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
|
||||||
|
|
@ -103,7 +103,8 @@ async def test_announce_uses_pov_templates(punch):
|
||||||
atk_writer.write.reset_mock()
|
atk_writer.write.reset_mock()
|
||||||
def_writer.write.reset_mock()
|
def_writer.write.reset_mock()
|
||||||
|
|
||||||
time.sleep(0.31)
|
# Wait for hit time (800ms) to trigger announce
|
||||||
|
time.sleep(0.85)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
|
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
|
||||||
|
|
@ -128,14 +129,7 @@ async def test_resolve_uses_pov_templates(punch):
|
||||||
encounter = start_encounter(attacker, defender)
|
encounter = start_encounter(attacker, defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
|
|
||||||
# Advance to window
|
# Advance to resolve (announce and resolve happen on same tick)
|
||||||
time.sleep(0.31)
|
|
||||||
await process_combat()
|
|
||||||
|
|
||||||
atk_writer.write.reset_mock()
|
|
||||||
def_writer.write.reset_mock()
|
|
||||||
|
|
||||||
# Advance to resolve
|
|
||||||
time.sleep(0.85)
|
time.sleep(0.85)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
|
|
@ -162,20 +156,18 @@ async def test_resolve_uses_resolve_miss_on_counter(punch):
|
||||||
name="dodge right",
|
name="dodge right",
|
||||||
move_type="defense",
|
move_type="defense",
|
||||||
stamina_cost=3.0,
|
stamina_cost=3.0,
|
||||||
timing_window_ms=800,
|
active_ms=1000, # Longer than hit_time to still be active at resolve
|
||||||
|
recovery_ms=2700,
|
||||||
)
|
)
|
||||||
|
|
||||||
encounter = start_encounter(attacker, defender)
|
encounter = start_encounter(attacker, defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
encounter.defend(dodge)
|
encounter.defend(dodge)
|
||||||
|
|
||||||
# Advance to resolve
|
|
||||||
time.sleep(0.31)
|
|
||||||
await process_combat()
|
|
||||||
|
|
||||||
atk_writer.write.reset_mock()
|
atk_writer.write.reset_mock()
|
||||||
def_writer.write.reset_mock()
|
def_writer.write.reset_mock()
|
||||||
|
|
||||||
|
# Advance to resolve (defense is already active)
|
||||||
time.sleep(0.85)
|
time.sleep(0.85)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
|
|
@ -220,7 +212,8 @@ async def test_announce_color_applied(punch):
|
||||||
atk_writer.write.reset_mock()
|
atk_writer.write.reset_mock()
|
||||||
def_writer.write.reset_mock()
|
def_writer.write.reset_mock()
|
||||||
|
|
||||||
time.sleep(0.31)
|
# Wait for hit time to trigger announce
|
||||||
|
time.sleep(0.85)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
|
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
|
||||||
|
|
@ -260,13 +253,10 @@ async def test_resolve_bold_color_applied(punch):
|
||||||
encounter = start_encounter(attacker, defender)
|
encounter = start_encounter(attacker, defender)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
|
|
||||||
# Advance to resolve
|
|
||||||
time.sleep(0.31)
|
|
||||||
await process_combat()
|
|
||||||
|
|
||||||
atk_writer.write.reset_mock()
|
atk_writer.write.reset_mock()
|
||||||
def_writer.write.reset_mock()
|
def_writer.write.reset_mock()
|
||||||
|
|
||||||
|
# Advance to resolve
|
||||||
time.sleep(0.85)
|
time.sleep(0.85)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
|
|
@ -280,8 +270,8 @@ async def test_resolve_bold_color_applied(punch):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_announce_on_idle_to_telegraph():
|
async def test_no_announce_on_idle_to_pending():
|
||||||
"""Test no announce message sent on IDLE→TELEGRAPH transition."""
|
"""Test no announce message sent on IDLE→PENDING transition."""
|
||||||
atk_writer = _mock_writer()
|
atk_writer = _mock_writer()
|
||||||
def_writer = _mock_writer()
|
def_writer = _mock_writer()
|
||||||
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer)
|
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer)
|
||||||
|
|
@ -293,7 +283,7 @@ async def test_no_announce_on_idle_to_telegraph():
|
||||||
name="punch left",
|
name="punch left",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=5.0,
|
stamina_cost=5.0,
|
||||||
timing_window_ms=800,
|
hit_time_ms=800,
|
||||||
damage_pct=0.15,
|
damage_pct=0.15,
|
||||||
announce="{attacker} throw{s} a left hook at {defender}!",
|
announce="{attacker} throw{s} a left hook at {defender}!",
|
||||||
)
|
)
|
||||||
|
|
@ -305,7 +295,7 @@ async def test_no_announce_on_idle_to_telegraph():
|
||||||
|
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
|
|
||||||
# Process combat immediately (still in TELEGRAPH)
|
# Process combat immediately (still in PENDING)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
|
atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list]
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,7 @@ async def test_knockout_ends_combat(clear_state, mock_writer):
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
damage_pct=0.5,
|
damage_pct=0.5,
|
||||||
stamina_cost=10.0,
|
stamina_cost=10.0,
|
||||||
timing_window_ms=850,
|
hit_time_ms=850,
|
||||||
countered_by=[],
|
countered_by=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -266,7 +266,7 @@ async def test_defender_stamina_ko_ends_combat(clear_state, mock_writer):
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
damage_pct=0.5,
|
damage_pct=0.5,
|
||||||
stamina_cost=10.0,
|
stamina_cost=10.0,
|
||||||
timing_window_ms=850,
|
hit_time_ms=850,
|
||||||
countered_by=[],
|
countered_by=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ def test_combat_move_with_unlock_condition():
|
||||||
name="roundhouse",
|
name="roundhouse",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=20.0,
|
stamina_cost=20.0,
|
||||||
timing_window_ms=500,
|
hit_time_ms=500,
|
||||||
unlock_condition=condition,
|
unlock_condition=condition,
|
||||||
)
|
)
|
||||||
assert move.unlock_condition is condition
|
assert move.unlock_condition is condition
|
||||||
|
|
@ -53,7 +53,7 @@ def test_combat_move_unlock_condition_defaults_to_none():
|
||||||
name="punch",
|
name="punch",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=10.0,
|
stamina_cost=10.0,
|
||||||
timing_window_ms=300,
|
hit_time_ms=300,
|
||||||
)
|
)
|
||||||
assert move.unlock_condition is None
|
assert move.unlock_condition is None
|
||||||
|
|
||||||
|
|
@ -68,7 +68,7 @@ def test_check_unlocks_grants_move_on_kill_threshold():
|
||||||
name="roundhouse",
|
name="roundhouse",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=20.0,
|
stamina_cost=20.0,
|
||||||
timing_window_ms=500,
|
hit_time_ms=500,
|
||||||
command="roundhouse",
|
command="roundhouse",
|
||||||
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
||||||
)
|
)
|
||||||
|
|
@ -90,7 +90,7 @@ def test_check_unlocks_grants_move_on_mob_kills_threshold():
|
||||||
name="goblin_slayer",
|
name="goblin_slayer",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=15.0,
|
stamina_cost=15.0,
|
||||||
timing_window_ms=400,
|
hit_time_ms=400,
|
||||||
command="goblin_slayer",
|
command="goblin_slayer",
|
||||||
unlock_condition=UnlockCondition(
|
unlock_condition=UnlockCondition(
|
||||||
type="mob_kills", mob_name="goblin", threshold=3
|
type="mob_kills", mob_name="goblin", threshold=3
|
||||||
|
|
@ -114,7 +114,7 @@ def test_check_unlocks_does_not_unlock_when_threshold_not_met():
|
||||||
name="roundhouse",
|
name="roundhouse",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=20.0,
|
stamina_cost=20.0,
|
||||||
timing_window_ms=500,
|
hit_time_ms=500,
|
||||||
command="roundhouse",
|
command="roundhouse",
|
||||||
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
||||||
)
|
)
|
||||||
|
|
@ -136,7 +136,7 @@ def test_check_unlocks_returns_empty_list_if_nothing_new():
|
||||||
name="roundhouse",
|
name="roundhouse",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=20.0,
|
stamina_cost=20.0,
|
||||||
timing_window_ms=500,
|
hit_time_ms=500,
|
||||||
command="roundhouse",
|
command="roundhouse",
|
||||||
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
||||||
)
|
)
|
||||||
|
|
@ -153,7 +153,7 @@ def test_load_toml_with_unlock_condition():
|
||||||
name = "roundhouse"
|
name = "roundhouse"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 20.0
|
stamina_cost = 20.0
|
||||||
timing_window_ms = 500
|
hit_time_ms = 500
|
||||||
|
|
||||||
[unlock]
|
[unlock]
|
||||||
type = "kill_count"
|
type = "kill_count"
|
||||||
|
|
@ -181,7 +181,7 @@ def test_load_toml_without_unlock_section():
|
||||||
name = "punch"
|
name = "punch"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 10.0
|
stamina_cost = 10.0
|
||||||
timing_window_ms = 300
|
hit_time_ms = 300
|
||||||
"""
|
"""
|
||||||
with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
f.write(toml_content)
|
f.write(toml_content)
|
||||||
|
|
@ -202,7 +202,7 @@ def test_load_toml_with_mob_kills_unlock():
|
||||||
name = "goblin_slayer"
|
name = "goblin_slayer"
|
||||||
move_type = "attack"
|
move_type = "attack"
|
||||||
stamina_cost = 15.0
|
stamina_cost = 15.0
|
||||||
timing_window_ms = 400
|
hit_time_ms = 400
|
||||||
|
|
||||||
[unlock]
|
[unlock]
|
||||||
type = "mob_kills"
|
type = "mob_kills"
|
||||||
|
|
@ -238,7 +238,7 @@ async def test_gating_locked_move_rejected_in_do_attack():
|
||||||
name="roundhouse",
|
name="roundhouse",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=20.0,
|
stamina_cost=20.0,
|
||||||
timing_window_ms=500,
|
hit_time_ms=500,
|
||||||
command="roundhouse",
|
command="roundhouse",
|
||||||
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
||||||
)
|
)
|
||||||
|
|
@ -265,7 +265,7 @@ async def test_gating_unlocked_move_works_normally():
|
||||||
name="roundhouse",
|
name="roundhouse",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=20.0,
|
stamina_cost=20.0,
|
||||||
timing_window_ms=500,
|
hit_time_ms=500,
|
||||||
command="roundhouse",
|
command="roundhouse",
|
||||||
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
||||||
)
|
)
|
||||||
|
|
@ -295,7 +295,7 @@ async def test_gating_move_without_unlock_condition_always_works():
|
||||||
name="punch",
|
name="punch",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=10.0,
|
stamina_cost=10.0,
|
||||||
timing_window_ms=300,
|
hit_time_ms=300,
|
||||||
command="punch",
|
command="punch",
|
||||||
unlock_condition=None, # no unlock needed
|
unlock_condition=None, # no unlock needed
|
||||||
)
|
)
|
||||||
|
|
@ -322,7 +322,7 @@ def test_check_unlocks_deduplicates_variants():
|
||||||
name="punch left",
|
name="punch left",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=10.0,
|
stamina_cost=10.0,
|
||||||
timing_window_ms=300,
|
hit_time_ms=300,
|
||||||
command="punch",
|
command="punch",
|
||||||
variant="left",
|
variant="left",
|
||||||
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
||||||
|
|
@ -331,7 +331,7 @@ def test_check_unlocks_deduplicates_variants():
|
||||||
name="punch right",
|
name="punch right",
|
||||||
move_type="attack",
|
move_type="attack",
|
||||||
stamina_cost=10.0,
|
stamina_cost=10.0,
|
||||||
timing_window_ms=300,
|
hit_time_ms=300,
|
||||||
command="punch",
|
command="punch",
|
||||||
variant="right",
|
variant="right",
|
||||||
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue