Wire kill/death tracking into combat engine

Increment player.kills and player.mob_kills on mob defeat,
player.deaths on player defeat. Session time accumulation
via accumulate_play_time helper.
This commit is contained in:
Jared Miller 2026-02-14 11:20:13 -05:00
parent a398227814
commit e31af53577
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 184 additions and 6 deletions

View file

@ -84,6 +84,8 @@ async def process_combat() -> None:
This should be called each game loop tick to advance combat state machines. This should be called each game loop tick to advance combat state machines.
""" """
from mudlib.player import Player
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
@ -92,8 +94,6 @@ async def process_combat() -> None:
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")
from mudlib.player import Player
for entity in (encounter.attacker, encounter.defender): for entity in (encounter.attacker, encounter.defender):
if isinstance(entity, Player) and entity.mode == "combat": if isinstance(entity, Player) and entity.mode == "combat":
entity.mode_stack.pop() entity.mode_stack.pop()
@ -149,8 +149,6 @@ async def process_combat() -> None:
await viewer.send(msg + "\r\n") await viewer.send(msg + "\r\n")
# Send vitals update after damage resolution # Send vitals update after damage resolution
from mudlib.player import Player
for entity in (encounter.attacker, encounter.defender): for entity in (encounter.attacker, encounter.defender):
if isinstance(entity, Player): if isinstance(entity, Player):
send_char_vitals(entity) send_char_vitals(entity)
@ -168,6 +166,17 @@ async def process_combat() -> None:
loser = encounter.attacker loser = encounter.attacker
winner = encounter.defender 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
)
if isinstance(loser, Player):
loser.deaths += 1
# Despawn mob losers, send victory/defeat messages # Despawn mob losers, send victory/defeat messages
if isinstance(loser, Mob): if isinstance(loser, Mob):
from mudlib.corpse import create_corpse from mudlib.corpse import create_corpse
@ -191,8 +200,6 @@ async def process_combat() -> None:
) )
# Pop combat mode from both entities if they're Players # Pop combat mode from both entities if they're Players
from mudlib.player import Player
attacker = encounter.attacker attacker = encounter.attacker
if isinstance(attacker, Player) and attacker.mode == "combat": if isinstance(attacker, Player) and attacker.mode == "combat":
attacker.mode_stack.pop() attacker.mode_stack.pop()

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -90,5 +91,16 @@ class Player(Entity):
) )
def accumulate_play_time(player: Player) -> None:
"""Accumulate play time since session start and reset timer.
Args:
player: The player to update
"""
if player.session_start > 0:
player.play_time_seconds += time.monotonic() - player.session_start
player.session_start = time.monotonic()
# Global registry of connected players # Global registry of connected players
players: dict[str, Player] = {} players: dict[str, Player] = {}

View file

@ -226,6 +226,11 @@ def save_player(player: Player) -> None:
Args: Args:
player: Player instance to save player: Player instance to save
""" """
# Accumulate play time before saving
from mudlib.player import accumulate_play_time
accumulate_play_time(player)
# Serialize inventory as JSON list of thing names # Serialize inventory as JSON list of thing names
inventory_names = [obj.name for obj in player.contents if isinstance(obj, Thing)] inventory_names = [obj.name for obj in player.contents if isinstance(obj, Thing)]
inventory_json = json.dumps(inventory_names) inventory_json = json.dumps(inventory_names)

154
tests/test_kill_tracking.py Normal file
View file

@ -0,0 +1,154 @@
"""Tests for kill and death tracking in combat."""
import time
import pytest
from mudlib.combat.engine import (
process_combat,
start_encounter,
)
from mudlib.combat.moves import CombatMove
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."""
# 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()
# Verify stats
assert player.kills == 1
assert player.mob_kills["goblin"] == 1
@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()
# 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."""
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()
# Verify accumulated kills
assert player.kills == 3
assert player.mob_kills["goblin"] == 3
def test_session_time_tracking(player):
"""Session time tracking accumulates correctly."""
# Set session start to a known time
start_time = time.monotonic()
player.session_start = start_time
# Simulate 5 seconds passing
time.sleep(0.01) # Small real delay to ensure monotonic() advances
player.session_start = start_time # Reset for predictable test
# Mock time to be 5 seconds later
import unittest.mock
with unittest.mock.patch("time.monotonic", return_value=start_time + 5.0):
accumulate_play_time(player)
# Should have accumulated 5 seconds
assert player.play_time_seconds == 5.0
# Session start should be reset to current time
with unittest.mock.patch("time.monotonic", return_value=start_time + 5.0):
assert player.session_start == start_time + 5.0
def test_accumulate_play_time_multiple_sessions(player):
"""Multiple accumulation calls should add up correctly."""
start_time = time.monotonic()
player.session_start = start_time
import unittest.mock
# First accumulation: 3 seconds
with unittest.mock.patch("time.monotonic", return_value=start_time + 3.0):
accumulate_play_time(player)
assert player.play_time_seconds == 3.0
# Second accumulation: 2 more seconds (from reset point)
with unittest.mock.patch("time.monotonic", return_value=start_time + 5.0):
accumulate_play_time(player)
# Should have 3 + 2 = 5 total
assert player.play_time_seconds == 5.0