229 lines
8.6 KiB
Python
229 lines
8.6 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
|
|
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,
|
|
}
|
|
|
|
_enum_to_str = {
|
|
0: {
|
|
"MR": {"reflection"},
|
|
"AC": {"accuracy_judge_mode"},
|
|
"BR": {"direction"},
|
|
"AD": {"style"},
|
|
},
|
|
1: {"AC": {"accuracy_judge_mode"}},
|
|
2: {"AC": {"accuracy_judge_mode"}},
|
|
3: {"AC": {"accuracy_judge_mode"}},
|
|
}
|
|
|
|
|
|
def _parse_enum_to_str(ruleset_id: int, mods: list[APIMod]):
|
|
for mod in mods:
|
|
if mod["acronym"] in _enum_to_str.get(ruleset_id, {}):
|
|
for setting in mod.get("settings", {}):
|
|
if setting in _enum_to_str[ruleset_id][mod["acronym"]]:
|
|
mod["settings"][setting] = str(mod["settings"][setting]) # pyright: ignore[reportTypedDictNotRequiredAccess]
|
|
|
|
|
|
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
|