feat(custom-rulesets): support custom rulesets (#23)

* feat(custom_ruleset): add custom rulesets support

* feat(custom-ruleset): add version check

* feat(custom-ruleset): add LegacyIO API to get ruleset hashes

* feat(pp): add check for rulesets whose pp cannot be calculated

* docs(readme): update README to include support for custom rulesets

* fix(custom-ruleset): make `rulesets` empty instead of throw a error when version check is disabled

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore(custom-ruleset): apply the latest changes of generator

c891bcd159

and

e25041ad3b

* feat(calculator): add fallback performance calculation for unsupported modes

* fix(calculator): remove debug print

* fix: resolve reviews

* feat(calculator): add difficulty calculation checks

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
MingxuanGame
2025-10-26 21:10:36 +08:00
committed by GitHub
parent 8f4a9d5fed
commit 33f321952d
24 changed files with 3134 additions and 74 deletions

View File

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

View File

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

View File

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

View File

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