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

@@ -15,12 +15,32 @@ This is an osu! API server implemented with FastAPI + MySQL + Redis, supporting
- **OAuth 2.0 Authentication**: Supports password and refresh token flows.
- **User Data Management**: Complete user information, statistics, achievements, etc.
- **Multi-game Mode Support**: osu! (RX, AP), taiko (RX), catch (RX), mania.
- **Multi-game Mode Support**: osu! (RX, AP), taiko (RX), catch (RX), mania and custom rulesets (see below).
- **Database Persistence**: MySQL for storing user data.
- **Cache Support**: Redis for caching tokens and session information.
- **Multiple Storage Backends**: Supports local storage, Cloudflare R2, and AWS S3.
- **Containerized Deployment**: Docker and Docker Compose support.
## Supported Rulesets
**Ruleset**|**ID**|**ShortName**|**PP Algorithm (rosu)**|**PP Algorithm (performance-server)**
:-----:|:-----:|:-----:|:-----:|:-----:
osu!|`0`|`osu`|✅|✅
osu!taiko|`1`|`taiko`|✅|✅
osu!catch|`2`|`fruits`|✅|✅
osu!mania|`3`|`mania`|✅|✅
osu! (RX)|`4`|`osurx`|✅|✅
osu! (AP)|`5`|`osuap`|✅|✅
osu!taiko (RX)|`6`|`taikorx`|✅|✅
osu!catch (RX)|`7`|`fruitsrx`|✅|✅
[Sentakki](https://github.com/LumpBloom7/sentakki)|`10`|`Sentakki`|❌|❌
[tau](https://github.com/taulazer/tau)|`11`|`tau`|❌|✅
[Rush!](https://github.com/Beamographic/rush)|`12`|`rush`|❌|❌
[hishigata](https://github.com/LumpBloom7/hishigata)|`13`|`hishigata`|❌|❌
[soyokaze!](https://github.com/goodtrailer/soyokaze)|`14`|`soyokaze`|❌|✅
Go to [custom-rulesets](https://github.com/GooGuTeam/custom-rulesets) to download the custom rulesets modified for g0v0-server.
## Quick Start
### Using Docker Compose (Recommended)
@@ -102,7 +122,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
## Discussion

View File

@@ -15,13 +15,33 @@
- **OAuth 2.0 认证**: 支持密码流和刷新令牌流
- **用户数据管理**: 完整的用户信息、统计数据、成就等
- **多游戏模式支持**: osu! (RX, AP), taiko (RX), catch (RX), mania
- **多游戏模式支持**: osu! (RX, AP), taiko (RX), catch (RX), mania 和自定义 ruleset见下
- **数据库持久化**: MySQL 存储用户数据
- **缓存支持**: Redis 缓存令牌和会话信息
- **多种存储后端**: 支持本地存储、Cloudflare R2、AWS S3
- **容器化部署**: Docker 和 Docker Compose 支持
- **资源文件反向代理**: 可以将 osu! 官方的资源链接(头像、谱面封面、音频等)替换为自定义域名。
## 支持的 ruleset
**Ruleset**|**ID**|**ShortName**|**PP 算法 (rosu)**|**PP 算法 (performance-server)**
:-----:|:-----:|:-----:|:-----:|:-----:
osu!|`0`|`osu`|✅|✅
osu!taiko|`1`|`taiko`|✅|✅
osu!catch|`2`|`fruits`|✅|✅
osu!mania|`3`|`mania`|✅|✅
osu! (RX)|`4`|`osurx`|✅|✅
osu! (AP)|`5`|`osuap`|✅|✅
osu!taiko (RX)|`6`|`taikorx`|✅|✅
osu!catch (RX)|`7`|`fruitsrx`|✅|✅
[Sentakki](https://github.com/LumpBloom7/sentakki)|`10`|`Sentakki`|❌|❌
[tau](https://github.com/taulazer/tau)|`11`|`tau`|❌|✅
[Rush!](https://github.com/Beamographic/rush)|`12`|`rush`|❌|❌
[hishigata](https://github.com/LumpBloom7/hishigata)|`13`|`hishigata`|❌|❌
[soyokaze!](https://github.com/goodtrailer/soyokaze)|`14`|`soyokaze`|❌|✅
前往 [custom-rulesets](https://github.com/GooGuTeam/custom-rulesets/releases/latest) 下载为 g0v0-server 修改的自定义 ruleset。
## 快速开始
### 使用 Docker Compose (推荐)
@@ -113,5 +133,3 @@ docker-compose -f docker-compose-osurx.yml up -d
- QQ 群:`1059561526`
- Discord: https://discord.gg/AhzJXXWYfF

View File

@@ -1,5 +1,6 @@
from datetime import datetime
from app.calculator import get_calculator
from app.database.beatmap import calculate_beatmap_attributes
from app.database.score import Beatmap, Score
from app.dependencies.database import get_redis
@@ -7,11 +8,24 @@ from app.dependencies.fetcher import get_fetcher
from app.models.achievement import Achievement, Medals
from app.models.beatmap import BeatmapRankStatus
from app.models.mods import get_speed_rate, mod_to_save
from app.models.performance import DifficultyAttributesUnion
from app.models.score import Rank
from sqlmodel.ext.asyncio.session import AsyncSession
async def _calculate_attributes(score: Score, beatmap: Beatmap) -> DifficultyAttributesUnion | None:
fetcher = await get_fetcher()
redis = get_redis()
mods_ = score.mods.copy()
if await get_calculator().can_calculate_difficulty(score.gamemode) is False:
return None
attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_, redis, fetcher)
return attribute
async def jackpot(
session: AsyncSession,
score: Score,
@@ -105,10 +119,10 @@ async def reckless_adandon(
mods_ = mod_to_save(score.mods)
if "HR" not in mods_ or "SD" not in mods_:
return False
fetcher = await get_fetcher()
redis = get_redis()
mods_ = score.mods.copy()
attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_, redis, fetcher)
attribute = await _calculate_attributes(score, beatmap)
if attribute is None:
return False
return not attribute.star_rating < 3
@@ -169,10 +183,10 @@ async def slow_and_steady(
mods_ = mod_to_save(score.mods)
if "HT" not in mods_ or "PF" not in mods_:
return False
fetcher = await get_fetcher()
redis = get_redis()
mods_ = score.mods.copy()
attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_, redis, fetcher)
attribute = await _calculate_attributes(score, beatmap)
if attribute is None:
return False
return attribute.star_rating >= 3
@@ -231,10 +245,10 @@ async def impeccable(
# DT and NC interchangeable
if not ("DT" in mods_ or "NC" in mods_) or "PF" not in mods_:
return False
fetcher = await get_fetcher()
redis = get_redis()
mods_ = score.mods.copy()
attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_, redis, fetcher)
attribute = await _calculate_attributes(score, beatmap)
if attribute is None:
return False
return attribute.star_rating >= 4
@@ -255,10 +269,10 @@ async def aeon(
return False
if beatmap.total_length < 180:
return False
fetcher = await get_fetcher()
redis = get_redis()
mods_ = score.mods.copy()
attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_, redis, fetcher)
attribute = await _calculate_attributes(score, beatmap)
if attribute is None:
return False
return attribute.star_rating >= 4
@@ -345,10 +359,9 @@ async def deliberation(
if not beatmap.beatmap_status.has_pp() and beatmap.beatmap_status != BeatmapRankStatus.LOVED:
return False
fetcher = await get_fetcher()
redis = get_redis()
mods_copy = score.mods.copy()
attribute = await calculate_beatmap_attributes(beatmap.id, score.gamemode, mods_copy, redis, fetcher)
attribute = await _calculate_attributes(score, beatmap)
if attribute is None:
return False
return attribute.star_rating >= 6

View File

@@ -25,11 +25,13 @@ logger = log("Calculator")
CALCULATOR: PerformanceCalculator | None = None
def init_calculator():
async def init_calculator():
global CALCULATOR
try:
module = importlib.import_module(f"app.calculators.performance.{settings.calculator}")
CALCULATOR = module.PerformanceCalculator(**settings.calculator_config)
if CALCULATOR is not None:
await CALCULATOR.init()
except (ImportError, AttributeError) as e:
raise ImportError(f"Failed to import performance calculator for {settings.calculator}") from e
return CALCULATOR
@@ -50,6 +52,26 @@ def clamp[T: int | float](n: T, min_value: T, max_value: T) -> T:
return n
def calculate_pp_for_no_calculator(score: "Score", star_rating: float) -> float:
# TODO: Improve this algorithm
# https://www.desmos.com/calculator/i2aa7qm3o6
k = 4.0
pmax = 1.4 * (star_rating**2.8)
b = 0.95 - 0.33 * ((clamp(star_rating, 1, 8) - 1) / 7)
x = score.total_score / 1000000
if x < b:
# Linear section
return pmax * x
else:
# Exponential reward section
x = (x - b) / (1 - b)
exp_part = (math.exp(k * x) - 1) / (math.exp(k) - 1)
return pmax * (b + (1 - b) * exp_part)
async def calculate_pp(score: "Score", beatmap: str, session: AsyncSession) -> float:
from app.database.beatmap import BannedBeatmaps
@@ -68,8 +90,18 @@ async def calculate_pp(score: "Score", beatmap: str, session: AsyncSession) -> f
except Exception:
logger.exception(f"Error checking if beatmap {score.beatmap_id} is suspicious")
attrs = await get_calculator().calculate_performance(beatmap, score)
pp = attrs.pp
if not (await get_calculator().can_calculate_performance(score.gamemode)):
if not settings.fallback_no_calculator_pp:
return 0
star_rating = -1
if await get_calculator().can_calculate_difficulty(score.gamemode):
star_rating = (await get_calculator().calculate_difficulty(beatmap, score.mods, score.gamemode)).star_rating
if star_rating < 0:
star_rating = (await score.awaitable_attrs.beatmap).difficulty_rating
pp = calculate_pp_for_no_calculator(score, star_rating)
else:
attrs = await get_calculator().calculate_performance(beatmap, score)
pp = attrs.pp
if settings.suspicious_score_check and (pp > 3000):
logger.warning(

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

View File

@@ -536,6 +536,11 @@ CALCULATOR_CONFIG='{
),
"表现计算设置",
]
fallback_no_calculator_pp: Annotated[
bool,
Field(default=False, description="当计算器不支持某个模式时,使用简化的 pp 计算方法作为后备"),
"表现计算设置",
]
# 谱面缓存设置
enable_beatmap_preload: Annotated[
@@ -687,6 +692,11 @@ CALCULATOR_CONFIG='{
Field(default=False, description="允许用户删除自己的成绩"),
"反作弊设置",
]
check_ruleset_version: Annotated[
bool,
Field(default=True, description="检查自定义 ruleset 版本"),
"反作弊设置",
]
# 存储设置
storage_service: Annotated[

View File

@@ -28,7 +28,7 @@ if TYPE_CHECKING:
class PlaylistBase(SQLModel, UTCBaseModel):
id: int = Field(index=True)
owner_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id")))
ruleset_id: int = Field(ge=0, le=3)
ruleset_id: int
expired: bool = Field(default=False)
playlist_order: int = Field(default=0)
played_at: datetime | None = Field(

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

View File

@@ -16,6 +16,7 @@ from app.dependencies.storage import StorageService
from app.log import log
from app.models.playlist import PlaylistItem
from app.models.room import MatchType, QueueMode, RoomCategory, RoomStatus
from app.models.score import RULESETS_VERSION_HASH, GameMode, VersionEntry
from app.utils import camel_to_snake, utcnow
from .notification.server import server
@@ -150,7 +151,7 @@ def _validate_playlist_items(items: list[dict[str, Any]]) -> None:
)
ruleset_id = item["ruleset_id"]
if not isinstance(ruleset_id, int) or not (0 <= ruleset_id <= 3):
if not isinstance(ruleset_id, int):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Playlist item at index {idx} has invalid ruleset_id {ruleset_id}",
@@ -679,3 +680,8 @@ async def save_replay(
replay_data = req.mreplay
replay_path = f"replays/{req.score_id}_{req.beatmap_id}_{req.user_id}_lazer_replay.osr"
await storage_service.write_file(replay_path, base64.b64decode(replay_data), "application/x-osu-replay")
@router.get("/ruleset-hashes", response_model=dict[GameMode, VersionEntry])
async def get_ruleset_version():
return RULESETS_VERSION_HASH

View File

@@ -152,7 +152,7 @@ async def get_beatmaps(
beatmap_id: Annotated[int | None, Query(alias="b", description="谱面 ID")] = None,
user: Annotated[str | None, Query(alias="u", description="谱师")] = None,
type: Annotated[Literal["string", "id"] | None, Query(description="用户类型string 用户名称 / id 用户 ID")] = None,
ruleset_id: Annotated[int | None, Query(alias="m", description="Ruleset ID", ge=0, le=3)] = None, # TODO
ruleset_id: Annotated[int | None, Query(alias="m", description="Ruleset ID")] = None, # TODO
convert: Annotated[bool, Query(alias="a", description="转谱")] = False, # TODO
checksum: Annotated[str | None, Query(alias="h", description="谱面文件 MD5")] = None,
limit: Annotated[int, Query(ge=1, le=500, description="返回结果数量限制")] = 500,

View File

@@ -3,6 +3,7 @@ import hashlib
import json
from typing import Annotated
from app.calculator import get_calculator
from app.calculators.performance import ConvertError
from app.database import Beatmap, BeatmapResp, User
from app.database.beatmap import calculate_beatmap_attributes
@@ -147,7 +148,7 @@ async def get_beatmap_attributes(
redis: Redis,
fetcher: Fetcher,
ruleset: Annotated[GameMode | None, Query(description="指定 ruleset为空则使用谱面自身模式")] = None,
ruleset_id: Annotated[int | None, Query(description="以数字指定 ruleset (与 ruleset 二选一)", ge=0, le=3)] = None,
ruleset_id: Annotated[int | None, Query(description="以数字指定 ruleset (与 ruleset 二选一)")] = None,
):
mods_ = []
if mods and mods[0].isdigit():
@@ -170,6 +171,10 @@ async def get_beatmap_attributes(
)
if await redis.exists(key):
return DifficultyAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType]
if await get_calculator().can_calculate_difficulty(ruleset) is False:
raise HTTPException(status_code=422, detail="Cannot calculate difficulty for the specified ruleset")
try:
return await calculate_beatmap_attributes(beatmap_id, ruleset, mods_, redis, fetcher)
except HTTPStatusError:

View File

@@ -374,13 +374,29 @@ async def create_solo_score(
db: Database,
beatmap_id: Annotated[int, Path(description="谱面 ID")],
beatmap_hash: Annotated[str, Form(description="谱面文件哈希")],
ruleset_id: Annotated[int, Form(..., ge=0, le=3, description="ruleset 数字 ID (0-3)")],
ruleset_id: Annotated[int, Form(..., description="ruleset 数字 ID (0-3)")],
current_user: ClientUser,
version_hash: Annotated[str, Form(description="游戏版本哈希")] = "",
ruleset_hash: Annotated[str, Form(description="ruleset 版本哈希")] = "",
):
# 立即获取用户ID避免懒加载问题
user_id = current_user.id
try:
gamemode = GameMode.from_int(ruleset_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid ruleset ID")
if not (result := gamemode.check_ruleset_version(ruleset_hash)):
logger.info(
f"Ruleset version check failed for user {current_user.id} on beatmap {beatmap_id} "
f"(ruleset: {ruleset_id}, hash: {ruleset_hash})"
)
raise HTTPException(
status_code=422,
detail=result.error_msg or "Ruleset version check failed",
)
background_task.add_task(_preload_beatmap_for_pp_calculation, beatmap_id)
async with db:
score_token = ScoreToken(
@@ -428,10 +444,26 @@ async def create_playlist_score(
playlist_id: int,
beatmap_id: Annotated[int, Form(description="谱面 ID")],
beatmap_hash: Annotated[str, Form(description="游戏版本哈希")],
ruleset_id: Annotated[int, Form(..., ge=0, le=3, description="ruleset 数字 ID (0-3)")],
ruleset_id: Annotated[int, Form(..., description="ruleset 数字 ID (0-3)")],
current_user: ClientUser,
version_hash: Annotated[str, Form(description="谱面版本哈希")] = "",
ruleset_hash: Annotated[str, Form(description="ruleset 版本哈希")] = "",
):
try:
gamemode = GameMode.from_int(ruleset_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid ruleset ID")
if not (result := gamemode.check_ruleset_version(ruleset_hash)):
logger.info(
f"Ruleset version check failed for user {current_user.id} on room {room_id}, playlist {playlist_id},"
f" (ruleset: {ruleset_id}, hash: {ruleset_hash})"
)
raise HTTPException(
status_code=422,
detail=result.error_msg or "Ruleset version check failed",
)
if await current_user.is_restricted(session):
raise HTTPException(status_code=403, detail="You are restricted from submitting multiplayer scores")

View File

@@ -13,11 +13,12 @@ from .create_banchobot import create_banchobot
from .daily_challenge import daily_challenge_job, process_daily_challenge_top
from .geoip import init_geoip
from .load_achievements import load_achievements
from .osu_rx_statistics import create_rx_statistics
from .special_statistics import create_custom_ruleset_statistics, create_rx_statistics
__all__ = [
"calculate_user_rank",
"create_banchobot",
"create_custom_ruleset_statistics",
"create_rx_statistics",
"daily_challenge_job",
"init_geoip",

View File

@@ -57,3 +57,34 @@ async def create_rx_statistics():
logger.success(
f"Created {rx_created} RX statistics rows and {ap_created} AP statistics rows during backfill"
)
async def create_custom_ruleset_statistics():
async with with_db() as session:
users = (await session.exec(select(User.id))).all()
total_users = len(users)
logger.info(f"Ensuring custom ruleset statistics exist for {total_users} users")
created_count = 0
for i in users:
if i == BANCHOBOT_ID:
continue
for mode in GameMode:
if not mode.is_custom_ruleset():
continue
is_exist = (
await session.exec(
select(exists()).where(
UserStatistics.user_id == i,
UserStatistics.mode == mode,
)
)
).first()
if not is_exist:
statistics = UserStatistics(mode=mode, user_id=i)
session.add(statistics)
created_count += 1
await session.commit()
if created_count:
logger.success(f"Created {created_count} custom ruleset statistics rows during backfill")

View File

@@ -18,6 +18,7 @@ from app.dependencies.scheduler import start_scheduler, stop_scheduler
from app.log import system_logger
from app.middleware.verify_session import VerifySessionMiddleware
from app.models.mods import init_mods, init_ranked_mods
from app.models.score import init_ruleset_version_hash
from app.router import (
api_v1_router,
api_v2_router,
@@ -37,6 +38,7 @@ from app.service.redis_message_system import redis_message_system
from app.tasks import (
calculate_user_rank,
create_banchobot,
create_custom_ruleset_statistics,
create_rx_statistics,
daily_challenge_job,
init_geoip,
@@ -61,8 +63,9 @@ async def lifespan(app: FastAPI): # noqa: ARG001
# init mods, achievements and performance calculator
init_mods()
init_ranked_mods()
init_ruleset_version_hash()
load_achievements()
init_calculator()
await init_calculator()
# init rate limiter
await FastAPILimiter.init(redis_rate_limit_client)
@@ -74,6 +77,7 @@ async def lifespan(app: FastAPI): # noqa: ARG001
# init game server
await create_rx_statistics()
await create_custom_ruleset_statistics()
await calculate_user_rank(True)
await daily_challenge_job()
await process_daily_challenge_top()

View File

@@ -0,0 +1,122 @@
"""gamemode: add custom rulesets for sentakki, tau, rush, hishigata & soyokaze
Revision ID: 2d395ba2b4fd
Revises: ceabe941b207
Create Date: 2025-10-25 12:20:06.681929
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "2d395ba2b4fd"
down_revision: str | Sequence[str] | None = "ceabe941b207"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
OLD_MODES: tuple[str, ...] = (
"OSU",
"TAIKO",
"FRUITS",
"MANIA",
"OSURX",
"OSUAP",
"TAIKORX",
"FRUITSRX",
)
CUSTOM_MODES: tuple[str, ...] = (
"SENTAKKI",
"TAU",
"RUSH",
"HISHIGATA",
"SOYOKAZE",
)
NEW_MODES: tuple[str, ...] = OLD_MODES + CUSTOM_MODES
TARGET_COLUMNS: tuple[tuple[str, str], ...] = (
("lazer_users", "playmode"),
("lazer_users", "g0v0_playmode"),
("beatmaps", "mode"),
("lazer_user_statistics", "mode"),
("score_tokens", "ruleset_id"),
("scores", "gamemode"),
("best_scores", "gamemode"),
("total_score_best_scores", "gamemode"),
("rank_history", "mode"),
("rank_top", "mode"),
("teams", "playmode"),
)
def _gamemode_enum(values: tuple[str, ...]) -> mysql.ENUM:
return mysql.ENUM(*values, name="gamemode")
def upgrade() -> None:
"""Upgrade schema."""
for table, column in TARGET_COLUMNS:
op.alter_column(
table,
column,
existing_type=_gamemode_enum(OLD_MODES),
type_=_gamemode_enum(NEW_MODES),
)
def downgrade() -> None:
"""Downgrade schema."""
placeholders = ", ".join(f":mode_{index}" for index in range(len(CUSTOM_MODES)))
mode_params = {f"mode_{index}": mode for index, mode in enumerate(CUSTOM_MODES)}
cleanup_templates = [
"DELETE FROM playlist_best_scores WHERE score_id IN (SELECT id FROM scores WHERE gamemode IN ({placeholders}))",
"DELETE FROM total_score_best_scores WHERE gamemode IN ({placeholders})",
"DELETE FROM best_scores WHERE gamemode IN ({placeholders})",
"DELETE FROM score_tokens WHERE ruleset_id IN ({placeholders})",
"DELETE FROM score_tokens WHERE score_id IN (SELECT id FROM scores WHERE gamemode IN ({placeholders}))",
"DELETE FROM score_tokens WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))",
"DELETE FROM scores WHERE gamemode IN ({placeholders})",
"DELETE FROM rank_history WHERE mode IN ({placeholders})",
"DELETE FROM rank_top WHERE mode IN ({placeholders})",
"DELETE FROM lazer_user_statistics WHERE mode IN ({placeholders})",
"DELETE FROM team_requests WHERE team_id IN (SELECT id FROM teams WHERE playmode IN ({placeholders}))",
"DELETE FROM team_members WHERE team_id IN (SELECT id FROM teams WHERE playmode IN ({placeholders}))",
"DELETE FROM teams WHERE playmode IN ({placeholders})",
(
"DELETE FROM matchmaking_pool_beatmaps WHERE beatmap_id IN "
"(SELECT id FROM beatmaps WHERE mode IN ({placeholders}))"
),
"DELETE FROM beatmap_playcounts WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))",
"DELETE FROM beatmap_tags WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))",
"DELETE FROM failtime WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))",
"DELETE FROM room_playlists WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))",
"DELETE FROM banned_beatmaps WHERE beatmap_id IN (SELECT id FROM beatmaps WHERE mode IN ({placeholders}))",
"DELETE FROM beatmaps WHERE mode IN ({placeholders})",
]
for template in cleanup_templates:
statement = template.format(placeholders=placeholders)
op.execute(sa.text(statement), parameters=dict(mode_params))
# Reset persisted user modes to a supported option before shrinking the enum domain.
update_templates = [
"UPDATE lazer_users SET g0v0_playmode = 'OSU' WHERE g0v0_playmode IN ({placeholders})",
"UPDATE lazer_users SET playmode = 'OSU' WHERE playmode IN ({placeholders})",
]
for template in update_templates:
op.execute(sa.text(template.format(placeholders=placeholders)), parameters=dict(mode_params))
for table, column in TARGET_COLUMNS:
op.alter_column(
table,
column,
existing_type=_gamemode_enum(NEW_MODES),
type_=_gamemode_enum(OLD_MODES),
)

View File

@@ -16,7 +16,7 @@ def generate_model(schema_file: Path, version: str = ""):
temp_file.unlink()
temp_file.touch()
version = version or datetime.datetime.now().strftime("%Y.%m.%d")
version = version or f"{datetime.datetime.now().strftime('%Y.%m%d')}.0"
generate(
input_=schema_file,

View File

@@ -0,0 +1,32 @@
{
"Sentakki": {
"latest-version": "2025.1012.1",
"versions": {
"2025.1012.1": "66e02af2097f446246b146641295573a"
}
},
"tau": {
"latest-version": "2025.1012.1",
"versions": {
"2025.1012.1": "3a2dd168c2e520a3620a5dfd7b3c0b73"
}
},
"rush": {
"latest-version": "2025.1012.1",
"versions": {
"2025.1012.1": "df0c211c8c40f42feb119a3a11549a6f"
}
},
"hishigata": {
"latest-version": "2025.1012.1",
"versions": {
"2025.1012.1": "af26c2946cd0b2258ac52f5cce91958c"
}
},
"soyokaze": {
"latest-version": "2025.1012.1",
"versions": {
"2025.1012.1": "fea5c97b8b436305ba98ef8b39b133b6"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ import warnings
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.calculator import calculate_pp, calculate_score_to_level
from app.calculator import calculate_pp, calculate_score_to_level, init_calculator
from app.config import settings
from app.const import BANCHOBOT_ID
from app.database import TotalScoreBestScore, UserStatistics
@@ -546,6 +546,7 @@ async def recalculate(config: RecalculateConfig) -> None:
init_mods()
init_ranked_mods()
await init_calculator()
targets = await determine_targets(config)
if not targets: