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. - **OAuth 2.0 Authentication**: Supports password and refresh token flows.
- **User Data Management**: Complete user information, statistics, achievements, etc. - **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. - **Database Persistence**: MySQL for storing user data.
- **Cache Support**: Redis for caching tokens and session information. - **Cache Support**: Redis for caching tokens and session information.
- **Multiple Storage Backends**: Supports local storage, Cloudflare R2, and AWS S3. - **Multiple Storage Backends**: Supports local storage, Cloudflare R2, and AWS S3.
- **Containerized Deployment**: Docker and Docker Compose support. - **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 ## Quick Start
### Using Docker Compose (Recommended) ### Using Docker Compose (Recommended)
@@ -102,7 +122,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- ALL-CONTRIBUTORS-LIST:END --> <!-- 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 ## Discussion

View File

@@ -15,13 +15,33 @@
- **OAuth 2.0 认证**: 支持密码流和刷新令牌流 - **OAuth 2.0 认证**: 支持密码流和刷新令牌流
- **用户数据管理**: 完整的用户信息、统计数据、成就等 - **用户数据管理**: 完整的用户信息、统计数据、成就等
- **多游戏模式支持**: osu! (RX, AP), taiko (RX), catch (RX), mania - **多游戏模式支持**: osu! (RX, AP), taiko (RX), catch (RX), mania 和自定义 ruleset见下
- **数据库持久化**: MySQL 存储用户数据 - **数据库持久化**: MySQL 存储用户数据
- **缓存支持**: Redis 缓存令牌和会话信息 - **缓存支持**: Redis 缓存令牌和会话信息
- **多种存储后端**: 支持本地存储、Cloudflare R2、AWS S3 - **多种存储后端**: 支持本地存储、Cloudflare R2、AWS S3
- **容器化部署**: Docker 和 Docker Compose 支持 - **容器化部署**: Docker 和 Docker Compose 支持
- **资源文件反向代理**: 可以将 osu! 官方的资源链接(头像、谱面封面、音频等)替换为自定义域名。 - **资源文件反向代理**: 可以将 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 (推荐) ### 使用 Docker Compose (推荐)
@@ -113,5 +133,3 @@ docker-compose -f docker-compose-osurx.yml up -d
- QQ 群:`1059561526` - QQ 群:`1059561526`
- Discord: https://discord.gg/AhzJXXWYfF - Discord: https://discord.gg/AhzJXXWYfF

View File

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

View File

@@ -25,11 +25,13 @@ logger = log("Calculator")
CALCULATOR: PerformanceCalculator | None = None CALCULATOR: PerformanceCalculator | None = None
def init_calculator(): async def init_calculator():
global CALCULATOR global CALCULATOR
try: try:
module = importlib.import_module(f"app.calculators.performance.{settings.calculator}") module = importlib.import_module(f"app.calculators.performance.{settings.calculator}")
CALCULATOR = module.PerformanceCalculator(**settings.calculator_config) CALCULATOR = module.PerformanceCalculator(**settings.calculator_config)
if CALCULATOR is not None:
await CALCULATOR.init()
except (ImportError, AttributeError) as e: except (ImportError, AttributeError) as e:
raise ImportError(f"Failed to import performance calculator for {settings.calculator}") from e raise ImportError(f"Failed to import performance calculator for {settings.calculator}") from e
return CALCULATOR return CALCULATOR
@@ -50,6 +52,26 @@ def clamp[T: int | float](n: T, min_value: T, max_value: T) -> T:
return n 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: async def calculate_pp(score: "Score", beatmap: str, session: AsyncSession) -> float:
from app.database.beatmap import BannedBeatmaps from app.database.beatmap import BannedBeatmaps
@@ -68,8 +90,18 @@ async def calculate_pp(score: "Score", beatmap: str, session: AsyncSession) -> f
except Exception: except Exception:
logger.exception(f"Error checking if beatmap {score.beatmap_id} is suspicious") logger.exception(f"Error checking if beatmap {score.beatmap_id} is suspicious")
attrs = await get_calculator().calculate_performance(beatmap, score) if not (await get_calculator().can_calculate_performance(score.gamemode)):
pp = attrs.pp 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): if settings.suspicious_score_check and (pp > 3000):
logger.warning( logger.warning(

View File

@@ -1,5 +1,5 @@
import abc import abc
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, NamedTuple
from app.models.mods import APIMod from app.models.mods import APIMod
from app.models.performance import DifficultyAttributes, PerformanceAttributes from app.models.performance import DifficultyAttributes, PerformanceAttributes
@@ -25,7 +25,16 @@ class PerformanceError(CalculateError):
"""The performance could not be calculated.""" """The performance could not be calculated."""
class AvailableModes(NamedTuple):
has_performance_calculator: set[GameMode]
has_difficulty_calculator: set[GameMode]
class PerformanceCalculator(abc.ABC): class PerformanceCalculator(abc.ABC):
@abc.abstractmethod
async def get_available_modes(self) -> AvailableModes:
raise NotImplementedError
@abc.abstractmethod @abc.abstractmethod
async def calculate_performance(self, beatmap_raw: str, score: "Score") -> PerformanceAttributes: async def calculate_performance(self, beatmap_raw: str, score: "Score") -> PerformanceAttributes:
raise NotImplementedError raise NotImplementedError
@@ -35,3 +44,15 @@ class PerformanceCalculator(abc.ABC):
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
) -> DifficultyAttributes: ) -> DifficultyAttributes:
raise NotImplementedError 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.mods import APIMod
from app.models.performance import ( from app.models.performance import (
@@ -10,6 +12,7 @@ from app.models.performance import (
from app.models.score import GameMode from app.models.score import GameMode
from ._base import ( from ._base import (
AvailableModes,
CalculateError, CalculateError,
DifficultyError, DifficultyError,
PerformanceCalculator as BasePerformanceCalculator, PerformanceCalculator as BasePerformanceCalculator,
@@ -23,10 +26,61 @@ if TYPE_CHECKING:
from app.database.score import Score 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: def __init__(self, server_url: str = "http://localhost:5225") -> None:
self.server_url = server_url 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: async def calculate_performance(self, beatmap_raw: str, score: "Score") -> PerformanceAttributes:
# https://github.com/GooGuTeam/osu-performance-server#post-performance # https://github.com/GooGuTeam/osu-performance-server#post-performance
async with AsyncClient() as client: async with AsyncClient() as client:
@@ -74,7 +128,7 @@ class PerformanceCalculator(BasePerformanceCalculator):
json={ json={
"beatmap_file": beatmap_raw, "beatmap_file": beatmap_raw,
"mods": mods or [], "mods": mods or [],
"ruleset": int(gamemode) if gamemode else None, "ruleset": gamemode.value if gamemode else None,
}, },
) )
if resp.status_code != 200: if resp.status_code != 200:
@@ -84,3 +138,6 @@ class PerformanceCalculator(BasePerformanceCalculator):
raise DifficultyError(f"Failed to calculate difficulty: {e}") from e raise DifficultyError(f"Failed to calculate difficulty: {e}") from e
except Exception as e: except Exception as e:
raise DifficultyError(f"Unknown error: {e}") from e raise DifficultyError(f"Unknown error: {e}") from e
PerformanceCalculator = PerformanceServerPerformanceCalculator

View File

@@ -1,6 +1,6 @@
from asyncio import get_event_loop from asyncio import get_event_loop
from copy import deepcopy from copy import deepcopy
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, ClassVar
from app.calculator import clamp from app.calculator import clamp
from app.models.mods import APIMod, parse_enum_to_str 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 app.models.score import GameMode
from ._base import ( from ._base import (
AvailableModes,
CalculateError, CalculateError,
ConvertError, ConvertError,
DifficultyError, 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 @classmethod
def _to_rosu_mode(cls, mode: GameMode) -> rosu.GameMode: def _to_rosu_mode(cls, mode: GameMode) -> rosu.GameMode:
return { return {
@@ -70,6 +82,12 @@ class PerformanceCalculator(BasePerformanceCalculator):
rosu.GameMode.Mania: GameMode.MANIA, rosu.GameMode.Mania: GameMode.MANIA,
}[mode] }[mode]
async def get_available_modes(self) -> AvailableModes:
return AvailableModes(
has_performance_calculator=self.SUPPORT_MODES,
has_difficulty_calculator=self.SUPPORT_MODES,
)
@classmethod @classmethod
def _perf_attr_to_model(cls, attr: rosu.PerformanceAttributes, gamemode: GameMode) -> PerformanceAttributes: def _perf_attr_to_model(cls, attr: rosu.PerformanceAttributes, gamemode: GameMode) -> PerformanceAttributes:
attr_class = PERFORMANCE_CLASS.get(gamemode, PerformanceAttributes) attr_class = PERFORMANCE_CLASS.get(gamemode, PerformanceAttributes)
@@ -185,3 +203,6 @@ class PerformanceCalculator(BasePerformanceCalculator):
raise DifficultyError(f"Beatmap parse error: {e}") raise DifficultyError(f"Beatmap parse error: {e}")
except Exception as e: except Exception as e:
raise CalculateError(f"Unknown error: {e}") from 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[ enable_beatmap_preload: Annotated[
@@ -687,6 +692,11 @@ CALCULATOR_CONFIG='{
Field(default=False, description="允许用户删除自己的成绩"), Field(default=False, description="允许用户删除自己的成绩"),
"反作弊设置", "反作弊设置",
] ]
check_ruleset_version: Annotated[
bool,
Field(default=True, description="检查自定义 ruleset 版本"),
"反作弊设置",
]
# 存储设置 # 存储设置
storage_service: Annotated[ storage_service: Annotated[

View File

@@ -28,7 +28,7 @@ if TYPE_CHECKING:
class PlaylistBase(SQLModel, UTCBaseModel): class PlaylistBase(SQLModel, UTCBaseModel):
id: int = Field(index=True) id: int = Field(index=True)
owner_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"))) 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) expired: bool = Field(default=False)
playlist_order: int = Field(default=0) playlist_order: int = Field(default=0)
played_at: datetime | None = Field( 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 continue
if app_settings.enable_ap and mod["acronym"] == "AP" and ruleset_id == 0: if app_settings.enable_ap and mod["acronym"] == "AP" and ruleset_id == 0:
continue 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: if not check_settings_result:
return False return False
return True return True

View File

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

View File

@@ -1,23 +1,56 @@
from enum import Enum 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.config import settings
from app.path import STATIC_DIR
from .mods import API_MODS, APIMod from .mods import API_MODS, APIMod
from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator 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): class GameMode(str, Enum):
OSU = "osu" OSU = "osu"
TAIKO = "taiko" TAIKO = "taiko"
FRUITS = "fruits" FRUITS = "fruits"
MANIA = "mania" MANIA = "mania"
OSURX = "osurx" OSURX = "osurx"
OSUAP = "osuap" OSUAP = "osuap"
TAIKORX = "taikorx" TAIKORX = "taikorx"
FRUITSRX = "fruitsrx" FRUITSRX = "fruitsrx"
SENTAKKI = "Sentakki"
TAU = "tau"
RUSH = "rush"
HISHIGATA = "hishigata"
SOYOKAZE = "soyokaze"
def __int__(self) -> int: def __int__(self) -> int:
return { return {
GameMode.OSU: 0, GameMode.OSU: 0,
@@ -28,6 +61,11 @@ class GameMode(str, Enum):
GameMode.OSUAP: 0, GameMode.OSUAP: 0,
GameMode.TAIKORX: 1, GameMode.TAIKORX: 1,
GameMode.FRUITSRX: 2, GameMode.FRUITSRX: 2,
GameMode.SENTAKKI: 10,
GameMode.TAU: 11,
GameMode.RUSH: 12,
GameMode.HISHIGATA: 13,
GameMode.SOYOKAZE: 14,
}[self] }[self]
def __str__(self) -> str: def __str__(self) -> str:
@@ -40,20 +78,22 @@ class GameMode(str, Enum):
1: GameMode.TAIKO, 1: GameMode.TAIKO,
2: GameMode.FRUITS, 2: GameMode.FRUITS,
3: GameMode.MANIA, 3: GameMode.MANIA,
10: GameMode.SENTAKKI,
11: GameMode.TAU,
12: GameMode.RUSH,
13: GameMode.HISHIGATA,
14: GameMode.SOYOKAZE,
}[v] }[v]
@classmethod @classmethod
def from_int_extra(cls, v: int) -> "GameMode": def from_int_extra(cls, v: int) -> "GameMode":
return { gamemode = {
0: GameMode.OSU,
1: GameMode.TAIKO,
2: GameMode.FRUITS,
3: GameMode.MANIA,
4: GameMode.OSURX, 4: GameMode.OSURX,
5: GameMode.OSUAP, 5: GameMode.OSUAP,
6: GameMode.TAIKORX, 6: GameMode.TAIKORX,
7: GameMode.FRUITSRX, 7: GameMode.FRUITSRX,
}[v] }.get(v)
return gamemode or cls.from_int(v)
def readable(self) -> str: def readable(self) -> str:
return { return {
@@ -65,8 +105,27 @@ class GameMode(str, Enum):
GameMode.OSUAP: "osu!autopilot", GameMode.OSUAP: "osu!autopilot",
GameMode.TAIKORX: "taiko relax", GameMode.TAIKORX: "taiko relax",
GameMode.FRUITSRX: "catch relax", GameMode.FRUITSRX: "catch relax",
GameMode.SENTAKKI: "sentakki",
GameMode.TAU: "tau",
GameMode.RUSH: "Rush!",
GameMode.HISHIGATA: "hishigata",
GameMode.SOYOKAZE: "soyokaze!",
}[self] }[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": def to_base_ruleset(self) -> "GameMode":
gamemode = { gamemode = {
GameMode.OSURX: GameMode.OSU, GameMode.OSURX: GameMode.OSU,
@@ -74,7 +133,7 @@ class GameMode(str, Enum):
GameMode.TAIKORX: GameMode.TAIKO, GameMode.TAIKORX: GameMode.TAIKO,
GameMode.FRUITSRX: GameMode.FRUITS, GameMode.FRUITSRX: GameMode.FRUITS,
}.get(self) }.get(self)
return gamemode if gamemode else self return gamemode or self
def to_special_mode(self, mods: list[APIMod] | list[str]) -> "GameMode": def to_special_mode(self, mods: list[APIMod] | list[str]) -> "GameMode":
if self not in (GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS): if self not in (GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS):
@@ -93,6 +152,27 @@ class GameMode(str, Enum):
}[self] }[self]
return 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 @classmethod
def parse(cls, v: str | int) -> "GameMode | None": def parse(cls, v: str | int) -> "GameMode | None":
if isinstance(v, int) or v.isdigit(): if isinstance(v, int) or v.isdigit():
@@ -189,7 +269,7 @@ class SoloScoreSubmissionInfo(BaseModel):
accuracy: float = Field(ge=0, le=1) accuracy: float = Field(ge=0, le=1)
pp: float = Field(default=0, ge=0, le=2**31 - 1) pp: float = Field(default=0, ge=0, le=2**31 - 1)
max_combo: int = 0 max_combo: int = 0
ruleset_id: Literal[0, 1, 2, 3] ruleset_id: int
passed: bool = False passed: bool = False
mods: list[APIMod] = Field(default_factory=list) mods: list[APIMod] = Field(default_factory=list)
statistics: ScoreStatistics = Field(default_factory=dict) statistics: ScoreStatistics = Field(default_factory=dict)
@@ -241,3 +321,21 @@ class LegacyReplaySoloScoreInfo(TypedDict):
rank: Rank rank: Rank
user_id: int user_id: int
total_score_without_mods: 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.log import log
from app.models.playlist import PlaylistItem from app.models.playlist import PlaylistItem
from app.models.room import MatchType, QueueMode, RoomCategory, RoomStatus 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 app.utils import camel_to_snake, utcnow
from .notification.server import server from .notification.server import server
@@ -150,7 +151,7 @@ def _validate_playlist_items(items: list[dict[str, Any]]) -> None:
) )
ruleset_id = item["ruleset_id"] 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( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Playlist item at index {idx} has invalid ruleset_id {ruleset_id}", 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_data = req.mreplay
replay_path = f"replays/{req.score_id}_{req.beatmap_id}_{req.user_id}_lazer_replay.osr" 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") 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, beatmap_id: Annotated[int | None, Query(alias="b", description="谱面 ID")] = None,
user: Annotated[str | None, Query(alias="u", description="谱师")] = None, user: Annotated[str | None, Query(alias="u", description="谱师")] = None,
type: Annotated[Literal["string", "id"] | None, Query(description="用户类型string 用户名称 / id 用户 ID")] = 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 convert: Annotated[bool, Query(alias="a", description="转谱")] = False, # TODO
checksum: Annotated[str | None, Query(alias="h", description="谱面文件 MD5")] = None, checksum: Annotated[str | None, Query(alias="h", description="谱面文件 MD5")] = None,
limit: Annotated[int, Query(ge=1, le=500, description="返回结果数量限制")] = 500, limit: Annotated[int, Query(ge=1, le=500, description="返回结果数量限制")] = 500,

View File

@@ -3,6 +3,7 @@ import hashlib
import json import json
from typing import Annotated from typing import Annotated
from app.calculator import get_calculator
from app.calculators.performance import ConvertError from app.calculators.performance import ConvertError
from app.database import Beatmap, BeatmapResp, User from app.database import Beatmap, BeatmapResp, User
from app.database.beatmap import calculate_beatmap_attributes from app.database.beatmap import calculate_beatmap_attributes
@@ -147,7 +148,7 @@ async def get_beatmap_attributes(
redis: Redis, redis: Redis,
fetcher: Fetcher, fetcher: Fetcher,
ruleset: Annotated[GameMode | None, Query(description="指定 ruleset为空则使用谱面自身模式")] = None, 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_ = [] mods_ = []
if mods and mods[0].isdigit(): if mods and mods[0].isdigit():
@@ -170,6 +171,10 @@ async def get_beatmap_attributes(
) )
if await redis.exists(key): if await redis.exists(key):
return DifficultyAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType] 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: try:
return await calculate_beatmap_attributes(beatmap_id, ruleset, mods_, redis, fetcher) return await calculate_beatmap_attributes(beatmap_id, ruleset, mods_, redis, fetcher)
except HTTPStatusError: except HTTPStatusError:

View File

@@ -374,13 +374,29 @@ async def create_solo_score(
db: Database, db: Database,
beatmap_id: Annotated[int, Path(description="谱面 ID")], beatmap_id: Annotated[int, Path(description="谱面 ID")],
beatmap_hash: Annotated[str, Form(description="谱面文件哈希")], 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, current_user: ClientUser,
version_hash: Annotated[str, Form(description="游戏版本哈希")] = "", version_hash: Annotated[str, Form(description="游戏版本哈希")] = "",
ruleset_hash: Annotated[str, Form(description="ruleset 版本哈希")] = "",
): ):
# 立即获取用户ID避免懒加载问题 # 立即获取用户ID避免懒加载问题
user_id = current_user.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) background_task.add_task(_preload_beatmap_for_pp_calculation, beatmap_id)
async with db: async with db:
score_token = ScoreToken( score_token = ScoreToken(
@@ -428,10 +444,26 @@ async def create_playlist_score(
playlist_id: int, playlist_id: int,
beatmap_id: Annotated[int, Form(description="谱面 ID")], beatmap_id: Annotated[int, Form(description="谱面 ID")],
beatmap_hash: Annotated[str, Form(description="游戏版本哈希")], 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, current_user: ClientUser,
version_hash: Annotated[str, Form(description="谱面版本哈希")] = "", 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): if await current_user.is_restricted(session):
raise HTTPException(status_code=403, detail="You are restricted from submitting multiplayer scores") 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 .daily_challenge import daily_challenge_job, process_daily_challenge_top
from .geoip import init_geoip from .geoip import init_geoip
from .load_achievements import load_achievements 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__ = [ __all__ = [
"calculate_user_rank", "calculate_user_rank",
"create_banchobot", "create_banchobot",
"create_custom_ruleset_statistics",
"create_rx_statistics", "create_rx_statistics",
"daily_challenge_job", "daily_challenge_job",
"init_geoip", "init_geoip",

View File

@@ -57,3 +57,34 @@ async def create_rx_statistics():
logger.success( logger.success(
f"Created {rx_created} RX statistics rows and {ap_created} AP statistics rows during backfill" 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.log import system_logger
from app.middleware.verify_session import VerifySessionMiddleware from app.middleware.verify_session import VerifySessionMiddleware
from app.models.mods import init_mods, init_ranked_mods from app.models.mods import init_mods, init_ranked_mods
from app.models.score import init_ruleset_version_hash
from app.router import ( from app.router import (
api_v1_router, api_v1_router,
api_v2_router, api_v2_router,
@@ -37,6 +38,7 @@ from app.service.redis_message_system import redis_message_system
from app.tasks import ( from app.tasks import (
calculate_user_rank, calculate_user_rank,
create_banchobot, create_banchobot,
create_custom_ruleset_statistics,
create_rx_statistics, create_rx_statistics,
daily_challenge_job, daily_challenge_job,
init_geoip, init_geoip,
@@ -61,8 +63,9 @@ async def lifespan(app: FastAPI): # noqa: ARG001
# init mods, achievements and performance calculator # init mods, achievements and performance calculator
init_mods() init_mods()
init_ranked_mods() init_ranked_mods()
init_ruleset_version_hash()
load_achievements() load_achievements()
init_calculator() await init_calculator()
# init rate limiter # init rate limiter
await FastAPILimiter.init(redis_rate_limit_client) await FastAPILimiter.init(redis_rate_limit_client)
@@ -74,6 +77,7 @@ async def lifespan(app: FastAPI): # noqa: ARG001
# init game server # init game server
await create_rx_statistics() await create_rx_statistics()
await create_custom_ruleset_statistics()
await calculate_user_rank(True) await calculate_user_rank(True)
await daily_challenge_job() await daily_challenge_job()
await process_daily_challenge_top() 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.unlink()
temp_file.touch() 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( generate(
input_=schema_file, 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__), ".."))) 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.config import settings
from app.const import BANCHOBOT_ID from app.const import BANCHOBOT_ID
from app.database import TotalScoreBestScore, UserStatistics from app.database import TotalScoreBestScore, UserStatistics
@@ -546,6 +546,7 @@ async def recalculate(config: RecalculateConfig) -> None:
init_mods() init_mods()
init_ranked_mods() init_ranked_mods()
await init_calculator()
targets = await determine_targets(config) targets = await determine_targets(config)
if not targets: if not targets: