diff --git a/character/migrations/0016_add_player_came_online_at.py b/character/migrations/0016_add_player_came_online_at.py new file mode 100644 index 00000000..9a88cb1b --- /dev/null +++ b/character/migrations/0016_add_player_came_online_at.py @@ -0,0 +1,22 @@ +# Generated manually for symbiotic XP multiplier system + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("character", "0015_remove_playercharacterlink_one_active_link_per_profile_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="playercharacterlink", + name="player_came_online_at", + field=models.DateTimeField( + blank=True, + null=True, + help_text="When the player came online while linked to this character", + ), + ), + ] diff --git a/character/models/character.py b/character/models/character.py index 807df260..bb241daa 100644 --- a/character/models/character.py +++ b/character/models/character.py @@ -1,6 +1,7 @@ # from datetime import datetime from django.db import models, transaction, IntegrityError from django.utils.timezone import now +from django.utils import timezone from random import random, randint from typing import TYPE_CHECKING, Optional, Dict, Any, cast import logging @@ -224,6 +225,86 @@ def current_player(self): """ return PlayerCharacterLink.get_player(self) + def get_active_link(self): + """Get active PlayerCharacterLink for this character.""" + return PlayerCharacterLink.objects.filter( + character=self, + is_active=True + ).select_related('player').first() + + @property + def player_is_online(self): + """Check if linked player is online.""" + link = self.get_active_link() + return link.player_is_online if link else False + + @property + def xp_multiplier(self): + """ + Get XP multiplier from link. + + Returns: + float: 1.0 (offline), 2.0 (online) + """ + link = self.get_active_link() + return link.xp_multiplier if link else 1.0 + + def on_player_state_change(self, now=None): + """ + Called when player online/offline state changes. + Interrupts current activity and starts a new one with current multiplier. + + This method handles both login and logout: + 1. Completes current activity early (with its stored multiplier) + 2. Starts new activity for remaining time (which captures current multiplier) + + Returns: + CharacterActivity: The new activity (or None if none to interrupt) + """ + now = now or timezone.now() + + behaviour = getattr(self, 'behaviour', None) + if not behaviour: + return None + + # Get current activity + current = behaviour.sync_to_now(now) + + if not current or current.is_complete: + return None + + # Don't interrupt sleep + if current.kind == 'sleep': + return current + + # Don't interrupt if less than 5 minutes remaining + remaining = (current.scheduled_end - now).total_seconds() + if remaining < 300: + return current + + # Complete current activity early + current.completed_at = now + current.is_complete = True + if current.started_at: + current.duration = int((now - current.started_at).total_seconds()) + current.save() + + # Create new activity for remaining time + # Multiplier will be captured automatically in save() + from progression.models import CharacterActivity + + new_activity = CharacterActivity.objects.create( + character=self, + kind=current.kind, + name=current.name or current.kind, + scheduled_start=now, + scheduled_end=current.scheduled_end, + started_at=now, + is_complete=False + ) + + return new_activity + def start_quest(self, quest): self.quest_timer.change_quest(quest) @@ -370,6 +451,11 @@ class PlayerCharacterLink(models.Model): date_linked = models.DateField(auto_now_add=True) date_unlinked = models.DateField(null=True, blank=True) is_active = models.BooleanField(default=True) + player_came_online_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the player came online while linked to this character" + ) class Meta: constraints = [ @@ -385,6 +471,37 @@ class Meta: ), ] + @property + def player_is_online(self): + """Is the player currently connected?""" + return self.player.is_online + + @property + def xp_multiplier(self): + """ + Simple multiplier: player online = 2.0x, offline = 1.0x + + Returns: + float: 1.0 (offline), 2.0 (online) + """ + return 2.0 if self.player_is_online else 1.0 + + @classmethod + def get_link_for_character(cls, character): + """Get active link for a character.""" + return cls.objects.filter( + character=character, + is_active=True + ).select_related('player').first() + + @classmethod + def get_link_for_player(cls, player): + """Get active link for a player.""" + return cls.objects.filter( + player=player, + is_active=True + ).select_related('character').first() + @classmethod def get_character(cls, player: Player) -> Character: links = PlayerCharacterLink.objects.filter(player=player, is_active=True) diff --git a/character/tests/test_xp_multiplier.py b/character/tests/test_xp_multiplier.py new file mode 100644 index 00000000..c41e21da --- /dev/null +++ b/character/tests/test_xp_multiplier.py @@ -0,0 +1,348 @@ +# character/tests/test_xp_multiplier.py + +from datetime import date, timedelta +from django.test import TestCase +from django.utils import timezone +from unittest.mock import patch + +from character.models import Character, PlayerCharacterLink +from progression.models import CharacterActivity +from character.models.behaviour import Behaviour +from users.models import CustomUser, Player + + +class XPMultiplierSystemTests(TestCase): + """Test suite for the symbiotic XP multiplier system.""" + + def setUp(self): + """Set up test data for XP multiplier tests.""" + # Create a user + self.user = CustomUser.objects.create_user( + email="testuser@example.com", + password="testpass123" + ) + + # Create a player + self.player = Player.objects.create( + user=self.user, + name="Test Player", + is_online=False + ) + + # Create a character + self.character = Character.objects.create( + first_name="Test", + last_name="Character", + birth_date=date.today() - timedelta(days=365 * 25), + sex="Female", + can_link=True + ) + + # Create behaviour for the character + self.behaviour = Behaviour.objects.create(character=self.character) + + # Link player to character + self.link = PlayerCharacterLink.assign_character(self.player, self.character) + + def test_player_is_online_property(self): + """Test that player_is_online property works correctly.""" + # Player is offline by default + self.assertFalse(self.link.player_is_online) + + # Set player online + self.player.is_online = True + self.player.save() + self.link.refresh_from_db() + + self.assertTrue(self.link.player_is_online) + + def test_xp_multiplier_offline(self): + """Test that XP multiplier is 1.0 when player is offline.""" + self.player.is_online = False + self.player.save() + self.link.refresh_from_db() + + self.assertEqual(self.link.xp_multiplier, 1.0) + self.assertEqual(self.character.xp_multiplier, 1.0) + + def test_xp_multiplier_online(self): + """Test that XP multiplier is 2.0 when player is online.""" + self.player.is_online = True + self.player.save() + self.link.refresh_from_db() + + self.assertEqual(self.link.xp_multiplier, 2.0) + self.assertEqual(self.character.xp_multiplier, 2.0) + + def test_get_link_for_character(self): + """Test getting active link for a character.""" + link = PlayerCharacterLink.get_link_for_character(self.character) + self.assertIsNotNone(link) + self.assertEqual(link.character, self.character) + self.assertEqual(link.player, self.player) + + def test_get_link_for_player(self): + """Test getting active link for a player.""" + link = PlayerCharacterLink.get_link_for_player(self.player) + self.assertIsNotNone(link) + self.assertEqual(link.player, self.player) + self.assertEqual(link.character, self.character) + + def test_character_activity_captures_multiplier_offline(self): + """Test that CharacterActivity captures multiplier when created offline.""" + self.player.is_online = False + self.player.save() + + now = timezone.now() + activity = CharacterActivity.objects.create( + character=self.character, + kind="work", + name="Working", + scheduled_start=now, + scheduled_end=now + timedelta(hours=2), + started_at=now, + is_complete=False + ) + + self.assertEqual(activity.xp_multiplier_applied, 1.0) + + def test_character_activity_captures_multiplier_online(self): + """Test that CharacterActivity captures multiplier when created online.""" + self.player.is_online = True + self.player.save() + + now = timezone.now() + activity = CharacterActivity.objects.create( + character=self.character, + kind="work", + name="Working", + scheduled_start=now, + scheduled_end=now + timedelta(hours=2), + started_at=now, + is_complete=False + ) + + self.assertEqual(activity.xp_multiplier_applied, 2.0) + + def test_activity_xp_calculation_uses_stored_multiplier(self): + """Test that XP calculation uses stored multiplier, not current state.""" + # Create activity while offline (1.0x) + self.player.is_online = False + self.player.save() + + now = timezone.now() + activity = CharacterActivity.objects.create( + character=self.character, + kind="work", + name="Working", + scheduled_start=now, + scheduled_end=now + timedelta(hours=2), + started_at=now, + duration=7200, # 2 hours in seconds + is_complete=False + ) + + # Player comes online - but activity should still use 1.0x + self.player.is_online = True + self.player.save() + + # Calculate XP - should use stored 1.0x multiplier + xp = activity.calculate_xp_reward() + expected_xp = (7200 // 60) * 1.0 * 1.0 # 120 XP + self.assertEqual(xp, int(expected_xp)) + self.assertEqual(activity.xp_multiplier_applied, 1.0) + + def test_activity_xp_calculation_with_online_multiplier(self): + """Test XP calculation with online multiplier.""" + # Create activity while online (2.0x) + self.player.is_online = True + self.player.save() + + now = timezone.now() + activity = CharacterActivity.objects.create( + character=self.character, + kind="work", + name="Working", + scheduled_start=now, + scheduled_end=now + timedelta(hours=2), + started_at=now, + duration=7200, # 2 hours in seconds + is_complete=False + ) + + # Calculate XP - should use 2.0x multiplier + xp = activity.calculate_xp_reward() + expected_xp = (7200 // 60) * 1.0 * 2.0 # 240 XP + self.assertEqual(xp, int(expected_xp)) + self.assertEqual(activity.xp_multiplier_applied, 2.0) + + def test_activity_xp_calculation_with_rest_activity(self): + """Test XP calculation for rest activity (0.25x activity multiplier).""" + self.player.is_online = True + self.player.save() + + now = timezone.now() + activity = CharacterActivity.objects.create( + character=self.character, + kind="rest", + name="Resting", + scheduled_start=now, + scheduled_end=now + timedelta(hours=2), + started_at=now, + duration=7200, # 2 hours in seconds + is_complete=False + ) + + # Calculate XP - should use 0.25x activity multiplier * 2.0x player multiplier + xp = activity.calculate_xp_reward() + expected_xp = (7200 // 60) * 0.25 * 2.0 # 60 XP + self.assertEqual(xp, int(expected_xp)) + + def test_on_player_state_change_interrupts_activity(self): + """Test that on_player_state_change interrupts current activity.""" + # Generate a day of activities + today = timezone.now().date() + self.behaviour.generate_day(today) + + # Sync to get current activity + now = timezone.now() + current = self.behaviour.sync_to_now(now) + + # Skip if no current activity or it's sleep + if not current or current.kind == 'sleep': + self.skipTest("No suitable activity to test with") + + original_end = current.scheduled_end + + # Change player state + new_activity = self.character.on_player_state_change(now) + + if new_activity: + # Old activity should be complete + current.refresh_from_db() + self.assertTrue(current.is_complete) + self.assertEqual(current.completed_at, now) + + # New activity should be scheduled for remaining time + self.assertEqual(new_activity.scheduled_end, original_end) + self.assertEqual(new_activity.scheduled_start, now) + self.assertFalse(new_activity.is_complete) + + def test_on_player_state_change_does_not_interrupt_sleep(self): + """Test that sleep activities are not interrupted.""" + now = timezone.now() + + # Create a sleep activity + sleep_activity = CharacterActivity.objects.create( + character=self.character, + kind="sleep", + name="Sleep", + scheduled_start=now - timedelta(hours=1), + scheduled_end=now + timedelta(hours=6), + started_at=now - timedelta(hours=1), + is_complete=False + ) + + # Try to change player state + result = self.character.on_player_state_change(now) + + # Should return the sleep activity unchanged + self.assertEqual(result, sleep_activity) + sleep_activity.refresh_from_db() + self.assertFalse(sleep_activity.is_complete) + + def test_on_player_state_change_does_not_interrupt_short_activities(self): + """Test that activities with less than 5 minutes remaining are not interrupted.""" + now = timezone.now() + + # Create an activity with 4 minutes remaining + activity = CharacterActivity.objects.create( + character=self.character, + kind="work", + name="Working", + scheduled_start=now - timedelta(hours=1), + scheduled_end=now + timedelta(minutes=4), + started_at=now - timedelta(hours=1), + is_complete=False + ) + + # Try to change player state + result = self.character.on_player_state_change(now) + + # Should return the activity unchanged + self.assertEqual(result, activity) + activity.refresh_from_db() + self.assertFalse(activity.is_complete) + + def test_on_player_state_change_captures_new_multiplier(self): + """Test that new activity after state change captures new multiplier.""" + # Start offline + self.player.is_online = False + self.player.save() + + now = timezone.now() + + # Create an activity + activity = CharacterActivity.objects.create( + character=self.character, + kind="work", + name="Working", + scheduled_start=now - timedelta(hours=1), + scheduled_end=now + timedelta(hours=2), + started_at=now - timedelta(hours=1), + is_complete=False + ) + + # Verify it has 1.0x multiplier + self.assertEqual(activity.xp_multiplier_applied, 1.0) + + # Player comes online + self.player.is_online = True + self.player.save() + + # Trigger state change + new_activity = self.character.on_player_state_change(now) + + if new_activity: + # New activity should have 2.0x multiplier + self.assertEqual(new_activity.xp_multiplier_applied, 2.0) + + # Old activity should still have 1.0x multiplier + activity.refresh_from_db() + self.assertEqual(activity.xp_multiplier_applied, 1.0) + + def test_character_without_link_has_default_multiplier(self): + """Test that character without active link has 1.0x multiplier.""" + # Create a character without a link + unlinked_char = Character.objects.create( + first_name="Unlinked", + last_name="Character", + birth_date=date.today() - timedelta(days=365 * 30), + sex="Male" + ) + + self.assertEqual(unlinked_char.xp_multiplier, 1.0) + self.assertFalse(unlinked_char.player_is_online) + + def test_completed_activity_not_interrupted(self): + """Test that completed activities are not interrupted.""" + now = timezone.now() + + # Create a completed activity + activity = CharacterActivity.objects.create( + character=self.character, + kind="work", + name="Working", + scheduled_start=now - timedelta(hours=2), + scheduled_end=now - timedelta(hours=1), + started_at=now - timedelta(hours=2), + completed_at=now - timedelta(hours=1), + is_complete=True, + duration=3600 + ) + + # Try to change player state + result = self.character.on_player_state_change(now) + + # Should return None since there's no active activity + self.assertIsNone(result) diff --git a/gameplay/consumers.py b/gameplay/consumers.py index b00a3166..f6fb8905 100644 --- a/gameplay/consumers.py +++ b/gameplay/consumers.py @@ -74,6 +74,11 @@ async def connect(self): self.activity_timer = await self.get_activity_timer() self.quest_timer = await self.get_quest_timer() + # Player came online - interrupt current activity and restart with boost + await database_sync_to_async( + self.character.on_player_state_change + )() + await self.send_json( { "type": "console.log", @@ -90,6 +95,12 @@ async def disconnect(self, close_code): f"[DISCONNECT] WebSocket disconnecting. Player: {self.player.id} | Code: {close_code}" ) await database_sync_to_async(self.player.set_offline)() + + # Player went offline - interrupt current activity and restart without boost + await database_sync_to_async( + self.character.on_player_state_change + )() + await self.channel_layer.group_discard("online_users", self.channel_name) if hasattr(self, "player_group"): diff --git a/progression/migrations/0018_add_xp_multiplier_applied.py b/progression/migrations/0018_add_xp_multiplier_applied.py new file mode 100644 index 00000000..713884e3 --- /dev/null +++ b/progression/migrations/0018_add_xp_multiplier_applied.py @@ -0,0 +1,18 @@ +# Generated manually for symbiotic XP multiplier system + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("progression", "0017_alter_characteractivity_name_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="characteractivity", + name="xp_multiplier_applied", + field=models.FloatField(default=1.0), + ), + ] diff --git a/progression/models.py b/progression/models.py index 8376c5d5..1f999fe7 100644 --- a/progression/models.py +++ b/progression/models.py @@ -304,6 +304,7 @@ class CharacterActivity(TimeRecord): ("idle", "Idling"), ], ) + xp_multiplier_applied = models.FloatField(default=1.0) class Meta: ordering = ["-created_at"] @@ -311,13 +312,22 @@ class Meta: def __str__(self): return f"character_activity {self.name}" + def save(self, *args, **kwargs): + """Capture multiplier when activity is created.""" + if not self.pk and not self.is_complete: + # New activity: capture current multiplier from character + self.xp_multiplier_applied = self.character.xp_multiplier + super().save(*args, **kwargs) + def calculate_xp_reward(self) -> int: """ - Calculate and store the XP gained based on duration. + Calculate XP using the multiplier that was active when activity started. """ base_xp = self.duration // 60 - multiplier = 0.25 if self.kind == "rest" else 1 - return int(base_xp * multiplier) + activity_multiplier = 0.25 if self.kind == "rest" else 1.0 + + # Use stored multiplier from when activity was created + return int(base_xp * activity_multiplier * self.xp_multiplier_applied) class CharacterQuest(TimeRecord):