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>
This commit is contained in:
MingxuanGame
2025-10-26 21:10:36 +08:00
committed by GitHub
parent 8f4a9d5fed
commit 33f321952d
24 changed files with 3134 additions and 74 deletions

View File

@@ -1,5 +1,5 @@
import abc
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, NamedTuple
from app.models.mods import APIMod
from app.models.performance import DifficultyAttributes, PerformanceAttributes
@@ -25,7 +25,16 @@ class PerformanceError(CalculateError):
"""The performance could not be calculated."""
class AvailableModes(NamedTuple):
has_performance_calculator: set[GameMode]
has_difficulty_calculator: set[GameMode]
class PerformanceCalculator(abc.ABC):
@abc.abstractmethod
async def get_available_modes(self) -> AvailableModes:
raise NotImplementedError
@abc.abstractmethod
async def calculate_performance(self, beatmap_raw: str, score: "Score") -> PerformanceAttributes:
raise NotImplementedError
@@ -35,3 +44,15 @@ class PerformanceCalculator(abc.ABC):
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
) -> DifficultyAttributes:
raise NotImplementedError
async def can_calculate_performance(self, gamemode: GameMode) -> bool:
modes = await self.get_available_modes()
return gamemode in modes.has_performance_calculator
async def can_calculate_difficulty(self, gamemode: GameMode) -> bool:
modes = await self.get_available_modes()
return gamemode in modes.has_difficulty_calculator
async def init(self) -> None:
"""Initialize the calculator (if needed)."""
pass

View File

@@ -1,4 +1,6 @@
from typing import TYPE_CHECKING
import asyncio
import datetime
from typing import TYPE_CHECKING, TypedDict, cast
from app.models.mods import APIMod
from app.models.performance import (
@@ -10,6 +12,7 @@ from app.models.performance import (
from app.models.score import GameMode
from ._base import (
AvailableModes,
CalculateError,
DifficultyError,
PerformanceCalculator as BasePerformanceCalculator,
@@ -23,10 +26,61 @@ if TYPE_CHECKING:
from app.database.score import Score
class PerformanceCalculator(BasePerformanceCalculator):
class AvailableRulesetResp(TypedDict):
has_performance_calculator: list[str]
has_difficulty_calculator: list[str]
loaded_rulesets: list[str]
class PerformanceServerPerformanceCalculator(BasePerformanceCalculator):
def __init__(self, server_url: str = "http://localhost:5225") -> None:
self.server_url = server_url
self._available_modes: AvailableModes | None = None
self._modes_lock = asyncio.Lock()
self._today = datetime.date.today()
async def init(self):
await self.get_available_modes()
def _process_modes(self, modes: AvailableRulesetResp) -> AvailableModes:
performance_modes = {
m for mode in modes["has_performance_calculator"] if (m := GameMode.parse(mode)) is not None
}
difficulty_modes = {m for mode in modes["has_difficulty_calculator"] if (m := GameMode.parse(mode)) is not None}
if GameMode.OSU in performance_modes:
performance_modes.add(GameMode.OSURX)
performance_modes.add(GameMode.OSUAP)
if GameMode.TAIKO in performance_modes:
performance_modes.add(GameMode.TAIKORX)
if GameMode.FRUITS in performance_modes:
performance_modes.add(GameMode.FRUITSRX)
return AvailableModes(
has_performance_calculator=performance_modes,
has_difficulty_calculator=difficulty_modes,
)
async def get_available_modes(self) -> AvailableModes:
# https://github.com/GooGuTeam/osu-performance-server#get-available_rulesets
if self._available_modes is not None and self._today == datetime.date.today():
return self._available_modes
async with self._modes_lock, AsyncClient() as client:
try:
resp = await client.get(f"{self.server_url}/available_rulesets")
if resp.status_code != 200:
raise CalculateError(f"Failed to get available modes: {resp.text}")
modes = cast(AvailableRulesetResp, resp.json())
result = self._process_modes(modes)
self._available_modes = result
self._today = datetime.date.today()
return result
except HTTPError as e:
raise CalculateError(f"Failed to get available modes: {e}") from e
except Exception as e:
raise CalculateError(f"Unknown error: {e}") from e
async def calculate_performance(self, beatmap_raw: str, score: "Score") -> PerformanceAttributes:
# https://github.com/GooGuTeam/osu-performance-server#post-performance
async with AsyncClient() as client:
@@ -74,7 +128,7 @@ class PerformanceCalculator(BasePerformanceCalculator):
json={
"beatmap_file": beatmap_raw,
"mods": mods or [],
"ruleset": int(gamemode) if gamemode else None,
"ruleset": gamemode.value if gamemode else None,
},
)
if resp.status_code != 200:
@@ -84,3 +138,6 @@ class PerformanceCalculator(BasePerformanceCalculator):
raise DifficultyError(f"Failed to calculate difficulty: {e}") from e
except Exception as e:
raise DifficultyError(f"Unknown error: {e}") from e
PerformanceCalculator = PerformanceServerPerformanceCalculator

View File

@@ -1,6 +1,6 @@
from asyncio import get_event_loop
from copy import deepcopy
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, ClassVar
from app.calculator import clamp
from app.models.mods import APIMod, parse_enum_to_str
@@ -16,6 +16,7 @@ from app.models.performance import (
from app.models.score import GameMode
from ._base import (
AvailableModes,
CalculateError,
ConvertError,
DifficultyError,
@@ -47,7 +48,18 @@ DIFFICULTY_CLASS = {
}
class PerformanceCalculator(BasePerformanceCalculator):
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 {
@@ -70,6 +82,12 @@ class PerformanceCalculator(BasePerformanceCalculator):
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)
@@ -185,3 +203,6 @@ class PerformanceCalculator(BasePerformanceCalculator):
raise DifficultyError(f"Beatmap parse error: {e}")
except Exception as e:
raise CalculateError(f"Unknown error: {e}") from e
PerformanceCalculator = RosuPerformanceCalculator