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 generatorc891bcd159ande25041ad3b* 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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user