Make snapneck the explicit kill/death/corpse finisher path

This commit is contained in:
Jared Miller 2026-02-15 12:40:25 -05:00
parent a4a866a77c
commit e0406e39e5
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 164 additions and 118 deletions

View file

@ -2,7 +2,7 @@
from mudlib.combat.engine import end_encounter, get_encounter from mudlib.combat.engine import end_encounter, get_encounter
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.player import Player, players from mudlib.player import Player
DEATH_PL = -100.0 DEATH_PL = -100.0
@ -14,37 +14,32 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
player: The player executing the command player: The player executing the command
args: Target name args: Target name
""" """
# Get encounter
encounter = get_encounter(player)
if encounter is None:
await player.send("You're not in combat.\r\n")
return
# Parse target # Parse target
target_name = args.strip() target_name = args.strip()
if not target_name: if not target_name:
await player.send("Snap whose neck?\r\n") await player.send("Snap whose neck?\r\n")
return return
# Find target # Must be used during an active encounter.
target = players.get(target_name) encounter = get_encounter(player)
if target is None and player.location is not None: if encounter is None:
from mudlib.mobs import get_nearby_mob await player.send("You're not in combat.\r\n")
from mudlib.zone import Zone return
if isinstance(player.location, Zone): # Find target on this tile.
target = get_nearby_mob(target_name, player.x, player.y, player.location) from mudlib.targeting import find_entity_on_tile
target = find_entity_on_tile(target_name, player)
if target is None: if target is None:
await player.send(f"You don't see {target_name} here.\r\n") await player.send(f"You don't see {target_name} here.\r\n")
return return
# Verify target is in the encounter if target is player:
if encounter.attacker is not player and encounter.defender is not player: await player.send("You can't do that to yourself.\r\n")
await player.send("You're not in combat with that target.\r\n")
return return
if encounter.attacker is not target and encounter.defender is not target: # Snap neck can only target your current opponent.
if target not in (encounter.attacker, encounter.defender):
await player.send("You're not in combat with that target.\r\n") await player.send("You're not in combat with that target.\r\n")
return return
@ -64,16 +59,38 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
from mudlib.entity import Mob from mudlib.entity import Mob
from mudlib.gmcp import send_char_vitals from mudlib.gmcp import send_char_vitals
if not isinstance(target, Mob): if isinstance(target, Player):
send_char_vitals(target) send_char_vitals(target)
# Handle mob despawn # Award kill/death stats on explicit finishers only.
if isinstance(target, Mob): player.kills += 1
from mudlib.mobs import despawn_mob if isinstance(target, Player):
target.deaths += 1
elif isinstance(target, Mob):
player.mob_kills[target.name] = player.mob_kills.get(target.name, 0) + 1
# Check for newly unlocked moves after a finisher kill.
from mudlib.combat.commands import combat_moves
from mudlib.combat.unlock import check_unlocks
newly_unlocked = check_unlocks(player, combat_moves)
for move_name in newly_unlocked:
await player.send(f"You have learned {move_name}!\r\n")
# Handle mob corpse/death
if isinstance(target, Mob):
from mudlib.corpse import create_corpse
from mudlib.mobs import despawn_mob, mob_templates
from mudlib.zone import Zone
zone = target.location
if isinstance(zone, Zone):
template = mob_templates.get(target.name)
loot_table = template.loot if template else None
create_corpse(target, zone, loot_table=loot_table)
else:
despawn_mob(target) despawn_mob(target)
# Pop combat mode from both entities if they're Players # Pop combat mode from both entities.
from mudlib.gmcp import send_char_status from mudlib.gmcp import send_char_status
if isinstance(player, Player) and player.mode == "combat": if isinstance(player, Player) and player.mode == "combat":

View file

@ -219,7 +219,7 @@ class TestCorpseAsContainer:
class TestCombatDeathCorpse: class TestCombatDeathCorpse:
"""Tests for corpse spawning when a mob dies in combat.""" """Knockouts do not create corpses until a finisher is used."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clear_corpses(self): def clear_corpses(self):
@ -230,8 +230,8 @@ class TestCombatDeathCorpse:
active_corpses.clear() active_corpses.clear()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_death_in_combat_spawns_corpse(self, test_zone): async def test_mob_knockout_in_combat_does_not_spawn_corpse(self, test_zone):
"""Mob death in combat spawns a corpse at mob's position.""" """KO in combat should not create a corpse by itself."""
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import ( from mudlib.combat.engine import (
active_encounters, active_encounters,
@ -283,16 +283,17 @@ class TestCombatDeathCorpse:
# Process combat to trigger resolve # Process combat to trigger resolve
await process_combat() await process_combat()
# Check for corpse at mob's position # Check no corpse spawned yet
corpses = [ corpses = [
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse) obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
] ]
assert len(corpses) == 1 assert len(corpses) == 0
assert corpses[0].name == "goblin's corpse" assert mob in mobs
assert mob.alive is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_death_transfers_inventory_to_corpse(self, test_zone, sword): async def test_mob_knockout_keeps_inventory_on_mob(self, test_zone, sword):
"""Mob death transfers inventory to corpse.""" """KO should not transfer inventory to a corpse until finished."""
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import ( from mudlib.combat.engine import (
active_encounters, active_encounters,
@ -343,20 +344,17 @@ class TestCombatDeathCorpse:
# Process combat # Process combat
await process_combat() await process_combat()
# Find corpse # No corpse yet
corpses = [ corpses = [
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse) obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
] ]
assert len(corpses) == 1 assert len(corpses) == 0
corpse = corpses[0] assert sword in mob._contents
assert sword.location is mob
# Verify sword is in corpse
assert sword in corpse._contents
assert sword.location is corpse
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_corpse_appears_in_zone_contents(self, test_zone): async def test_no_corpse_in_zone_contents_after_ko(self, test_zone):
"""Corpse appears in zone.contents_at after mob death.""" """Zone should not contain corpse from a plain KO."""
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import ( from mudlib.combat.engine import (
active_encounters, active_encounters,
@ -406,14 +404,50 @@ class TestCombatDeathCorpse:
# Process combat # Process combat
await process_combat() await process_combat()
# Verify corpse is in zone contents # Verify no corpse in zone contents
contents = list(test_zone.contents_at(5, 10)) contents = list(test_zone.contents_at(5, 10))
corpse_count = sum(1 for obj in contents if isinstance(obj, Corpse)) corpse_count = sum(1 for obj in contents if isinstance(obj, Corpse))
assert corpse_count == 1 assert corpse_count == 0
# Verify it's the goblin's corpse @pytest.mark.asyncio
corpse = next(obj for obj in contents if isinstance(obj, Corpse)) async def test_snapneck_finisher_spawns_corpse(self, test_zone):
assert corpse.name == "goblin's corpse" """Explicit finisher kill should create a corpse."""
from unittest.mock import AsyncMock
from mudlib.commands.snapneck import cmd_snap_neck
from mudlib.player import Player, players
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
attacker = Player(name="hero", x=5, y=10, reader=reader, writer=writer)
attacker.location = test_zone
test_zone._contents.append(attacker)
players[attacker.name] = attacker
mob = Mob(
name="goblin",
x=5,
y=10,
location=test_zone,
pl=0.0,
stamina=0.0,
)
mobs.append(mob)
from mudlib.combat.engine import start_encounter
start_encounter(attacker, mob)
attacker.mode_stack.append("combat")
await cmd_snap_neck(attacker, "goblin")
corpses = [
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
]
assert len(corpses) == 1
assert corpses[0].name == "goblin's corpse"
players.clear()
class TestCorpseDisplay: class TestCorpseDisplay:

View file

@ -4,54 +4,25 @@ import time
import pytest import pytest
from mudlib.combat.engine import ( from mudlib.combat.engine import start_encounter
process_combat, from mudlib.commands.snapneck import cmd_snap_neck
start_encounter,
)
from mudlib.combat.moves import CombatMove
from mudlib.entity import Mob from mudlib.entity import Mob
from mudlib.player import accumulate_play_time from mudlib.player import accumulate_play_time
@pytest.fixture
def punch_move():
"""Create a basic punch move for testing."""
return CombatMove(
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
damage_pct=0.15,
countered_by=[],
resolve_hit="{attacker} hits {defender}!",
resolve_miss="{defender} dodges!",
announce="{attacker} punches!",
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_kills_mob_increments_stats(player, test_zone, punch_move): async def test_player_finishes_mob_increments_stats(player, test_zone):
"""Player kills mob -> kills incremented, mob_kills tracked.""" """Snap-neck kill increments kills and mob_kills."""
# Create a goblin mob # Create a goblin mob
goblin = Mob(name="goblin", x=0, y=0) goblin = Mob(name="goblin", x=0, y=0)
goblin.location = test_zone goblin.location = test_zone
test_zone._contents.append(goblin) test_zone._contents.append(goblin)
# Start encounter # Start encounter and make target unconscious
encounter = start_encounter(player, goblin) start_encounter(player, goblin)
player.mode_stack.append("combat")
# Execute attack goblin.pl = 0.0
encounter.attack(punch_move) await cmd_snap_neck(player, "goblin")
# Advance past telegraph (0.3s) + window (0.8s)
encounter.tick(time.monotonic() + 0.31) # -> WINDOW
encounter.tick(time.monotonic() + 1.2) # -> RESOLVE
# Set defender to very low pl so damage kills them
goblin.pl = 1.0
# Process combat (this will resolve and end encounter)
await process_combat()
# Verify stats # Verify stats
assert player.kills == 1 assert player.kills == 1
@ -59,50 +30,32 @@ async def test_player_kills_mob_increments_stats(player, test_zone, punch_move):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_killed_by_mob_increments_deaths(player, test_zone, punch_move): async def test_player_finished_by_mob_increments_deaths(player, nearby_player):
"""Player killed by mob -> deaths incremented.""" """Snap-neck finisher from opponent increments deaths."""
# Create a goblin mob start_encounter(nearby_player, player)
goblin = Mob(name="goblin", x=0, y=0) nearby_player.mode_stack.append("combat")
goblin.location = test_zone player.mode_stack.append("combat")
test_zone._contents.append(goblin) player.pl = 0.0
await cmd_snap_neck(nearby_player, "Goku")
# Start encounter with mob as attacker
encounter = start_encounter(goblin, player)
# Execute attack
encounter.attack(punch_move)
# Advance to RESOLVE
encounter.tick(time.monotonic() + 0.31)
encounter.tick(time.monotonic() + 1.2)
# Set player to low pl so they die
player.pl = 1.0
# Process combat
await process_combat()
# Verify deaths incremented # Verify deaths incremented
assert player.deaths == 1 assert player.deaths == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_multiple_kills_accumulate(player, test_zone, punch_move): async def test_multiple_finisher_kills_accumulate(player, test_zone):
"""After killing 3 goblins, player.kills == 3, player.mob_kills["goblin"] == 3.""" """After 3 finishers, kill counters accumulate correctly."""
for _ in range(3): for _ in range(3):
# Create goblin # Create goblin
goblin = Mob(name="goblin", x=0, y=0) goblin = Mob(name="goblin", x=0, y=0)
goblin.location = test_zone goblin.location = test_zone
test_zone._contents.append(goblin) test_zone._contents.append(goblin)
# Create and resolve encounter # Create encounter and finish
encounter = start_encounter(player, goblin) start_encounter(player, goblin)
encounter.attack(punch_move) player.mode_stack.append("combat")
encounter.tick(time.monotonic() + 0.31) goblin.pl = 0.0
encounter.tick(time.monotonic() + 1.2) await cmd_snap_neck(player, "goblin")
goblin.pl = 1.0
await process_combat()
# Verify accumulated kills # Verify accumulated kills
assert player.kills == 3 assert player.kills == 3

View file

@ -6,6 +6,7 @@ from mudlib.combat.engine import active_encounters, start_encounter
from mudlib.combat.moves import CombatMove from mudlib.combat.moves import CombatMove
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.unconscious import process_unconscious from mudlib.unconscious import process_unconscious
from mudlib.zone import Zone
class MockWriter: class MockWriter:
@ -131,6 +132,11 @@ async def test_snap_neck_requires_unconscious(clear_state):
attacker = Player(name="Attacker", x=0, y=0, writer=attacker_writer) attacker = Player(name="Attacker", x=0, y=0, writer=attacker_writer)
defender = Player(name="Defender", x=0, y=0, writer=defender_writer, pl=100.0) defender = Player(name="Defender", x=0, y=0, writer=defender_writer, pl=100.0)
terrain = [["." for _ in range(3)] for _ in range(3)]
zone = Zone(name="test", width=3, height=3, toroidal=True, terrain=terrain)
attacker.location = zone
defender.location = zone
zone._contents.extend([attacker, defender])
players["Attacker"] = attacker players["Attacker"] = attacker
players["Defender"] = defender players["Defender"] = defender
@ -156,6 +162,11 @@ async def test_snap_neck_success(clear_state):
attacker = Player(name="Attacker", x=0, y=0, writer=attacker_writer) attacker = Player(name="Attacker", x=0, y=0, writer=attacker_writer)
defender = Player(name="Defender", x=0, y=0, writer=defender_writer, pl=0.0) defender = Player(name="Defender", x=0, y=0, writer=defender_writer, pl=0.0)
terrain = [["." for _ in range(3)] for _ in range(3)]
zone = Zone(name="test", width=3, height=3, toroidal=True, terrain=terrain)
attacker.location = zone
defender.location = zone
zone._contents.extend([attacker, defender])
players["Attacker"] = attacker players["Attacker"] = attacker
players["Defender"] = defender players["Defender"] = defender
@ -187,6 +198,11 @@ async def test_snap_neck_message_sent_to_both(clear_state):
attacker = Player(name="Attacker", x=0, y=0, writer=attacker_writer) attacker = Player(name="Attacker", x=0, y=0, writer=attacker_writer)
defender = Player(name="Defender", x=0, y=0, writer=defender_writer, pl=0.0) defender = Player(name="Defender", x=0, y=0, writer=defender_writer, pl=0.0)
terrain = [["." for _ in range(3)] for _ in range(3)]
zone = Zone(name="test", width=3, height=3, toroidal=True, terrain=terrain)
attacker.location = zone
defender.location = zone
zone._contents.extend([attacker, defender])
players["Attacker"] = attacker players["Attacker"] = attacker
players["Defender"] = defender players["Defender"] = defender
@ -208,7 +224,7 @@ async def test_snap_neck_message_sent_to_both(clear_state):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_knockout_ends_combat(clear_state, mock_writer): async def test_knockout_ends_combat(clear_state, mock_writer):
"""Test that combat ends when a player is knocked out.""" """Test that knockout does not auto-end combat."""
from mudlib.combat.encounter import CombatEncounter, CombatState from mudlib.combat.encounter import CombatEncounter, CombatState
attacker = Player(name="Attacker", x=0, y=0, writer=mock_writer) attacker = Player(name="Attacker", x=0, y=0, writer=mock_writer)
@ -232,8 +248,34 @@ async def test_knockout_ends_combat(clear_state, mock_writer):
# Resolve should detect knockout # Resolve should detect knockout
result = encounter.resolve() result = encounter.resolve()
# Combat should end # Knockout does not end combat by itself
assert result.combat_ended assert result.combat_ended is False
@pytest.mark.asyncio
async def test_defender_stamina_ko_ends_combat(clear_state, mock_writer):
"""Defender stamina KO should not auto-end combat."""
from mudlib.combat.encounter import CombatEncounter, CombatState
attacker = Player(name="Attacker", x=0, y=0, writer=mock_writer)
defender = Player(name="Defender", x=0, y=0, writer=mock_writer, stamina=0.0)
move = CombatMove(
name="test punch",
command="testpunch",
move_type="attack",
damage_pct=0.5,
stamina_cost=10.0,
timing_window_ms=850,
countered_by=[],
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.state = CombatState.RESOLVE
encounter.current_move = move
result = encounter.resolve()
assert result.combat_ended is False
@pytest.mark.asyncio @pytest.mark.asyncio