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

@@ -335,7 +335,7 @@ def _mods_can_get_pp(ruleset_id: int, mods: list[APIMod], ranked_mods: RankedMod
continue
if app_settings.enable_ap and mod["acronym"] == "AP" and ruleset_id == 0:
continue
check_settings_result = check_settings(mod, ranked_mods[ruleset_id])
check_settings_result = check_settings(mod, ranked_mods.get(ruleset_id, {}))
if not check_settings_result:
return False
return True

View File

@@ -1,4 +1,4 @@
# Version: 2025.10.19
# Version: 2025.1012.1
# Auto-generated by scripts/generate_ruleset_attributes.py.
# Schema generated by https://github.com/GooGuTeam/custom-rulesets
# Do not edit this file directly.
@@ -91,30 +91,12 @@ class ManiaPerformanceAttributes(PerformanceAttributes):
ManiaDifficultyAttributes = DifficultyAttributes
HishigataPerformanceAttributes = PerformanceAttributes
HishigataDifficultyAttributes = DifficultyAttributes
RushPerformanceAttributes = PerformanceAttributes
RushDifficultyAttributes = DifficultyAttributes
SentakkiPerformanceAttributes = PerformanceAttributes
SentakkiDifficultyAttributes = DifficultyAttributes
SoyokazePerformanceAttributes = PerformanceAttributes
SoyokazeDifficultyAttributes = DifficultyAttributes
class TauPerformanceAttribute(PerformanceAttributes):
aim: float
speed: float
@@ -132,6 +114,22 @@ class TauDifficultyAttributes(DifficultyAttributes):
overall_difficulty: float
RushPerformanceAttributes = PerformanceAttributes
RushDifficultyAttributes = DifficultyAttributes
HishigataPerformanceAttributes = PerformanceAttributes
HishigataDifficultyAttributes = DifficultyAttributes
SoyokazePerformanceAttributes = PerformanceAttributes
SoyokazeDifficultyAttributes = DifficultyAttributes
PerformanceAttributesUnion = (
OsuPerformanceAttributes | TaikoPerformanceAttributes | ManiaPerformanceAttributes | PerformanceAttributes
)

View File

@@ -1,23 +1,56 @@
from enum import Enum
from typing import Literal, TypedDict, cast
import json
from typing import NamedTuple, TypedDict, cast
from app.config import settings
from app.path import STATIC_DIR
from .mods import API_MODS, APIMod
from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator
VersionEntry = TypedDict("VersionEntry", {"latest-version": str, "versions": dict[str, str]})
DOWNLOAD_URL = "https://github.com/GooGuTeam/custom-rulesets/releases/tag/{version}"
class RulesetCheckResult(NamedTuple):
is_current: bool
latest_version: str = ""
current_version: str | None = None
download_url: str | None = None
def __bool__(self) -> bool:
return self.is_current
@property
def error_msg(self) -> str | None:
if self.is_current:
return None
msg = f"Ruleset is outdated. Latest version: {self.latest_version}."
if self.current_version:
msg += f" Current version: {self.current_version}."
if self.download_url:
msg += f" Download at: {self.download_url}"
return msg
class GameMode(str, Enum):
OSU = "osu"
TAIKO = "taiko"
FRUITS = "fruits"
MANIA = "mania"
OSURX = "osurx"
OSUAP = "osuap"
TAIKORX = "taikorx"
FRUITSRX = "fruitsrx"
SENTAKKI = "Sentakki"
TAU = "tau"
RUSH = "rush"
HISHIGATA = "hishigata"
SOYOKAZE = "soyokaze"
def __int__(self) -> int:
return {
GameMode.OSU: 0,
@@ -28,6 +61,11 @@ class GameMode(str, Enum):
GameMode.OSUAP: 0,
GameMode.TAIKORX: 1,
GameMode.FRUITSRX: 2,
GameMode.SENTAKKI: 10,
GameMode.TAU: 11,
GameMode.RUSH: 12,
GameMode.HISHIGATA: 13,
GameMode.SOYOKAZE: 14,
}[self]
def __str__(self) -> str:
@@ -40,20 +78,22 @@ class GameMode(str, Enum):
1: GameMode.TAIKO,
2: GameMode.FRUITS,
3: GameMode.MANIA,
10: GameMode.SENTAKKI,
11: GameMode.TAU,
12: GameMode.RUSH,
13: GameMode.HISHIGATA,
14: GameMode.SOYOKAZE,
}[v]
@classmethod
def from_int_extra(cls, v: int) -> "GameMode":
return {
0: GameMode.OSU,
1: GameMode.TAIKO,
2: GameMode.FRUITS,
3: GameMode.MANIA,
gamemode = {
4: GameMode.OSURX,
5: GameMode.OSUAP,
6: GameMode.TAIKORX,
7: GameMode.FRUITSRX,
}[v]
}.get(v)
return gamemode or cls.from_int(v)
def readable(self) -> str:
return {
@@ -65,8 +105,27 @@ class GameMode(str, Enum):
GameMode.OSUAP: "osu!autopilot",
GameMode.TAIKORX: "taiko relax",
GameMode.FRUITSRX: "catch relax",
GameMode.SENTAKKI: "sentakki",
GameMode.TAU: "tau",
GameMode.RUSH: "Rush!",
GameMode.HISHIGATA: "hishigata",
GameMode.SOYOKAZE: "soyokaze!",
}[self]
def is_official(self) -> bool:
return self in {
GameMode.OSU,
GameMode.TAIKO,
GameMode.FRUITS,
GameMode.MANIA,
GameMode.OSURX,
GameMode.TAIKORX,
GameMode.FRUITSRX,
}
def is_custom_ruleset(self) -> bool:
return not self.is_official()
def to_base_ruleset(self) -> "GameMode":
gamemode = {
GameMode.OSURX: GameMode.OSU,
@@ -74,7 +133,7 @@ class GameMode(str, Enum):
GameMode.TAIKORX: GameMode.TAIKO,
GameMode.FRUITSRX: GameMode.FRUITS,
}.get(self)
return gamemode if gamemode else self
return gamemode or self
def to_special_mode(self, mods: list[APIMod] | list[str]) -> "GameMode":
if self not in (GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS):
@@ -93,6 +152,27 @@ class GameMode(str, Enum):
}[self]
return self
def check_ruleset_version(self, hash: str) -> RulesetCheckResult:
if not settings.check_ruleset_version or self.is_official():
return RulesetCheckResult(True)
entry = RULESETS_VERSION_HASH.get(self)
if not entry:
return RulesetCheckResult(True)
latest_version = entry["latest-version"]
current_version = None
for version, version_hash in entry["versions"].items():
if version_hash == hash:
current_version = version
break
is_current = current_version == latest_version
return RulesetCheckResult(
is_current=is_current,
latest_version=latest_version,
current_version=current_version,
download_url=DOWNLOAD_URL.format(version=latest_version) if not is_current else None,
)
@classmethod
def parse(cls, v: str | int) -> "GameMode | None":
if isinstance(v, int) or v.isdigit():
@@ -189,7 +269,7 @@ class SoloScoreSubmissionInfo(BaseModel):
accuracy: float = Field(ge=0, le=1)
pp: float = Field(default=0, ge=0, le=2**31 - 1)
max_combo: int = 0
ruleset_id: Literal[0, 1, 2, 3]
ruleset_id: int
passed: bool = False
mods: list[APIMod] = Field(default_factory=list)
statistics: ScoreStatistics = Field(default_factory=dict)
@@ -241,3 +321,21 @@ class LegacyReplaySoloScoreInfo(TypedDict):
rank: Rank
user_id: int
total_score_without_mods: int
RULESETS_VERSION_HASH: dict[GameMode, VersionEntry] = {}
def init_ruleset_version_hash() -> None:
hash_file = STATIC_DIR / "custom_ruleset_version_hash.json"
if not hash_file.exists():
if settings.check_ruleset_version:
raise RuntimeError("Custom ruleset version hash file is missing")
rulesets = {}
else:
rulesets = json.loads(hash_file.read_text(encoding="utf-8"))
for mode_str, entry in rulesets.items():
mode = GameMode.parse(mode_str)
if mode is None:
continue
RULESETS_VERSION_HASH[mode] = entry