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:
24
README.en.md
24
README.en.md
@@ -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
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
6
main.py
6
main.py
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
32
static/custom_ruleset_version_hash.json
Normal file
32
static/custom_ruleset_version_hash.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
2540
static/mods.json
2540
static/mods.json
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user