Change combat flow: KO persists, timeout requires no landed damage
This commit is contained in:
parent
f40ee68f9a
commit
a4a866a77c
6 changed files with 177 additions and 145 deletions
|
|
@ -20,7 +20,7 @@ class CombatState(Enum):
|
||||||
# Telegraph phase duration in seconds (3 game ticks at 100ms/tick)
|
# Telegraph phase duration in seconds (3 game ticks at 100ms/tick)
|
||||||
TELEGRAPH_DURATION = 0.3
|
TELEGRAPH_DURATION = 0.3
|
||||||
|
|
||||||
# Seconds of no action before combat fizzles out
|
# Seconds since last landed damage before combat fizzles out
|
||||||
IDLE_TIMEOUT = 30.0
|
IDLE_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,6 +44,7 @@ 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
|
||||||
|
# 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:
|
||||||
|
|
@ -70,7 +71,6 @@ class CombatEncounter:
|
||||||
|
|
||||||
self.current_move = move
|
self.current_move = move
|
||||||
self.attacker.stamina -= move.stamina_cost
|
self.attacker.stamina -= move.stamina_cost
|
||||||
self.last_action_at = now
|
|
||||||
if self.state == CombatState.IDLE:
|
if self.state == CombatState.IDLE:
|
||||||
self.state = CombatState.TELEGRAPH
|
self.state = CombatState.TELEGRAPH
|
||||||
|
|
||||||
|
|
@ -83,7 +83,6 @@ class CombatEncounter:
|
||||||
move: The defense move to attempt
|
move: The defense move to attempt
|
||||||
"""
|
"""
|
||||||
self.pending_defense = move
|
self.pending_defense = move
|
||||||
self.last_action_at = time.monotonic()
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
@ -157,7 +156,7 @@ class CombatEncounter:
|
||||||
elif self.pending_defense:
|
elif self.pending_defense:
|
||||||
# Wrong defense - normal damage
|
# Wrong defense - normal damage
|
||||||
damage = self.attacker.pl * self.current_move.damage_pct
|
damage = self.attacker.pl * self.current_move.damage_pct
|
||||||
self.defender.pl -= damage
|
self.defender.pl = max(0.0, self.defender.pl - damage)
|
||||||
template = (
|
template = (
|
||||||
self.current_move.resolve_hit
|
self.current_move.resolve_hit
|
||||||
if self.current_move.resolve_hit
|
if self.current_move.resolve_hit
|
||||||
|
|
@ -167,7 +166,7 @@ class CombatEncounter:
|
||||||
else:
|
else:
|
||||||
# No defense - increased damage
|
# No defense - increased damage
|
||||||
damage = self.attacker.pl * self.current_move.damage_pct * 1.5
|
damage = self.attacker.pl * self.current_move.damage_pct * 1.5
|
||||||
self.defender.pl -= damage
|
self.defender.pl = max(0.0, self.defender.pl - damage)
|
||||||
template = (
|
template = (
|
||||||
self.current_move.resolve_hit
|
self.current_move.resolve_hit
|
||||||
if self.current_move.resolve_hit
|
if self.current_move.resolve_hit
|
||||||
|
|
@ -175,8 +174,9 @@ class CombatEncounter:
|
||||||
)
|
)
|
||||||
countered = False
|
countered = False
|
||||||
|
|
||||||
# Check for combat end conditions
|
if damage > 0:
|
||||||
combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0
|
self.last_action_at = time.monotonic()
|
||||||
|
combat_ended = False
|
||||||
|
|
||||||
# Reset to IDLE
|
# Reset to IDLE
|
||||||
self.state = CombatState.IDLE
|
self.state = CombatState.IDLE
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import time
|
||||||
|
|
||||||
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
|
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
|
||||||
from mudlib.combat.stamina import check_stamina_cues
|
from mudlib.combat.stamina import check_stamina_cues
|
||||||
from mudlib.entity import Entity, Mob
|
from mudlib.entity import Entity
|
||||||
from mudlib.gmcp import send_char_status, send_char_vitals
|
from mudlib.gmcp import send_char_status, send_char_vitals
|
||||||
from mudlib.render.colors import colorize
|
from mudlib.render.colors import colorize
|
||||||
from mudlib.render.pov import render_pov
|
from mudlib.render.pov import render_pov
|
||||||
|
|
@ -89,7 +89,7 @@ async def process_combat() -> None:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|
||||||
for encounter in active_encounters[:]: # Copy list to allow modification
|
for encounter in active_encounters[:]: # Copy list to allow modification
|
||||||
# Check for idle timeout
|
# Check for no-damage timeout.
|
||||||
if now - encounter.last_action_at > IDLE_TIMEOUT:
|
if now - encounter.last_action_at > IDLE_TIMEOUT:
|
||||||
await encounter.attacker.send("Combat has fizzled out.\r\n")
|
await encounter.attacker.send("Combat has fizzled out.\r\n")
|
||||||
await encounter.defender.send("Combat has fizzled out.\r\n")
|
await encounter.defender.send("Combat has fizzled out.\r\n")
|
||||||
|
|
@ -156,67 +156,3 @@ async def process_combat() -> None:
|
||||||
# Check stamina cues after damage
|
# Check stamina cues after damage
|
||||||
await check_stamina_cues(encounter.attacker)
|
await check_stamina_cues(encounter.attacker)
|
||||||
await check_stamina_cues(encounter.defender)
|
await check_stamina_cues(encounter.defender)
|
||||||
|
|
||||||
if result.combat_ended:
|
|
||||||
# Determine winner/loser
|
|
||||||
if encounter.defender.pl <= 0:
|
|
||||||
loser = encounter.defender
|
|
||||||
winner = encounter.attacker
|
|
||||||
else:
|
|
||||||
loser = encounter.attacker
|
|
||||||
winner = encounter.defender
|
|
||||||
|
|
||||||
# Track kill/death stats
|
|
||||||
if isinstance(winner, Player):
|
|
||||||
winner.kills += 1
|
|
||||||
if isinstance(loser, Mob):
|
|
||||||
winner.mob_kills[loser.name] = (
|
|
||||||
winner.mob_kills.get(loser.name, 0) + 1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for new unlocks
|
|
||||||
from mudlib.combat.commands import combat_moves
|
|
||||||
from mudlib.combat.unlock import check_unlocks
|
|
||||||
|
|
||||||
newly_unlocked = check_unlocks(winner, combat_moves)
|
|
||||||
for move_name in newly_unlocked:
|
|
||||||
await winner.send(f"You have learned {move_name}!\r\n")
|
|
||||||
|
|
||||||
if isinstance(loser, Player):
|
|
||||||
loser.deaths += 1
|
|
||||||
|
|
||||||
# Despawn mob losers, send victory/defeat messages
|
|
||||||
if isinstance(loser, Mob):
|
|
||||||
from mudlib.corpse import create_corpse
|
|
||||||
from mudlib.mobs import mob_templates
|
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
zone = loser.location
|
|
||||||
if isinstance(zone, Zone):
|
|
||||||
# Look up loot table from mob template
|
|
||||||
template = mob_templates.get(loser.name)
|
|
||||||
loot_table = template.loot if template else None
|
|
||||||
create_corpse(loser, zone, loot_table=loot_table)
|
|
||||||
else:
|
|
||||||
from mudlib.mobs import despawn_mob
|
|
||||||
|
|
||||||
despawn_mob(loser)
|
|
||||||
await winner.send(f"You have defeated the {loser.name}!\r\n")
|
|
||||||
elif isinstance(winner, Mob):
|
|
||||||
await loser.send(
|
|
||||||
f"You have been defeated by the {winner.name}!\r\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pop combat mode from both entities if they're Players
|
|
||||||
attacker = encounter.attacker
|
|
||||||
if isinstance(attacker, Player) and attacker.mode == "combat":
|
|
||||||
attacker.mode_stack.pop()
|
|
||||||
send_char_status(attacker)
|
|
||||||
|
|
||||||
defender = encounter.defender
|
|
||||||
if isinstance(defender, Player) and defender.mode == "combat":
|
|
||||||
defender.mode_stack.pop()
|
|
||||||
send_char_status(defender)
|
|
||||||
|
|
||||||
# Remove encounter from active list
|
|
||||||
end_encounter(encounter)
|
|
||||||
|
|
|
||||||
|
|
@ -239,8 +239,8 @@ def test_combat_state_enum():
|
||||||
assert CombatState.RESOLVE.value == "resolve"
|
assert CombatState.RESOLVE.value == "resolve"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch):
|
def test_resolve_knockout_does_not_end_combat(attacker, defender, punch):
|
||||||
"""Test resolve returns combat_ended=True when defender PL <= 0."""
|
"""KO should not end combat by itself."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
|
||||||
# Set defender to low PL so attack will knock them out
|
# Set defender to low PL so attack will knock them out
|
||||||
|
|
@ -250,12 +250,12 @@ def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch):
|
||||||
result = encounter.resolve()
|
result = encounter.resolve()
|
||||||
|
|
||||||
assert defender.pl <= 0
|
assert defender.pl <= 0
|
||||||
assert result.combat_ended is True
|
assert result.combat_ended is False
|
||||||
assert result.damage > 0
|
assert result.damage > 0
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch):
|
def test_resolve_exhaustion_does_not_end_combat(attacker, defender, punch):
|
||||||
"""Test resolve returns combat_ended=True when attacker stamina <= 0."""
|
"""Exhaustion should not end combat by itself."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
|
||||||
# Set attacker stamina to exactly the cost so attack depletes it
|
# Set attacker stamina to exactly the cost so attack depletes it
|
||||||
|
|
@ -265,7 +265,19 @@ def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch):
|
||||||
result = encounter.resolve()
|
result = encounter.resolve()
|
||||||
|
|
||||||
assert attacker.stamina <= 0
|
assert attacker.stamina <= 0
|
||||||
assert result.combat_ended is True
|
assert result.combat_ended is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_exhausted_defender_does_not_end_combat(attacker, defender, punch):
|
||||||
|
"""Exhausted defender still does not auto-end encounter."""
|
||||||
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
defender.stamina = 0.0
|
||||||
|
|
||||||
|
encounter.attack(punch)
|
||||||
|
result = encounter.resolve()
|
||||||
|
|
||||||
|
assert defender.stamina <= 0
|
||||||
|
assert result.combat_ended is False
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
|
def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
|
||||||
|
|
@ -393,27 +405,47 @@ def test_resolve_uses_final_move(attacker, defender, punch, sweep):
|
||||||
assert result.resolve_template != ""
|
assert result.resolve_template != ""
|
||||||
|
|
||||||
|
|
||||||
# --- last_action_at tracking tests ---
|
# --- last_action_at (last landed damage) tracking tests ---
|
||||||
|
|
||||||
|
|
||||||
def test_last_action_at_updates_on_attack(attacker, defender, punch):
|
def test_last_action_at_not_updated_on_attack(attacker, defender, punch):
|
||||||
"""Test last_action_at is set when attack() is called."""
|
"""Attack startup should not reset timeout until damage lands."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
assert encounter.last_action_at == 0.0
|
assert encounter.last_action_at == 0.0
|
||||||
|
|
||||||
before = time.monotonic()
|
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
|
|
||||||
|
assert encounter.last_action_at == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_action_at_not_updated_on_defend(attacker, defender, punch, dodge):
|
||||||
|
"""Defense input should not reset timeout without landed damage."""
|
||||||
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
encounter.attack(punch)
|
||||||
|
assert encounter.last_action_at == 0.0
|
||||||
|
encounter.defend(dodge)
|
||||||
|
|
||||||
|
assert encounter.last_action_at == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_action_at_updates_when_damage_lands(attacker, defender, punch):
|
||||||
|
"""Landed damage should refresh timeout timestamp."""
|
||||||
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
assert encounter.last_action_at == 0.0
|
||||||
|
encounter.attack(punch)
|
||||||
|
before = time.monotonic()
|
||||||
|
encounter.resolve()
|
||||||
assert encounter.last_action_at >= before
|
assert encounter.last_action_at >= before
|
||||||
|
|
||||||
|
|
||||||
def test_last_action_at_updates_on_defend(attacker, defender, punch, dodge):
|
def test_last_action_at_unchanged_when_attack_is_countered(
|
||||||
"""Test last_action_at is set when defend() is called."""
|
attacker, defender, punch, dodge
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
):
|
||||||
|
"""No damage (successful counter) should not refresh timeout timestamp."""
|
||||||
|
encounter = CombatEncounter(
|
||||||
|
attacker=attacker, defender=defender, last_action_at=10.0
|
||||||
|
)
|
||||||
encounter.attack(punch)
|
encounter.attack(punch)
|
||||||
first_action = encounter.last_action_at
|
|
||||||
|
|
||||||
time.sleep(0.01)
|
|
||||||
encounter.defend(dodge)
|
encounter.defend(dodge)
|
||||||
|
encounter.resolve()
|
||||||
assert encounter.last_action_at > first_action
|
assert encounter.last_action_at == 10.0
|
||||||
|
|
|
||||||
|
|
@ -201,8 +201,8 @@ async def test_encounter_cleanup_after_resolution(attacker, defender, punch):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_combat_ends_encounter_on_knockout(punch):
|
async def test_process_combat_keeps_encounter_after_knockout(punch):
|
||||||
"""Test process_combat ends encounter when defender is knocked out."""
|
"""KO should not end combat; encounter stays active."""
|
||||||
w = _mock_writer
|
w = _mock_writer
|
||||||
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
|
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
|
||||||
defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0, writer=w())
|
defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0, writer=w())
|
||||||
|
|
@ -220,17 +220,16 @@ async def test_process_combat_ends_encounter_on_knockout(punch):
|
||||||
time.sleep(0.85)
|
time.sleep(0.85)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
# Combat should have ended and been cleaned up
|
# Combat should remain active after KO
|
||||||
assert get_encounter(attacker) is None
|
assert get_encounter(attacker) is encounter
|
||||||
assert get_encounter(defender) is None
|
assert get_encounter(defender) is encounter
|
||||||
# Mode stacks should have combat popped
|
assert attacker.mode_stack == ["normal", "combat"]
|
||||||
assert attacker.mode_stack == ["normal"]
|
assert defender.mode_stack == ["normal", "combat"]
|
||||||
assert defender.mode_stack == ["normal"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_combat_ends_encounter_on_exhaustion(punch):
|
async def test_process_combat_keeps_encounter_after_exhaustion(punch):
|
||||||
"""Test process_combat ends encounter when attacker is exhausted."""
|
"""Exhaustion should not end combat; encounter stays active."""
|
||||||
w = _mock_writer
|
w = _mock_writer
|
||||||
attacker = Player(
|
attacker = Player(
|
||||||
name="Goku",
|
name="Goku",
|
||||||
|
|
@ -262,11 +261,68 @@ async def test_process_combat_ends_encounter_on_exhaustion(punch):
|
||||||
time.sleep(0.85)
|
time.sleep(0.85)
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
# Combat should have ended
|
# Combat should remain active
|
||||||
assert get_encounter(attacker) is None
|
assert get_encounter(attacker) is encounter
|
||||||
assert get_encounter(defender) is None
|
assert get_encounter(defender) is encounter
|
||||||
assert attacker.mode_stack == ["normal"]
|
assert attacker.mode_stack == ["normal", "combat"]
|
||||||
assert defender.mode_stack == ["normal"]
|
assert defender.mode_stack == ["normal", "combat"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_combat_keeps_encounter_when_defender_already_exhausted(punch):
|
||||||
|
"""Defender exhaustion should not auto-end encounter."""
|
||||||
|
w = _mock_writer
|
||||||
|
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
|
||||||
|
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=0.0, writer=w())
|
||||||
|
|
||||||
|
attacker.mode_stack.append("combat")
|
||||||
|
defender.mode_stack.append("combat")
|
||||||
|
|
||||||
|
encounter = start_encounter(attacker, defender)
|
||||||
|
encounter.attack(punch)
|
||||||
|
|
||||||
|
time.sleep(0.31)
|
||||||
|
await process_combat()
|
||||||
|
time.sleep(0.85)
|
||||||
|
await process_combat()
|
||||||
|
|
||||||
|
assert get_encounter(attacker) is encounter
|
||||||
|
assert get_encounter(defender) is encounter
|
||||||
|
assert attacker.mode_stack == ["normal", "combat"]
|
||||||
|
assert defender.mode_stack == ["normal", "combat"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_combat_keeps_encounter_when_both_unconscious(punch):
|
||||||
|
"""Double unconscious should not auto-end; timeout/finisher decides."""
|
||||||
|
w = _mock_writer
|
||||||
|
attacker = Player(
|
||||||
|
name="Goku",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
pl=100.0,
|
||||||
|
stamina=punch.stamina_cost,
|
||||||
|
writer=w(),
|
||||||
|
)
|
||||||
|
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=0.0, writer=w())
|
||||||
|
|
||||||
|
attacker.mode_stack.append("combat")
|
||||||
|
defender.mode_stack.append("combat")
|
||||||
|
|
||||||
|
encounter = start_encounter(attacker, defender)
|
||||||
|
encounter.attack(punch)
|
||||||
|
|
||||||
|
time.sleep(0.31)
|
||||||
|
await process_combat()
|
||||||
|
time.sleep(0.85)
|
||||||
|
await process_combat()
|
||||||
|
|
||||||
|
assert get_encounter(attacker) is encounter
|
||||||
|
assert get_encounter(defender) is encounter
|
||||||
|
assert attacker.kills == 0
|
||||||
|
assert defender.kills == 0
|
||||||
|
assert attacker.deaths == 0
|
||||||
|
assert defender.deaths == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -335,7 +391,7 @@ async def test_process_combat_sends_messages_on_resolve(punch):
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_idle_timeout_ends_encounter():
|
async def test_idle_timeout_ends_encounter():
|
||||||
"""Test encounter times out after 30s of no actions."""
|
"""Encounter times out after 30s without landed damage."""
|
||||||
w = _mock_writer
|
w = _mock_writer
|
||||||
attacker = Player(name="Goku", x=0, y=0, writer=w())
|
attacker = Player(name="Goku", x=0, y=0, writer=w())
|
||||||
defender = Player(name="Vegeta", x=0, y=0, writer=w())
|
defender = Player(name="Vegeta", x=0, y=0, writer=w())
|
||||||
|
|
@ -393,7 +449,7 @@ async def test_idle_timeout_pops_combat_mode():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_recent_action_prevents_timeout():
|
async def test_recent_action_prevents_timeout():
|
||||||
"""Test recent action prevents idle timeout."""
|
"""Fresh encounter start prevents immediate timeout."""
|
||||||
w = _mock_writer
|
w = _mock_writer
|
||||||
attacker = Player(name="Goku", x=0, y=0, writer=w())
|
attacker = Player(name="Goku", x=0, y=0, writer=w())
|
||||||
defender = Player(name="Vegeta", x=0, y=0, writer=w())
|
defender = Player(name="Vegeta", x=0, y=0, writer=w())
|
||||||
|
|
@ -411,7 +467,7 @@ async def test_recent_action_prevents_timeout():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_encounter_sets_last_action_at():
|
async def test_start_encounter_sets_last_action_at():
|
||||||
"""Test start_encounter initializes last_action_at."""
|
"""start_encounter initializes no-damage timeout clock."""
|
||||||
attacker = Entity(name="Goku", x=0, y=0)
|
attacker = Entity(name="Goku", x=0, y=0)
|
||||||
defender = Entity(name="Vegeta", x=0, y=0)
|
defender = Entity(name="Vegeta", x=0, y=0)
|
||||||
|
|
||||||
|
|
@ -419,3 +475,25 @@ async def test_start_encounter_sets_last_action_at():
|
||||||
encounter = start_encounter(attacker, defender)
|
encounter = start_encounter(attacker, defender)
|
||||||
|
|
||||||
assert encounter.last_action_at >= before
|
assert encounter.last_action_at >= before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_landed_damage_refreshes_timeout_clock(punch):
|
||||||
|
"""Successful hit should refresh timeout timer."""
|
||||||
|
w = _mock_writer
|
||||||
|
attacker = Player(name="Goku", x=0, y=0, writer=w())
|
||||||
|
defender = Player(name="Vegeta", x=0, y=0, writer=w())
|
||||||
|
attacker.mode_stack.append("combat")
|
||||||
|
defender.mode_stack.append("combat")
|
||||||
|
|
||||||
|
encounter = start_encounter(attacker, defender)
|
||||||
|
# Keep close to timeout, but still allow resolve to land damage first.
|
||||||
|
encounter.last_action_at = time.monotonic() - 28.5
|
||||||
|
encounter.attack(punch)
|
||||||
|
time.sleep(0.31)
|
||||||
|
await process_combat()
|
||||||
|
time.sleep(0.85)
|
||||||
|
await process_combat()
|
||||||
|
|
||||||
|
# A landed hit should keep encounter alive by refreshing the timer.
|
||||||
|
assert get_encounter(attacker) is encounter
|
||||||
|
|
|
||||||
|
|
@ -413,11 +413,9 @@ async def test_char_vitals_sent_on_combat_resolve():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_char_status_sent_on_combat_end():
|
async def test_char_status_sent_on_combat_end():
|
||||||
"""Test Char.Status is sent when combat ends (victory/defeat)."""
|
"""Test Char.Status is sent when combat ends (timeout)."""
|
||||||
import time
|
|
||||||
|
|
||||||
from mudlib.combat.engine import active_encounters, process_combat, start_encounter
|
from mudlib.combat.engine import active_encounters, process_combat, start_encounter
|
||||||
from mudlib.combat.moves import CombatMove
|
|
||||||
|
|
||||||
# Clear encounters
|
# Clear encounters
|
||||||
active_encounters.clear()
|
active_encounters.clear()
|
||||||
|
|
@ -443,22 +441,9 @@ async def test_char_status_sent_on_combat_end():
|
||||||
attacker.mode_stack.append("combat")
|
attacker.mode_stack.append("combat")
|
||||||
defender.mode_stack.append("combat")
|
defender.mode_stack.append("combat")
|
||||||
|
|
||||||
# Create encounter and attack (will kill defender)
|
# Create encounter and force timeout end.
|
||||||
encounter = start_encounter(attacker, defender)
|
encounter = start_encounter(attacker, defender)
|
||||||
punch = CombatMove(
|
encounter.last_action_at -= 31.0
|
||||||
name="punch right",
|
|
||||||
move_type="attack",
|
|
||||||
stamina_cost=5.0,
|
|
||||||
timing_window_ms=800,
|
|
||||||
damage_pct=0.15,
|
|
||||||
countered_by=["dodge left"],
|
|
||||||
)
|
|
||||||
encounter.attack(punch)
|
|
||||||
|
|
||||||
# Advance past telegraph and window to trigger resolution
|
|
||||||
time.sleep(0.31)
|
|
||||||
await process_combat()
|
|
||||||
time.sleep(0.85)
|
|
||||||
|
|
||||||
# Reset mocks before the resolution call
|
# Reset mocks before the resolution call
|
||||||
mock_writer_1.send_gmcp.reset_mock()
|
mock_writer_1.send_gmcp.reset_mock()
|
||||||
|
|
|
||||||
|
|
@ -388,8 +388,8 @@ class TestMobDefeat:
|
||||||
return spawn_mob(template, 0, 0, test_zone)
|
return spawn_mob(template, 0, 0, test_zone)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
|
async def test_mob_not_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
|
||||||
"""Mob with PL <= 0 gets despawned after combat resolves."""
|
"""KO does not despawn mob without an explicit finisher."""
|
||||||
from mudlib.combat.engine import process_combat, start_encounter
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
encounter = start_encounter(player, goblin_mob)
|
encounter = start_encounter(player, goblin_mob)
|
||||||
|
|
@ -404,12 +404,14 @@ class TestMobDefeat:
|
||||||
|
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
assert goblin_mob not in mobs
|
assert goblin_mob in mobs
|
||||||
assert goblin_mob.alive is False
|
assert goblin_mob.alive is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_player_gets_victory_message(self, player, goblin_mob, punch_right):
|
async def test_player_gets_no_victory_message_on_ko(
|
||||||
"""Player receives a victory message when mob is defeated."""
|
self, player, goblin_mob, punch_right
|
||||||
|
):
|
||||||
|
"""KO should not be treated as a defeat/kill message."""
|
||||||
from mudlib.combat.engine import process_combat, start_encounter
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
encounter = start_encounter(player, goblin_mob)
|
encounter = start_encounter(player, goblin_mob)
|
||||||
|
|
@ -422,29 +424,30 @@ class TestMobDefeat:
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
assert any("defeated" in msg.lower() for msg in messages)
|
assert not any("defeated" in msg.lower() for msg in messages)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_stamina_depleted_despawns(self, player, goblin_mob, punch_right):
|
async def test_exhaustion_does_not_end_encounter(
|
||||||
"""Mob is despawned when attacker stamina depleted (combat end)."""
|
self, player, goblin_mob, punch_right
|
||||||
|
):
|
||||||
|
"""Attacker exhaustion does not auto-end combat."""
|
||||||
from mudlib.combat.engine import process_combat, start_encounter
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
encounter = start_encounter(player, goblin_mob)
|
encounter = start_encounter(player, goblin_mob)
|
||||||
player.mode_stack.append("combat")
|
player.mode_stack.append("combat")
|
||||||
|
|
||||||
# Drain player stamina so combat ends on exhaustion
|
# Drain player stamina before resolve
|
||||||
player.stamina = 0.0
|
player.stamina = 0.0
|
||||||
encounter.attack(punch_right)
|
encounter.attack(punch_right)
|
||||||
encounter.state = CombatState.RESOLVE
|
encounter.state = CombatState.RESOLVE
|
||||||
|
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
# Encounter should have ended
|
assert get_encounter(player) is encounter
|
||||||
assert get_encounter(player) is None
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_player_defeat_not_despawned(self, player, goblin_mob, punch_right):
|
async def test_player_ko_not_despawned(self, player, goblin_mob, punch_right):
|
||||||
"""When player loses, player is not despawned."""
|
"""When player is KO'd, player remains present."""
|
||||||
from mudlib.combat.engine import process_combat, start_encounter
|
from mudlib.combat.engine import process_combat, start_encounter
|
||||||
|
|
||||||
# Mob attacks player — mob is attacker, player is defender
|
# Mob attacks player — mob is attacker, player is defender
|
||||||
|
|
@ -457,10 +460,8 @@ class TestMobDefeat:
|
||||||
|
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
|
||||||
# Player should get defeat message, not be despawned
|
|
||||||
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
assert any(
|
assert len(messages) > 0
|
||||||
"defeated" in msg.lower() or "damage" in msg.lower() for msg in messages
|
assert not any("defeated" in msg.lower() for msg in messages)
|
||||||
)
|
|
||||||
# Player is still in players dict (not removed)
|
# Player is still in players dict (not removed)
|
||||||
assert player.name in players
|
assert player.name in players
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue