Skip to content

Commit

Permalink
Merge pull request #103 from Syriiin/add-leaderboard-calc-selection
Browse files Browse the repository at this point in the history
Add diffcalc support for leaderboards
  • Loading branch information
Syriiin authored Aug 27, 2024
2 parents 2a10ab6 + da86e59 commit 9b353a0
Show file tree
Hide file tree
Showing 15 changed files with 153 additions and 53 deletions.
13 changes: 13 additions & 0 deletions common/osu/difficultycalculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
2 changes: 2 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)
Expand Down
22 changes: 22 additions & 0 deletions leaderboards/migrations/0024_leaderboard_calculator_engine.py
Original file line number Diff line number Diff line change
@@ -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,
),
]
Original file line number Diff line number Diff line change
@@ -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,
),
]
2 changes: 2 additions & 0 deletions leaderboards/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ 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)
primary_performance_value = models.CharField(max_length=20)

# Relations
score_filter = models.OneToOneField(ScoreFilter, on_delete=models.CASCADE)
Expand Down
2 changes: 2 additions & 0 deletions leaderboards/serialisers.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class Meta:
"allow_past_scores",
"member_count",
"archived",
"calculator_engine",
"primary_performance_value",
# relations
"score_filter",
"owner",
Expand Down
11 changes: 8 additions & 3 deletions leaderboards/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions leaderboards/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions leaderboards/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
),
)
Expand Down
30 changes: 28 additions & 2 deletions leaderboards/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -115,6 +116,19 @@ def post(self, request, leaderboard_type, gamemode):
if score_filter_data is None:
raise ParseError("Missing score_filter parameter.")

# 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
).engine()
primary_performance_value = "total"
else:
primary_performance_value = request.data.get(
"primary_performance_value", "total"
)

leaderboard = Leaderboard(
gamemode=gamemode,
score_set=score_set,
Expand All @@ -123,6 +137,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
Expand Down Expand Up @@ -564,7 +580,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",
Expand Down Expand Up @@ -603,7 +624,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",
Expand Down
2 changes: 0 additions & 2 deletions profiles/management/commands/recalculate.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
73 changes: 43 additions & 30 deletions profiles/models.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
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,
get_difficulty_calculator_class_for_engine,
)
from common.osu.enums import BeatmapStatus, Gamemode, Mods
from profiles.enums import AllowedBeatmapStatus, ScoreMutation, ScoreResult, ScoreSet
Expand Down Expand Up @@ -115,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
Expand Down Expand Up @@ -244,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):
Expand Down Expand Up @@ -396,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.
Expand All @@ -411,41 +416,43 @@ 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()
),
)
)
.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(
id__in=Subquery(
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):
Expand Down Expand Up @@ -589,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):
Expand Down
5 changes: 1 addition & 4 deletions profiles/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion profiles/test_models.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Loading

0 comments on commit 9b353a0

Please sign in to comment.