Make snapneck the explicit kill/death/corpse finisher path
This commit is contained in:
parent
a4a866a77c
commit
e0406e39e5
4 changed files with 164 additions and 118 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from mudlib.combat.engine import end_encounter, get_encounter
|
||||
from mudlib.commands import CommandDefinition, register
|
||||
from mudlib.player import Player, players
|
||||
from mudlib.player import Player
|
||||
|
||||
DEATH_PL = -100.0
|
||||
|
||||
|
|
@ -14,37 +14,32 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
|
|||
player: The player executing the command
|
||||
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
|
||||
target_name = args.strip()
|
||||
if not target_name:
|
||||
await player.send("Snap whose neck?\r\n")
|
||||
return
|
||||
|
||||
# Find target
|
||||
target = players.get(target_name)
|
||||
if target is None and player.location is not None:
|
||||
from mudlib.mobs import get_nearby_mob
|
||||
from mudlib.zone import Zone
|
||||
# Must be used during an active encounter.
|
||||
encounter = get_encounter(player)
|
||||
if encounter is None:
|
||||
await player.send("You're not in combat.\r\n")
|
||||
return
|
||||
|
||||
if isinstance(player.location, Zone):
|
||||
target = get_nearby_mob(target_name, player.x, player.y, player.location)
|
||||
# Find target on this tile.
|
||||
from mudlib.targeting import find_entity_on_tile
|
||||
|
||||
target = find_entity_on_tile(target_name, player)
|
||||
if target is None:
|
||||
await player.send(f"You don't see {target_name} here.\r\n")
|
||||
return
|
||||
|
||||
# Verify target is in the encounter
|
||||
if encounter.attacker is not player and encounter.defender is not player:
|
||||
await player.send("You're not in combat with that target.\r\n")
|
||||
if target is player:
|
||||
await player.send("You can't do that to yourself.\r\n")
|
||||
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")
|
||||
return
|
||||
|
||||
|
|
@ -64,16 +59,38 @@ async def cmd_snap_neck(player: Player, args: str) -> None:
|
|||
from mudlib.entity import Mob
|
||||
from mudlib.gmcp import send_char_vitals
|
||||
|
||||
if not isinstance(target, Mob):
|
||||
if isinstance(target, Player):
|
||||
send_char_vitals(target)
|
||||
|
||||
# Handle mob despawn
|
||||
# Award kill/death stats on explicit finishers only.
|
||||
player.kills += 1
|
||||
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.mobs import despawn_mob
|
||||
from mudlib.corpse import create_corpse
|
||||
from mudlib.mobs import despawn_mob, mob_templates
|
||||
from mudlib.zone import Zone
|
||||
|
||||
despawn_mob(target)
|
||||
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)
|
||||
|
||||
# Pop combat mode from both entities if they're Players
|
||||
# Pop combat mode from both entities.
|
||||
from mudlib.gmcp import send_char_status
|
||||
|
||||
if isinstance(player, Player) and player.mode == "combat":
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ class TestCorpseAsContainer:
|
|||
|
||||
|
||||
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)
|
||||
def clear_corpses(self):
|
||||
|
|
@ -230,8 +230,8 @@ class TestCombatDeathCorpse:
|
|||
active_corpses.clear()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_death_in_combat_spawns_corpse(self, test_zone):
|
||||
"""Mob death in combat spawns a corpse at mob's position."""
|
||||
async def test_mob_knockout_in_combat_does_not_spawn_corpse(self, test_zone):
|
||||
"""KO in combat should not create a corpse by itself."""
|
||||
from mudlib.combat.encounter import CombatState
|
||||
from mudlib.combat.engine import (
|
||||
active_encounters,
|
||||
|
|
@ -283,16 +283,17 @@ class TestCombatDeathCorpse:
|
|||
# Process combat to trigger resolve
|
||||
await process_combat()
|
||||
|
||||
# Check for corpse at mob's position
|
||||
# Check no corpse spawned yet
|
||||
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"
|
||||
assert len(corpses) == 0
|
||||
assert mob in mobs
|
||||
assert mob.alive is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_death_transfers_inventory_to_corpse(self, test_zone, sword):
|
||||
"""Mob death transfers inventory to corpse."""
|
||||
async def test_mob_knockout_keeps_inventory_on_mob(self, test_zone, sword):
|
||||
"""KO should not transfer inventory to a corpse until finished."""
|
||||
from mudlib.combat.encounter import CombatState
|
||||
from mudlib.combat.engine import (
|
||||
active_encounters,
|
||||
|
|
@ -343,20 +344,17 @@ class TestCombatDeathCorpse:
|
|||
# Process combat
|
||||
await process_combat()
|
||||
|
||||
# Find corpse
|
||||
# No corpse yet
|
||||
corpses = [
|
||||
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
|
||||
]
|
||||
assert len(corpses) == 1
|
||||
corpse = corpses[0]
|
||||
|
||||
# Verify sword is in corpse
|
||||
assert sword in corpse._contents
|
||||
assert sword.location is corpse
|
||||
assert len(corpses) == 0
|
||||
assert sword in mob._contents
|
||||
assert sword.location is mob
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_corpse_appears_in_zone_contents(self, test_zone):
|
||||
"""Corpse appears in zone.contents_at after mob death."""
|
||||
async def test_no_corpse_in_zone_contents_after_ko(self, test_zone):
|
||||
"""Zone should not contain corpse from a plain KO."""
|
||||
from mudlib.combat.encounter import CombatState
|
||||
from mudlib.combat.engine import (
|
||||
active_encounters,
|
||||
|
|
@ -406,14 +404,50 @@ class TestCombatDeathCorpse:
|
|||
# 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))
|
||||
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
|
||||
corpse = next(obj for obj in contents if isinstance(obj, Corpse))
|
||||
assert corpse.name == "goblin's corpse"
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapneck_finisher_spawns_corpse(self, test_zone):
|
||||
"""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:
|
||||
|
|
|
|||
|
|
@ -4,54 +4,25 @@ import time
|
|||
|
||||
import pytest
|
||||
|
||||
from mudlib.combat.engine import (
|
||||
process_combat,
|
||||
start_encounter,
|
||||
)
|
||||
from mudlib.combat.moves import CombatMove
|
||||
from mudlib.combat.engine import start_encounter
|
||||
from mudlib.commands.snapneck import cmd_snap_neck
|
||||
from mudlib.entity import Mob
|
||||
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
|
||||
async def test_player_kills_mob_increments_stats(player, test_zone, punch_move):
|
||||
"""Player kills mob -> kills incremented, mob_kills tracked."""
|
||||
async def test_player_finishes_mob_increments_stats(player, test_zone):
|
||||
"""Snap-neck kill increments kills and mob_kills."""
|
||||
# Create a goblin mob
|
||||
goblin = Mob(name="goblin", x=0, y=0)
|
||||
goblin.location = test_zone
|
||||
test_zone._contents.append(goblin)
|
||||
|
||||
# Start encounter
|
||||
encounter = start_encounter(player, goblin)
|
||||
|
||||
# Execute attack
|
||||
encounter.attack(punch_move)
|
||||
|
||||
# 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()
|
||||
# Start encounter and make target unconscious
|
||||
start_encounter(player, goblin)
|
||||
player.mode_stack.append("combat")
|
||||
goblin.pl = 0.0
|
||||
await cmd_snap_neck(player, "goblin")
|
||||
|
||||
# Verify stats
|
||||
assert player.kills == 1
|
||||
|
|
@ -59,50 +30,32 @@ async def test_player_kills_mob_increments_stats(player, test_zone, punch_move):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_player_killed_by_mob_increments_deaths(player, test_zone, punch_move):
|
||||
"""Player killed by mob -> deaths incremented."""
|
||||
# Create a goblin mob
|
||||
goblin = Mob(name="goblin", x=0, y=0)
|
||||
goblin.location = test_zone
|
||||
test_zone._contents.append(goblin)
|
||||
|
||||
# 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()
|
||||
async def test_player_finished_by_mob_increments_deaths(player, nearby_player):
|
||||
"""Snap-neck finisher from opponent increments deaths."""
|
||||
start_encounter(nearby_player, player)
|
||||
nearby_player.mode_stack.append("combat")
|
||||
player.mode_stack.append("combat")
|
||||
player.pl = 0.0
|
||||
await cmd_snap_neck(nearby_player, "Goku")
|
||||
|
||||
# Verify deaths incremented
|
||||
assert player.deaths == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_kills_accumulate(player, test_zone, punch_move):
|
||||
"""After killing 3 goblins, player.kills == 3, player.mob_kills["goblin"] == 3."""
|
||||
async def test_multiple_finisher_kills_accumulate(player, test_zone):
|
||||
"""After 3 finishers, kill counters accumulate correctly."""
|
||||
for _ in range(3):
|
||||
# Create goblin
|
||||
goblin = Mob(name="goblin", x=0, y=0)
|
||||
goblin.location = test_zone
|
||||
test_zone._contents.append(goblin)
|
||||
|
||||
# Create and resolve encounter
|
||||
encounter = start_encounter(player, goblin)
|
||||
encounter.attack(punch_move)
|
||||
encounter.tick(time.monotonic() + 0.31)
|
||||
encounter.tick(time.monotonic() + 1.2)
|
||||
goblin.pl = 1.0
|
||||
|
||||
await process_combat()
|
||||
# Create encounter and finish
|
||||
start_encounter(player, goblin)
|
||||
player.mode_stack.append("combat")
|
||||
goblin.pl = 0.0
|
||||
await cmd_snap_neck(player, "goblin")
|
||||
|
||||
# Verify accumulated kills
|
||||
assert player.kills == 3
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from mudlib.combat.engine import active_encounters, start_encounter
|
|||
from mudlib.combat.moves import CombatMove
|
||||
from mudlib.player import Player, players
|
||||
from mudlib.unconscious import process_unconscious
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
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)
|
||||
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["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)
|
||||
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["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)
|
||||
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["Defender"] = defender
|
||||
|
|
@ -208,7 +224,7 @@ async def test_snap_neck_message_sent_to_both(clear_state):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
|
||||
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
|
||||
result = encounter.resolve()
|
||||
|
||||
# Combat should end
|
||||
assert result.combat_ended
|
||||
# Knockout does not end combat by itself
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue