From fb44d81e7a81701fc012e46c54b20ae61e7467cd Mon Sep 17 00:00:00 2001 From: Samuel Cattini-Schultz Date: Sat, 24 Aug 2024 16:26:16 +1000 Subject: [PATCH 1/5] Add calculator_engine field to Leaderboard --- .../0024_leaderboard_calculator_engine.py | 22 +++++++++++++++++++ leaderboards/models.py | 1 + 2 files changed, 23 insertions(+) create mode 100644 leaderboards/migrations/0024_leaderboard_calculator_engine.py diff --git a/leaderboards/migrations/0024_leaderboard_calculator_engine.py b/leaderboards/migrations/0024_leaderboard_calculator_engine.py new file mode 100644 index 0000000..5286fc0 --- /dev/null +++ b/leaderboards/migrations/0024_leaderboard_calculator_engine.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.13 on 2024-08-24 06:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "leaderboards", + "0023_leaderboard_scores_membershipscore_leaderboard_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="leaderboard", + name="calculator_engine", + field=models.CharField(default="osu.Game.Rulesets.Osu", max_length=50), + preserve_default=False, + ), + ] diff --git a/leaderboards/models.py b/leaderboards/models.py index eb9c814..f6904c9 100644 --- a/leaderboards/models.py +++ b/leaderboards/models.py @@ -52,6 +52,7 @@ class Leaderboard(models.Model): ) # global leaderboards will have null member count archived = models.BooleanField(default=False) notification_discord_webhook_url = models.CharField(max_length=250, blank=True) + calculator_engine = models.CharField(max_length=50) # Relations score_filter = models.OneToOneField(ScoreFilter, on_delete=models.CASCADE) From e3c02f887646b92266069510036721c17fe9f8b8 Mon Sep 17 00:00:00 2001 From: Samuel Cattini-Schultz Date: Sat, 24 Aug 2024 16:41:23 +1000 Subject: [PATCH 2/5] Clean up usage of get_difficulty_calculator_class --- profiles/management/commands/recalculate.py | 2 -- profiles/models.py | 12 +----------- profiles/test_models.py | 1 - profiles/test_services.py | 18 +++++++++--------- 4 files changed, 10 insertions(+), 23 deletions(-) diff --git a/profiles/management/commands/recalculate.py b/profiles/management/commands/recalculate.py index 1771551..c422b86 100644 --- a/profiles/management/commands/recalculate.py +++ b/profiles/management/commands/recalculate.py @@ -1,5 +1,3 @@ -from typing import Iterable, Type - from django.core.management.base import BaseCommand from django.core.paginator import Paginator from django.db.models import FilteredRelation, Q, QuerySet diff --git a/profiles/models.py b/profiles/models.py index d662148..44341a0 100644 --- a/profiles/models.py +++ b/profiles/models.py @@ -1,21 +1,11 @@ import typing from datetime import datetime, timezone -from typing import Type from django.db import models from django.db.models import FilteredRelation, Q, Subquery -from common.error_reporter import ErrorReporter from common.osu import utils -from common.osu.difficultycalculator import ( - AbstractDifficultyCalculator, - DifficultyCalculatorException, -) -from common.osu.difficultycalculator import Score as DifficultyCalculatorScore -from common.osu.difficultycalculator import ( - get_default_difficulty_calculator_class, - get_difficulty_calculator_class, -) +from common.osu.difficultycalculator import get_default_difficulty_calculator_class from common.osu.enums import BeatmapStatus, Gamemode, Mods from profiles.enums import AllowedBeatmapStatus, ScoreMutation, ScoreResult, ScoreSet diff --git a/profiles/test_models.py b/profiles/test_models.py index 8df26dc..1756a0b 100644 --- a/profiles/test_models.py +++ b/profiles/test_models.py @@ -1,6 +1,5 @@ import pytest -from common.osu.difficultycalculator import get_difficulty_calculator_class from profiles.enums import ScoreResult from profiles.models import Beatmap, OsuUser, Score, UserStats diff --git a/profiles/test_services.py b/profiles/test_services.py index b20239c..25bbde9 100644 --- a/profiles/test_services.py +++ b/profiles/test_services.py @@ -1,6 +1,6 @@ import pytest -from common.osu.difficultycalculator import get_difficulty_calculator_class +from common.osu.difficultycalculator import get_default_difficulty_calculator_class from common.osu.enums import Gamemode, Mods from profiles.models import DifficultyCalculation, PerformanceCalculation from profiles.services import ( @@ -68,8 +68,8 @@ def test_fetch_scores(self): @pytest.mark.django_db class TestDifficultyCalculationServices: def test_update_difficulty_calculations(self, beatmap): - with get_difficulty_calculator_class( - "difficalcy-osu" + with get_default_difficulty_calculator_class( + Gamemode.STANDARD )() as difficulty_calculator: update_difficulty_calculations([beatmap], difficulty_calculator) @@ -89,8 +89,8 @@ def test_update_difficulty_calculations(self, beatmap): assert difficulty_values[3].value == 6.710442985146793 def test_update_performance_calculations(self, score): - with get_difficulty_calculator_class( - "difficalcy-osu" + with get_default_difficulty_calculator_class( + Gamemode.STANDARD )() as difficulty_calculator: update_performance_calculations([score], difficulty_calculator) @@ -136,8 +136,8 @@ def difficulty_calculation(self, beatmap): ) def test_calculate_difficulty_values(self, difficulty_calculation): - with get_difficulty_calculator_class( - "difficalcy-osu" + with get_default_difficulty_calculator_class( + Gamemode.STANDARD )() as difficulty_calculator: difficulty_values = calculate_difficulty_values( [difficulty_calculation], difficulty_calculator @@ -163,8 +163,8 @@ def performance_calculation(self, score, difficulty_calculation): ) def test_calculate_performance_values(self, performance_calculation): - with get_difficulty_calculator_class( - "difficalcy-osu" + with get_default_difficulty_calculator_class( + Gamemode.STANDARD )() as difficulty_calculator: performance_values = calculate_performance_values( [performance_calculation], difficulty_calculator From 048e9b49fadf30b68ce06570537f69def0dfd7a0 Mon Sep 17 00:00:00 2001 From: Samuel Cattini-Schultz Date: Sat, 24 Aug 2024 17:12:04 +1000 Subject: [PATCH 3/5] Add primary_performance_value field to Leaderboard --- ...5_leaderboard_primary_performance_value.py | 19 +++++++++++++++++++ leaderboards/models.py | 1 + 2 files changed, 20 insertions(+) create mode 100644 leaderboards/migrations/0025_leaderboard_primary_performance_value.py diff --git a/leaderboards/migrations/0025_leaderboard_primary_performance_value.py b/leaderboards/migrations/0025_leaderboard_primary_performance_value.py new file mode 100644 index 0000000..a9f08d2 --- /dev/null +++ b/leaderboards/migrations/0025_leaderboard_primary_performance_value.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.13 on 2024-08-24 07:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("leaderboards", "0024_leaderboard_calculator_engine"), + ] + + operations = [ + migrations.AddField( + model_name="leaderboard", + name="primary_performance_value", + field=models.CharField(default="total", max_length=20), + preserve_default=False, + ), + ] diff --git a/leaderboards/models.py b/leaderboards/models.py index f6904c9..c80208e 100644 --- a/leaderboards/models.py +++ b/leaderboards/models.py @@ -53,6 +53,7 @@ class Leaderboard(models.Model): archived = models.BooleanField(default=False) notification_discord_webhook_url = models.CharField(max_length=250, blank=True) calculator_engine = models.CharField(max_length=50) + primary_performance_value = models.CharField(max_length=20) # Relations score_filter = models.OneToOneField(ScoreFilter, on_delete=models.CASCADE) From 0aa1b2406a41d2e2ed2cadc5663dc189c0000a99 Mon Sep 17 00:00:00 2001 From: Samuel Cattini-Schultz Date: Sat, 24 Aug 2024 17:50:54 +1000 Subject: [PATCH 4/5] Implement usage of new leaderboard diffcalc fields --- common/osu/difficultycalculator.py | 13 ++++++ conftest.py | 2 + leaderboards/serialisers.py | 2 + leaderboards/services.py | 11 +++-- leaderboards/tasks.py | 4 +- leaderboards/test_services.py | 2 + leaderboards/views.py | 28 +++++++++++- profiles/models.py | 69 ++++++++++++++++++++---------- profiles/services.py | 5 +-- 9 files changed, 102 insertions(+), 34 deletions(-) diff --git a/common/osu/difficultycalculator.py b/common/osu/difficultycalculator.py index 09236a9..4a26deb 100644 --- a/common/osu/difficultycalculator.py +++ b/common/osu/difficultycalculator.py @@ -431,6 +431,19 @@ def get_difficulty_calculator_class(name: str) -> Type[AbstractDifficultyCalcula return difficulty_calculators_classes[name] +def get_difficulty_calculator_class_for_engine( + engine: str, +) -> Type[AbstractDifficultyCalculator]: + try: + return next( + calculator_class + for calculator_class in difficulty_calculators_classes.values() + if calculator_class.engine() == engine + ) + except StopIteration as e: + raise ValueError(f"No difficulty calculator found for engine {engine}") from e + + def get_default_difficulty_calculator_class( gamemode: Gamemode, ) -> Type[AbstractDifficultyCalculator]: diff --git a/conftest.py b/conftest.py index 884aeb4..1d8d763 100644 --- a/conftest.py +++ b/conftest.py @@ -161,6 +161,8 @@ def leaderboard(score_filter: ScoreFilter): member_count=0, archived=False, notification_discord_webhook_url="", + calculator_engine="osu.Game.Rulesets.Osu", + primary_performance_value="total", score_filter=score_filter, ), ) diff --git a/leaderboards/serialisers.py b/leaderboards/serialisers.py index 9273ac4..2358041 100644 --- a/leaderboards/serialisers.py +++ b/leaderboards/serialisers.py @@ -27,6 +27,8 @@ class Meta: "allow_past_scores", "member_count", "archived", + "calculator_engine", + "primary_performance_value", # relations "score_filter", "owner", diff --git a/leaderboards/services.py b/leaderboards/services.py index 383f212..168f5ad 100644 --- a/leaderboards/services.py +++ b/leaderboards/services.py @@ -90,18 +90,23 @@ def update_membership(leaderboard: Leaderboard, user_id: int): if leaderboard.score_filter: scores = scores.apply_score_filter(leaderboard.score_filter) - scores = scores.get_score_set(leaderboard.gamemode, score_set=leaderboard.score_set) + scores = scores.get_score_set( + leaderboard.gamemode, + score_set=leaderboard.score_set, + calculator_engine=leaderboard.calculator_engine, + primary_performance_value=leaderboard.primary_performance_value, + ) membership_scores = [ MembershipScore( membership=membership, leaderboard=leaderboard, score=score, - performance_total=score.default_performance_total, + performance_total=score.performance_total, ) for score in scores # Skip scores missing performance calculation - if score.default_performance_total is not None + if score.performance_total is not None ] MembershipScore.objects.bulk_create( diff --git a/leaderboards/tasks.py b/leaderboards/tasks.py index 2c1e577..077122f 100644 --- a/leaderboards/tasks.py +++ b/leaderboards/tasks.py @@ -49,14 +49,14 @@ def send_leaderboard_top_score_notification(leaderboard_id: int, score_id: int): if score.mods != Mods.NONE: beatmap_details += f" +{get_mods_string(score.mods)}" - performance_calculation = score.get_default_performance_calculation() + performance_calculation = score.get_performance_calculation() difficulty_total = ( performance_calculation.difficulty_calculation.get_total_difficulty() ) beatmap_details += f" **{difficulty_total:.2f} stars**" - performance_calculation = score.get_default_performance_calculation() + performance_calculation = score.get_performance_calculation() performance_total = performance_calculation.get_total_performance() score_details = f"**{performance_total:.0f}pp** ({score.accuracy:.2f}%)" if score.result is not None and score.result & ScoreResult.FULL_COMBO: diff --git a/leaderboards/test_services.py b/leaderboards/test_services.py index d3b0e78..f00a3b2 100644 --- a/leaderboards/test_services.py +++ b/leaderboards/test_services.py @@ -30,6 +30,8 @@ def test_nochoke_leaderboard(self, stub_user_stats, score_filter): member_count=0, archived=False, notification_discord_webhook_url="", + calculator_engine="osu.Game.Rulesets.Osu", + primary_performance_value="total", score_filter=ScoreFilter.objects.create(), ), ) diff --git a/leaderboards/views.py b/leaderboards/views.py index 01083ce..a84403b 100644 --- a/leaderboards/views.py +++ b/leaderboards/views.py @@ -8,6 +8,7 @@ from rest_framework.response import Response from rest_framework.views import APIView +from common.osu.difficultycalculator import get_default_difficulty_calculator_class from common.osu.enums import Gamemode, Mods from common.utils import parse_int_or_none from leaderboards.enums import LeaderboardAccessType @@ -115,6 +116,17 @@ def post(self, request, leaderboard_type, gamemode): if score_filter_data is None: raise ParseError("Missing score_filter parameter.") + calculator_engine = request.data.get("calculator_engine") + if calculator_engine is None: + calculator_engine = get_default_difficulty_calculator_class( + gamemode + ).engine() + primary_performance_value = "total" + else: + primary_performance_value = request.data.get( + "primary_performance_value", "total" + ) + leaderboard = Leaderboard( gamemode=gamemode, score_set=score_set, @@ -123,6 +135,8 @@ def post(self, request, leaderboard_type, gamemode): description=description or "", icon_url=icon_url or "", allow_past_scores=request.data.get("allow_past_scores"), + calculator_engine=calculator_engine, + primary_performance_value=primary_performance_value, score_filter=ScoreFilter( allowed_beatmap_status=score_filter_data.get( "allowed_beatmap_status", AllowedBeatmapStatus.RANKED_ONLY @@ -564,7 +578,12 @@ def get(self, request, leaderboard_type, gamemode, leaderboard_id, beatmap_id): .distinct() .filter(membership__leaderboard_id=leaderboard_id, beatmap_id=beatmap_id) .select_related("user_stats", "user_stats__user") - .get_score_set(leaderboard.gamemode, score_set=leaderboard.score_set) + .get_score_set( + leaderboard.gamemode, + score_set=leaderboard.score_set, + calculator_engine=leaderboard.calculator_engine, + primary_performance_value=leaderboard.primary_performance_value, + ) .prefetch_related( "performance_calculations__performance_values", "performance_calculations__difficulty_calculation__difficulty_values", @@ -603,7 +622,12 @@ def get(self, request, leaderboard_type, gamemode, leaderboard_id, user_id): membership__leaderboard_id=leaderboard_id, membership__user_id=user_id ) .select_related("beatmap") - .get_score_set(leaderboard.gamemode, score_set=leaderboard.score_set) + .get_score_set( + leaderboard.gamemode, + score_set=leaderboard.score_set, + calculator_engine=leaderboard.calculator_engine, + primary_performance_value=leaderboard.primary_performance_value, + ) .prefetch_related( "performance_calculations__performance_values", "performance_calculations__difficulty_calculation__difficulty_values", diff --git a/profiles/models.py b/profiles/models.py index 44341a0..8c10e7d 100644 --- a/profiles/models.py +++ b/profiles/models.py @@ -5,7 +5,10 @@ from django.db.models import FilteredRelation, Q, Subquery from common.osu import utils -from common.osu.difficultycalculator import get_default_difficulty_calculator_class +from common.osu.difficultycalculator import ( + get_default_difficulty_calculator_class, + get_difficulty_calculator_class_for_engine, +) from common.osu.enums import BeatmapStatus, Gamemode, Mods from profiles.enums import AllowedBeatmapStatus, ScoreMutation, ScoreResult, ScoreSet @@ -105,7 +108,7 @@ def recalculate(self): # Calculate bonus pp (+ pp from non-top100 scores) self.extra_pp = self.pp - utils.calculate_pp_total( - score.default_performance_total for score in scores[:100] + score.performance_total for score in scores[:100] ) # Calculate score style @@ -234,14 +237,20 @@ def from_data(cls, beatmap_data): return beatmap - def get_default_difficulty_calculation(self): - difficulty_calculator = get_default_difficulty_calculator_class( - Gamemode(self.gamemode) - ) + def get_difficulty_calculation( + self, calculator_engine: typing.Optional[str] = None + ): + if calculator_engine is not None: + calculator_engine_name = calculator_engine + else: + calculator_engine_name = get_default_difficulty_calculator_class( + Gamemode(self.gamemode) + ).engine() + return DifficultyCalculation.objects.get( beatmap=self, mods=Mods.NONE, - calculator_engine=difficulty_calculator.engine(), + calculator_engine=calculator_engine_name, ) def __str__(self): @@ -386,7 +395,13 @@ def apply_score_filter(self, score_filter): return scores - def get_score_set(self, gamemode: Gamemode, score_set: ScoreSet = ScoreSet.NORMAL): + def get_score_set( + self, + gamemode: Gamemode, + score_set: ScoreSet = ScoreSet.NORMAL, + calculator_engine: typing.Optional[str] = None, + primary_performance_value: str = "total", + ): """ Queryset that returns distinct on beatmap_id prioritising highest pp given the score_set. Remember to use at end of query to not unintentionally filter out scores before primary filtering. @@ -401,11 +416,15 @@ def get_score_set(self, gamemode: Gamemode, score_set: ScoreSet = ScoreSet.NORMA else: raise ValueError(f"Invalid score set: {score_set}") - difficulty_calculator_class = get_default_difficulty_calculator_class(gamemode) + difficulty_calculator_class = ( + get_default_difficulty_calculator_class(gamemode) + if calculator_engine is None + else get_difficulty_calculator_class_for_engine(calculator_engine) + ) annotated_scores = ( scores.annotate( - default_performance_calculation=FilteredRelation( + performance_calculation=FilteredRelation( "performance_calculations", condition=Q( performance_calculations__calculator_engine=difficulty_calculator_class.engine() @@ -413,16 +432,14 @@ def get_score_set(self, gamemode: Gamemode, score_set: ScoreSet = ScoreSet.NORMA ) ) .annotate( - default_performance_value=FilteredRelation( - "default_performance_calculation__performance_values", + performance_value=FilteredRelation( + "performance_calculation__performance_values", condition=Q( - default_performance_calculation__performance_values__name="total" + performance_calculation__performance_values__name=primary_performance_value ), ) ) - .annotate( - default_performance_total=models.F("default_performance_value__value") - ) + .annotate(performance_total=models.F("performance_value__value")) ) return annotated_scores.filter( @@ -430,12 +447,12 @@ def get_score_set(self, gamemode: Gamemode, score_set: ScoreSet = ScoreSet.NORMA annotated_scores.all() .order_by( "beatmap_id", # ordering first by beatmap_id is required for distinct - "-default_performance_total", # required to make sure we dont distinct out the wrong scores + "-performance_total", # required to make sure we dont distinct out the wrong scores ) .distinct("beatmap_id") .values("id") ) - ).order_by("-default_performance_total", "date") + ).order_by("-performance_total", "date") class Score(models.Model): @@ -579,13 +596,19 @@ def get_nochoke_mutation(self): return score - def get_default_performance_calculation(self): - difficulty_calculator = get_default_difficulty_calculator_class( - Gamemode(self.gamemode) - ) + def get_performance_calculation( + self, calculator_engine: typing.Optional[str] = None + ): + if calculator_engine is not None: + calculator_engine_name = calculator_engine + else: + calculator_engine_name = get_default_difficulty_calculator_class( + Gamemode(self.gamemode) + ).engine() + return PerformanceCalculation.objects.get( score=self, - calculator_engine=difficulty_calculator.engine(), + calculator_engine=calculator_engine_name, ) def __str__(self): diff --git a/profiles/services.py b/profiles/services.py index fbec9b2..28e5eb8 100644 --- a/profiles/services.py +++ b/profiles/services.py @@ -13,10 +13,7 @@ DifficultyCalculatorException, ) from common.osu.difficultycalculator import Score as DifficultyCalculatorScore -from common.osu.difficultycalculator import ( - get_default_difficulty_calculator_class, - get_difficulty_calculators_for_gamemode, -) +from common.osu.difficultycalculator import get_difficulty_calculators_for_gamemode from common.osu.enums import BeatmapStatus, Gamemode, Mods from leaderboards.models import Leaderboard, Membership from profiles.enums import ScoreMutation, ScoreResult From da86e59dcffc54c592df6404e19d7c41685cf1e6 Mon Sep 17 00:00:00 2001 From: Samuel Cattini-Schultz Date: Mon, 26 Aug 2024 23:48:18 +1000 Subject: [PATCH 5/5] Disable diffcalc settings on new leaderboards Temporarily disabling until we have a frontend to handle this --- leaderboards/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/leaderboards/views.py b/leaderboards/views.py index a84403b..f3b63b4 100644 --- a/leaderboards/views.py +++ b/leaderboards/views.py @@ -116,7 +116,9 @@ def post(self, request, leaderboard_type, gamemode): if score_filter_data is None: raise ParseError("Missing score_filter parameter.") - calculator_engine = request.data.get("calculator_engine") + # TODO: enable this once we have a frontend for it + # calculator_engine = request.data.get("calculator_engine") + calculator_engine = None if calculator_engine is None: calculator_engine = get_default_difficulty_calculator_class( gamemode