From d9d26d052300b43c23a9d6c0d45f0c3d64814f39 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Nov 2025 20:56:29 +0800 Subject: [PATCH] feat(statistics): store `ranked_score` & `total_score` under classic scoring mode (#68) * Initial plan * feat(calculator): add classic score simulator and scoring mode support - Add ScoringMode enum with STANDARDISED and CLASSIC modes - Add scoring_mode configuration to game settings - Implement GetDisplayScore function in calculator.py - Add get_display_score method to Score model - Update score statistics to use display scores based on scoring mode Co-authored-by: MingxuanGame <68982190+MingxuanGame@users.noreply.github.com> * fix(calculator): apply scoring mode to TotalScoreBestScore delete method - Update delete method to use display score for consistency - Ensures all UserStatistics modifications use configured scoring mode Co-authored-by: MingxuanGame <68982190+MingxuanGame@users.noreply.github.com> * refactor(calculator): address code review feedback - Move MAX_SCORE constant to app/const.py - Implement is_basic() as method in HitResult enum - Move imports to top of file in Score model - Revert TotalScoreBestScore storage to use standardised score - Apply display score calculation in tools/recalculate.py - Keep display score usage in UserStatistics modifications Co-authored-by: MingxuanGame <68982190+MingxuanGame@users.noreply.github.com> * chore(linter): auto fix by pre-commit hooks * Don't use forward-ref for `ScoringMode` * chore(linter): auto fix by pre-commit hooks * fix(calculator): update HitResult usage in get_display_score and adjust ruleset value in PerformanceServerPerformanceCalculator --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: MingxuanGame --- app/calculator.py | 55 ++++++++++++++++++- .../performance/performance_server.py | 2 +- app/config.py | 10 ++++ app/const.py | 4 ++ app/database/score.py | 33 ++++++++++- app/database/total_score_best_scores.py | 6 +- app/models/score.py | 22 ++++++++ app/models/scoring_mode.py | 13 +++++ tools/recalculate.py | 8 ++- 9 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 app/models/scoring_mode.py diff --git a/app/calculator.py b/app/calculator.py index 01fa51c..0065471 100644 --- a/app/calculator.py +++ b/app/calculator.py @@ -6,8 +6,10 @@ from typing import TYPE_CHECKING from app.calculators.performance import PerformanceCalculator from app.config import settings +from app.const import MAX_SCORE from app.log import log -from app.models.score import GameMode +from app.models.score import GameMode, HitResult, ScoreStatistics +from app.models.scoring_mode import ScoringMode from osupyparser import HitObject, OsuFile from osupyparser.osu.objects import Slider @@ -52,6 +54,57 @@ def clamp[T: int | float](n: T, min_value: T, max_value: T) -> T: return n +def get_display_score(ruleset_id: int, total_score: int, mode: ScoringMode, maximum_statistics: ScoreStatistics) -> int: + """ + Calculate the display score based on the scoring mode. + + Based on: https://github.com/ppy/osu/blob/master/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs + + Args: + ruleset_id: The ruleset ID (0=osu!, 1=taiko, 2=catch, 3=mania) + total_score: The standardised total score + mode: The scoring mode (standardised or classic) + maximum_statistics: Dictionary of maximum statistics for the score + + Returns: + The display score in the requested scoring mode + """ + if mode == ScoringMode.STANDARDISED: + return total_score + + # Calculate max basic judgements + max_basic_judgements = sum( + count for hit_result, count in maximum_statistics.items() if HitResult(hit_result).is_basic() + ) + + return _convert_standardised_to_classic(ruleset_id, total_score, max_basic_judgements) + + +def _convert_standardised_to_classic(ruleset_id: int, standardised_total_score: int, object_count: int) -> int: + """ + Convert a standardised score to classic score. + + The coefficients were determined by a least-squares fit to minimise relative error + of maximum possible base score across all beatmaps. + + Args: + ruleset_id: The ruleset ID (0=osu!, 1=taiko, 2=catch, 3=mania) + standardised_total_score: The standardised total score + object_count: The number of basic hit objects + + Returns: + The classic score + """ + if ruleset_id == 0: # osu! + return round((object_count**2 * 32.57 + 100000) * standardised_total_score / MAX_SCORE) + elif ruleset_id == 1: # taiko + return round((object_count * 1109 + 100000) * standardised_total_score / MAX_SCORE) + elif ruleset_id == 2: # catch + return round((standardised_total_score / MAX_SCORE * object_count) ** 2 * 21.62 + standardised_total_score / 10) + else: # mania (ruleset_id == 3) or default + return standardised_total_score + + def calculate_pp_for_no_calculator(score: "Score", star_rating: float) -> float: # TODO: Improve this algorithm # https://www.desmos.com/calculator/i2aa7qm3o6 diff --git a/app/calculators/performance/performance_server.py b/app/calculators/performance/performance_server.py index b0916db..2bab2bb 100644 --- a/app/calculators/performance/performance_server.py +++ b/app/calculators/performance/performance_server.py @@ -106,7 +106,7 @@ class PerformanceServerPerformanceCalculator(BasePerformanceCalculator): "small_tick_hit": score.nsmall_tick_hit or 0, "slider_tail_hit": score.nslider_tail_hit or 0, }, - "ruleset": score.gamemode.value, + "ruleset": score.gamemode.to_base_ruleset().value, }, ) if resp.status_code != 200: diff --git a/app/config.py b/app/config.py index e1dc8fd..ba71ea0 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,8 @@ from enum import Enum from typing import Annotated, Any, Literal +from app.models.scoring_mode import ScoringMode + from pydantic import ( AliasChoices, Field, @@ -521,6 +523,14 @@ CALCULATOR_CONFIG='{ ), "游戏设置", ] + scoring_mode: Annotated[ + ScoringMode, + Field( + default=ScoringMode.STANDARDISED, + description="分数计算模式:standardised(标准化)或 classic(经典)", + ), + "游戏设置", + ] # 表现计算设置 calculator: Annotated[ diff --git a/app/const.py b/app/const.py index fd5f421..ff0906a 100644 --- a/app/const.py +++ b/app/const.py @@ -3,3 +3,7 @@ BANCHOBOT_ID = 2 BACKUP_CODE_LENGTH = 10 SUPPORT_TOTP_VERIFICATION_VER = 20250913 + +# Maximum score in standardised scoring mode +# https://github.com/ppy/osu/blob/master/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +MAX_SCORE = 1000000 diff --git a/app/database/score.py b/app/database/score.py index 8c2f851..49bdecd 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -11,6 +11,7 @@ from app.calculator import ( calculate_weighted_acc, calculate_weighted_pp, clamp, + get_display_score, pre_fetch_and_calculate_pp, ) from app.config import settings @@ -34,6 +35,7 @@ from app.models.score import ( ScoreStatistics, SoloScoreSubmissionInfo, ) +from app.models.scoring_mode import ScoringMode from app.storage import StorageService from app.utils import utcnow @@ -223,6 +225,26 @@ class Score(ScoreBase, table=True): def replay_filename(self) -> str: return f"replays/{self.id}_{self.beatmap_id}_{self.user_id}_lazer_replay.osr" + def get_display_score(self, mode: ScoringMode | None = None) -> int: + """ + Get the display score for this score based on the scoring mode. + + Args: + mode: The scoring mode to use. If None, uses the global setting. + + Returns: + The display score in the requested scoring mode + """ + if mode is None: + mode = settings.scoring_mode + + return get_display_score( + ruleset_id=int(self.gamemode), + total_score=self.total_score, + mode=mode, + maximum_statistics=self.maximum_statistics, + ) + async def to_resp(self, session: AsyncSession, api_version: int) -> "ScoreResp | LegacyScoreResp": if api_version >= 20220705: return await ScoreResp.from_db(session, self) @@ -1108,12 +1130,17 @@ async def _process_statistics( raise ValueError(f"User {user.id} does not have statistics for mode {score.gamemode.value}") # pc, pt, tth, tts - statistics.total_score += score.total_score - difference = score.total_score - previous_score_best.total_score if previous_score_best else score.total_score + # Get display scores based on configured scoring mode + current_display_score = score.get_display_score() + previous_display_score = previous_score_best.score.get_display_score() if previous_score_best else 0 + + statistics.total_score += current_display_score + difference = current_display_score - previous_display_score logger.debug( - "Score delta computed for {score_id}: {difference}", + "Score delta computed for {score_id}: {difference} (display score in {mode} mode)", score_id=score.id, difference=difference, + mode=settings.scoring_mode, ) if difference > 0 and score.passed and ranked: match score.rank: diff --git a/app/database/total_score_best_scores.py b/app/database/total_score_best_scores.py index 8c52c1e..1ab1d5a 100644 --- a/app/database/total_score_best_scores.py +++ b/app/database/total_score_best_scores.py @@ -56,8 +56,10 @@ class TotalScoreBestScore(SQLModel, table=True): ) statistics = statistics.first() if statistics: - statistics.total_score -= self.total_score - statistics.ranked_score -= self.total_score + # Use display score from the referenced score for consistency with current scoring mode + display_score = self.score.get_display_score() + statistics.total_score -= display_score + statistics.ranked_score -= display_score statistics.level_current = calculate_score_to_level(statistics.total_score) match self.rank: case Rank.X: diff --git a/app/models/score.py b/app/models/score.py index 9ae3308..2a6d296 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -251,6 +251,28 @@ class HitResult(str, Enum): HitResult.IGNORE_MISS, ) + def is_basic(self) -> bool: + """ + Check if a HitResult is a basic (non-tick, non-bonus) result. + + Based on: https://github.com/ppy/osu/blob/master/osu.Game/Rulesets/Scoring/HitResult.cs + """ + if self in {HitResult.LEGACY_COMBO_INCREASE, HitResult.COMBO_BREAK}: + return False + + # Check if it's scorable and not a tick or bonus + is_tick = self in { + HitResult.LARGE_TICK_HIT, + HitResult.LARGE_TICK_MISS, + HitResult.SMALL_TICK_HIT, + HitResult.SMALL_TICK_MISS, + HitResult.SLIDER_TAIL_HIT, + } + + is_bonus = self in {HitResult.SMALL_BONUS, HitResult.LARGE_BONUS} + + return self.is_scorable() and not is_tick and not is_bonus + class LeaderboardType(Enum): GLOBAL = "global" diff --git a/app/models/scoring_mode.py b/app/models/scoring_mode.py new file mode 100644 index 0000000..615e56a --- /dev/null +++ b/app/models/scoring_mode.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class ScoringMode(str, Enum): + """ + Scoring mode for calculating scores. + + STANDARDISED: Modern scoring mode used in current osu!lazer + CLASSIC: Legacy scoring mode for backward compatibility + """ + + STANDARDISED = "standardised" + CLASSIC = "classic" diff --git a/tools/recalculate.py b/tools/recalculate.py index 5f89bc1..ae79ada 100644 --- a/tools/recalculate.py +++ b/tools/recalculate.py @@ -374,7 +374,9 @@ async def _recalculate_statistics( continue statistics.play_count += 1 - statistics.total_score += score.total_score + # Use display score based on configured scoring mode + display_score = score.get_display_score() + statistics.total_score += display_score playtime, is_valid = calculate_playtime(score, beatmap.hit_length) if is_valid: @@ -390,7 +392,9 @@ async def _recalculate_statistics( if ranked and score.passed: statistics.maximum_combo = max(statistics.maximum_combo, score.max_combo) previous = cached_best.get(score.beatmap_id) - difference = score.total_score - (previous.total_score if previous else 0) + # Calculate difference using display scores + previous_display = previous.get_display_score() if previous else 0 + difference = display_score - previous_display if difference > 0: cached_best[score.beatmap_id] = score statistics.ranked_score += difference