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.
|
- **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
|
||||||
|
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
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.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()
|
||||||
|
|||||||
@@ -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.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,
|
||||||
|
|||||||
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__), "..")))
|
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user