diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py index 18507df..c803d92 100644 --- a/src/mudlib/combat/engine.py +++ b/src/mudlib/combat/engine.py @@ -84,6 +84,8 @@ async def process_combat() -> None: This should be called each game loop tick to advance combat state machines. """ + from mudlib.player import Player + now = time.monotonic() 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.defender.send("Combat has fizzled out.\r\n") - from mudlib.player import Player - for entity in (encounter.attacker, encounter.defender): if isinstance(entity, Player) and entity.mode == "combat": entity.mode_stack.pop() @@ -149,8 +149,6 @@ async def process_combat() -> None: await viewer.send(msg + "\r\n") # Send vitals update after damage resolution - from mudlib.player import Player - for entity in (encounter.attacker, encounter.defender): if isinstance(entity, Player): send_char_vitals(entity) @@ -168,6 +166,17 @@ async def process_combat() -> None: 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 + ) + + if isinstance(loser, Player): + loser.deaths += 1 + # Despawn mob losers, send victory/defeat messages if isinstance(loser, Mob): 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 - from mudlib.player import Player - attacker = encounter.attacker if isinstance(attacker, Player) and attacker.mode == "combat": attacker.mode_stack.pop() diff --git a/src/mudlib/player.py b/src/mudlib/player.py index f153a31..7eb219f 100644 --- a/src/mudlib/player.py +++ b/src/mudlib/player.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import time from dataclasses import dataclass, field 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 players: dict[str, Player] = {} diff --git a/src/mudlib/store/__init__.py b/src/mudlib/store/__init__.py index 8b8e801..38bf051 100644 --- a/src/mudlib/store/__init__.py +++ b/src/mudlib/store/__init__.py @@ -226,6 +226,11 @@ def save_player(player: Player) -> None: Args: 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 inventory_names = [obj.name for obj in player.contents if isinstance(obj, Thing)] inventory_json = json.dumps(inventory_names) diff --git a/tests/test_kill_tracking.py b/tests/test_kill_tracking.py new file mode 100644 index 0000000..ca53f82 --- /dev/null +++ b/tests/test_kill_tracking.py @@ -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