From e0406e39e59c0788e4cfc2209d8956248f34b0f6 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sun, 15 Feb 2026 12:40:25 -0500 Subject: [PATCH] Make snapneck the explicit kill/death/corpse finisher path --- src/mudlib/commands/snapneck.py | 63 ++++++++++++++-------- tests/test_corpse.py | 78 +++++++++++++++++++-------- tests/test_kill_tracking.py | 93 ++++++++------------------------- tests/test_unconscious.py | 48 +++++++++++++++-- 4 files changed, 164 insertions(+), 118 deletions(-) diff --git a/src/mudlib/commands/snapneck.py b/src/mudlib/commands/snapneck.py index 62a1be9..a87669c 100644 --- a/src/mudlib/commands/snapneck.py +++ b/src/mudlib/commands/snapneck.py @@ -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": diff --git a/tests/test_corpse.py b/tests/test_corpse.py index cf73826..680d80a 100644 --- a/tests/test_corpse.py +++ b/tests/test_corpse.py @@ -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: diff --git a/tests/test_kill_tracking.py b/tests/test_kill_tracking.py index ca53f82..cad1c06 100644 --- a/tests/test_kill_tracking.py +++ b/tests/test_kill_tracking.py @@ -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 diff --git a/tests/test_unconscious.py b/tests/test_unconscious.py index 46d9d23..037b516 100644 --- a/tests/test_unconscious.py +++ b/tests/test_unconscious.py @@ -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