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 <MingxuanGame@outlook.com>
This commit is contained in:
@@ -6,8 +6,10 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from app.calculators.performance import PerformanceCalculator
|
from app.calculators.performance import PerformanceCalculator
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.const import MAX_SCORE
|
||||||
from app.log import log
|
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 import HitObject, OsuFile
|
||||||
from osupyparser.osu.objects import Slider
|
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
|
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:
|
def calculate_pp_for_no_calculator(score: "Score", star_rating: float) -> float:
|
||||||
# TODO: Improve this algorithm
|
# TODO: Improve this algorithm
|
||||||
# https://www.desmos.com/calculator/i2aa7qm3o6
|
# https://www.desmos.com/calculator/i2aa7qm3o6
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class PerformanceServerPerformanceCalculator(BasePerformanceCalculator):
|
|||||||
"small_tick_hit": score.nsmall_tick_hit or 0,
|
"small_tick_hit": score.nsmall_tick_hit or 0,
|
||||||
"slider_tail_hit": score.nslider_tail_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:
|
if resp.status_code != 200:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
|
from app.models.scoring_mode import ScoringMode
|
||||||
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
AliasChoices,
|
AliasChoices,
|
||||||
Field,
|
Field,
|
||||||
@@ -521,6 +523,14 @@ CALCULATOR_CONFIG='{
|
|||||||
),
|
),
|
||||||
"游戏设置",
|
"游戏设置",
|
||||||
]
|
]
|
||||||
|
scoring_mode: Annotated[
|
||||||
|
ScoringMode,
|
||||||
|
Field(
|
||||||
|
default=ScoringMode.STANDARDISED,
|
||||||
|
description="分数计算模式:standardised(标准化)或 classic(经典)",
|
||||||
|
),
|
||||||
|
"游戏设置",
|
||||||
|
]
|
||||||
|
|
||||||
# 表现计算设置
|
# 表现计算设置
|
||||||
calculator: Annotated[
|
calculator: Annotated[
|
||||||
|
|||||||
@@ -3,3 +3,7 @@ BANCHOBOT_ID = 2
|
|||||||
BACKUP_CODE_LENGTH = 10
|
BACKUP_CODE_LENGTH = 10
|
||||||
|
|
||||||
SUPPORT_TOTP_VERIFICATION_VER = 20250913
|
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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.calculator import (
|
|||||||
calculate_weighted_acc,
|
calculate_weighted_acc,
|
||||||
calculate_weighted_pp,
|
calculate_weighted_pp,
|
||||||
clamp,
|
clamp,
|
||||||
|
get_display_score,
|
||||||
pre_fetch_and_calculate_pp,
|
pre_fetch_and_calculate_pp,
|
||||||
)
|
)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -34,6 +35,7 @@ from app.models.score import (
|
|||||||
ScoreStatistics,
|
ScoreStatistics,
|
||||||
SoloScoreSubmissionInfo,
|
SoloScoreSubmissionInfo,
|
||||||
)
|
)
|
||||||
|
from app.models.scoring_mode import ScoringMode
|
||||||
from app.storage import StorageService
|
from app.storage import StorageService
|
||||||
from app.utils import utcnow
|
from app.utils import utcnow
|
||||||
|
|
||||||
@@ -223,6 +225,26 @@ class Score(ScoreBase, table=True):
|
|||||||
def replay_filename(self) -> str:
|
def replay_filename(self) -> str:
|
||||||
return f"replays/{self.id}_{self.beatmap_id}_{self.user_id}_lazer_replay.osr"
|
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":
|
async def to_resp(self, session: AsyncSession, api_version: int) -> "ScoreResp | LegacyScoreResp":
|
||||||
if api_version >= 20220705:
|
if api_version >= 20220705:
|
||||||
return await ScoreResp.from_db(session, self)
|
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}")
|
raise ValueError(f"User {user.id} does not have statistics for mode {score.gamemode.value}")
|
||||||
|
|
||||||
# pc, pt, tth, tts
|
# pc, pt, tth, tts
|
||||||
statistics.total_score += score.total_score
|
# Get display scores based on configured scoring mode
|
||||||
difference = score.total_score - previous_score_best.total_score if previous_score_best else score.total_score
|
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(
|
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,
|
score_id=score.id,
|
||||||
difference=difference,
|
difference=difference,
|
||||||
|
mode=settings.scoring_mode,
|
||||||
)
|
)
|
||||||
if difference > 0 and score.passed and ranked:
|
if difference > 0 and score.passed and ranked:
|
||||||
match score.rank:
|
match score.rank:
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ class TotalScoreBestScore(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
statistics = statistics.first()
|
statistics = statistics.first()
|
||||||
if statistics:
|
if statistics:
|
||||||
statistics.total_score -= self.total_score
|
# Use display score from the referenced score for consistency with current scoring mode
|
||||||
statistics.ranked_score -= self.total_score
|
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)
|
statistics.level_current = calculate_score_to_level(statistics.total_score)
|
||||||
match self.rank:
|
match self.rank:
|
||||||
case Rank.X:
|
case Rank.X:
|
||||||
|
|||||||
@@ -251,6 +251,28 @@ class HitResult(str, Enum):
|
|||||||
HitResult.IGNORE_MISS,
|
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):
|
class LeaderboardType(Enum):
|
||||||
GLOBAL = "global"
|
GLOBAL = "global"
|
||||||
|
|||||||
13
app/models/scoring_mode.py
Normal file
13
app/models/scoring_mode.py
Normal file
@@ -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"
|
||||||
@@ -374,7 +374,9 @@ async def _recalculate_statistics(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
statistics.play_count += 1
|
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)
|
playtime, is_valid = calculate_playtime(score, beatmap.hit_length)
|
||||||
if is_valid:
|
if is_valid:
|
||||||
@@ -390,7 +392,9 @@ async def _recalculate_statistics(
|
|||||||
if ranked and score.passed:
|
if ranked and score.passed:
|
||||||
statistics.maximum_combo = max(statistics.maximum_combo, score.max_combo)
|
statistics.maximum_combo = max(statistics.maximum_combo, score.max_combo)
|
||||||
previous = cached_best.get(score.beatmap_id)
|
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:
|
if difference > 0:
|
||||||
cached_best[score.beatmap_id] = score
|
cached_best[score.beatmap_id] = score
|
||||||
statistics.ranked_score += difference
|
statistics.ranked_score += difference
|
||||||
|
|||||||
Reference in New Issue
Block a user