Files
g0v0-server/app/calculators/performance/rosu.py
MingxuanGame 33f321952d feat(custom-rulesets): support custom rulesets (#23)
* feat(custom_ruleset): add custom rulesets support

* feat(custom-ruleset): add version check

* feat(custom-ruleset): add LegacyIO API to get ruleset hashes

* feat(pp): add check for rulesets whose pp cannot be calculated

* docs(readme): update README to include support for custom rulesets

* fix(custom-ruleset): make `rulesets` empty instead of throw a error when version check is disabled

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore(custom-ruleset): apply the latest changes of generator

c891bcd159

and

e25041ad3b

* feat(calculator): add fallback performance calculation for unsupported modes

* fix(calculator): remove debug print

* fix: resolve reviews

* feat(calculator): add difficulty calculation checks

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-26 21:10:36 +08:00

209 lines
8.0 KiB
Python

from asyncio import get_event_loop
from copy import deepcopy
from typing import TYPE_CHECKING, ClassVar
from app.calculator import clamp
from app.models.mods import APIMod, parse_enum_to_str
from app.models.performance import (
DifficultyAttributes,
ManiaPerformanceAttributes,
OsuDifficultyAttributes,
OsuPerformanceAttributes,
PerformanceAttributes,
TaikoDifficultyAttributes,
TaikoPerformanceAttributes,
)
from app.models.score import GameMode
from ._base import (
AvailableModes,
CalculateError,
ConvertError,
DifficultyError,
PerformanceCalculator as BasePerformanceCalculator,
PerformanceError,
)
if TYPE_CHECKING:
from app.database.score import Score
try:
import rosu_pp_py as rosu
except ImportError:
raise ImportError(
"rosu-pp-py is not installed. "
"Please install it.\n"
" Official: uv add rosu-pp-py\n"
" gu: uv add git+https://github.com/GooGuTeam/gu-pp-py.git"
)
PERFORMANCE_CLASS = {
GameMode.OSU: OsuPerformanceAttributes,
GameMode.TAIKO: TaikoPerformanceAttributes,
GameMode.MANIA: ManiaPerformanceAttributes,
}
DIFFICULTY_CLASS = {
GameMode.OSU: OsuDifficultyAttributes,
GameMode.TAIKO: TaikoDifficultyAttributes,
}
class RosuPerformanceCalculator(BasePerformanceCalculator):
SUPPORT_MODES: ClassVar[set[GameMode]] = {
GameMode.OSU,
GameMode.TAIKO,
GameMode.FRUITS,
GameMode.MANIA,
GameMode.OSURX,
GameMode.OSUAP,
GameMode.TAIKORX,
GameMode.FRUITSRX,
}
@classmethod
def _to_rosu_mode(cls, mode: GameMode) -> rosu.GameMode:
return {
GameMode.OSU: rosu.GameMode.Osu,
GameMode.TAIKO: rosu.GameMode.Taiko,
GameMode.FRUITS: rosu.GameMode.Catch,
GameMode.MANIA: rosu.GameMode.Mania,
GameMode.OSURX: rosu.GameMode.Osu,
GameMode.OSUAP: rosu.GameMode.Osu,
GameMode.TAIKORX: rosu.GameMode.Taiko,
GameMode.FRUITSRX: rosu.GameMode.Catch,
}[mode]
@classmethod
def _from_rosu_mode(cls, mode: rosu.GameMode) -> GameMode:
return {
rosu.GameMode.Osu: GameMode.OSU,
rosu.GameMode.Taiko: GameMode.TAIKO,
rosu.GameMode.Catch: GameMode.FRUITS,
rosu.GameMode.Mania: GameMode.MANIA,
}[mode]
async def get_available_modes(self) -> AvailableModes:
return AvailableModes(
has_performance_calculator=self.SUPPORT_MODES,
has_difficulty_calculator=self.SUPPORT_MODES,
)
@classmethod
def _perf_attr_to_model(cls, attr: rosu.PerformanceAttributes, gamemode: GameMode) -> PerformanceAttributes:
attr_class = PERFORMANCE_CLASS.get(gamemode, PerformanceAttributes)
if attr_class is OsuPerformanceAttributes:
return OsuPerformanceAttributes(
pp=attr.pp,
aim=attr.pp_aim or 0,
speed=attr.pp_speed or 0,
accuracy=attr.pp_accuracy or 0,
flashlight=attr.pp_flashlight or 0,
effective_miss_count=attr.effective_miss_count or 0,
speed_deviation=attr.speed_deviation,
combo_based_estimated_miss_count=0,
score_based_estimated_miss_count=0,
aim_estimated_slider_breaks=0,
speed_estimated_slider_breaks=0,
)
elif attr_class is TaikoPerformanceAttributes:
return TaikoPerformanceAttributes(
pp=attr.pp,
difficulty=attr.pp_difficulty or 0,
accuracy=attr.pp_accuracy or 0,
estimated_unstable_rate=attr.estimated_unstable_rate,
)
elif attr_class is ManiaPerformanceAttributes:
return ManiaPerformanceAttributes(
pp=attr.pp,
difficulty=attr.pp_difficulty or 0,
)
else:
return PerformanceAttributes(pp=attr.pp)
async def calculate_performance(self, beatmap_raw: str, score: "Score") -> PerformanceAttributes:
try:
map = rosu.Beatmap(content=beatmap_raw)
mods = deepcopy(score.mods.copy())
parse_enum_to_str(int(score.gamemode), mods)
map.convert(self._to_rosu_mode(score.gamemode), mods) # pyright: ignore[reportArgumentType]
perf = rosu.Performance(
mods=mods,
lazer=True,
accuracy=clamp(score.accuracy * 100, 0, 100),
combo=score.max_combo,
large_tick_hits=score.nlarge_tick_hit or 0,
slider_end_hits=score.nslider_tail_hit or 0,
small_tick_hits=score.nsmall_tick_hit or 0,
n_geki=score.ngeki,
n_katu=score.nkatu,
n300=score.n300,
n100=score.n100,
n50=score.n50,
misses=score.nmiss,
)
attr = await get_event_loop().run_in_executor(None, perf.calculate, map)
return self._perf_attr_to_model(attr, score.gamemode.to_base_ruleset())
except rosu.ParseError as e: # pyright: ignore[reportAttributeAccessIssue]
raise PerformanceError(f"Beatmap parse error: {e}")
except Exception as e:
raise CalculateError(f"Unknown error: {e}") from e
@classmethod
def _diff_attr_to_model(cls, diff: rosu.DifficultyAttributes, gamemode: GameMode) -> DifficultyAttributes:
attr_class = DIFFICULTY_CLASS.get(gamemode, DifficultyAttributes)
if attr_class is OsuDifficultyAttributes:
return OsuDifficultyAttributes(
star_rating=diff.stars,
max_combo=diff.max_combo,
aim_difficulty=diff.aim or 0,
aim_difficult_slider_count=diff.aim_difficult_slider_count or 0,
speed_difficulty=diff.speed or 0,
speed_note_count=diff.speed_note_count or 0,
slider_factor=diff.slider_factor or 0,
aim_difficult_strain_count=diff.aim_difficult_strain_count or 0,
speed_difficult_strain_count=diff.speed_difficult_strain_count or 0,
flashlight_difficulty=diff.flashlight or 0,
aim_top_weighted_slider_factor=0,
speed_top_weighted_slider_factor=0,
nested_score_per_object=0,
legacy_score_base_multiplier=0,
maximum_legacy_combo_score=0,
)
elif attr_class is TaikoDifficultyAttributes:
return TaikoDifficultyAttributes(
star_rating=diff.stars,
max_combo=diff.max_combo,
rhythm_difficulty=diff.rhythm or 0,
mono_stamina_factor=diff.stamina or 0,
consistency_factor=0,
)
else:
return DifficultyAttributes(
star_rating=diff.stars,
max_combo=diff.max_combo,
)
async def calculate_difficulty(
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
) -> DifficultyAttributes:
try:
map = rosu.Beatmap(content=beatmap_raw)
if gamemode is not None:
map.convert(self._to_rosu_mode(gamemode), mods) # pyright: ignore[reportArgumentType]
diff_calculator = rosu.Difficulty(mods=mods)
diff = await get_event_loop().run_in_executor(None, diff_calculator.calculate, map)
return self._diff_attr_to_model(
diff, gamemode.to_base_ruleset() if gamemode else self._from_rosu_mode(diff.mode)
)
except rosu.ConvertError as e: # pyright: ignore[reportAttributeAccessIssue]
raise ConvertError(f"Beatmap convert error: {e}")
except rosu.ParseError as e: # pyright: ignore[reportAttributeAccessIssue]
raise DifficultyError(f"Beatmap parse error: {e}")
except Exception as e:
raise CalculateError(f"Unknown error: {e}") from e
PerformanceCalculator = RosuPerformanceCalculator