Change combat flow: KO persists, timeout requires no landed damage

This commit is contained in:
Jared Miller 2026-02-15 12:40:21 -05:00
parent f40ee68f9a
commit a4a866a77c
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
6 changed files with 177 additions and 145 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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