From 1251ba31a202086c7eccce0a660c9ce1e3666add Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 15 Aug 2025 05:59:21 +0000 Subject: [PATCH] feat(score): support rx for taiko & catch --- .env.example | 4 +- README.md | 6 +- app/config.py | 10 +- app/database/score.py | 17 +- app/models/mods.py | 8 +- app/models/score.py | 29 +++- app/router/auth.py | 9 +- app/service/osu_rx_statistics.py | 29 ++-- ...1a2188e691_score_add_rx_for_taiko_catch.py | 162 ++++++++++++++++++ 9 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 migrations/versions/951a2188e691_score_add_rx_for_taiko_catch.py diff --git a/.env.example b/.env.example index c253552..70e5128 100644 --- a/.env.example +++ b/.env.example @@ -49,8 +49,8 @@ LOG_LEVEL="INFO" SENTRY_DSN # 游戏设置 -ENABLE_OSU_RX=false # 启用 osu!RX 统计数据 -ENABLE_OSU_AP=false # 启用 osu!AP 统计数据 +ENABLE_RX=false # 启用 RX mod 统计数据 +ENABLE_AP=false # 启用 AP mod Z统计数据 ENABLE_ALL_MODS_PP=false # 启用所有 Mod 的 PP 计算 ENABLE_SUPPORTER_FOR_ALL_USERS=false # 启用所有新注册用户的支持者状态 ENABLE_ALL_BEATMAP_LEADERBOARD=false # 启用所有谱面的排行榜(没有排行榜的谱面会以 APPROVED 状态返回) diff --git a/README.md b/README.md index 02c2983..0700825 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - **OAuth 2.0 认证**: 支持密码流和刷新令牌流 - **用户数据管理**: 完整的用户信息、统计数据、成就等 -- **多游戏模式支持**: osu! (osu!rx, osu!ap), taiko, fruits, mania +- **多游戏模式支持**: osu! (RX, AP), taiko (RX), catch (RX), mania - **数据库持久化**: MySQL 存储用户数据 - **缓存支持**: Redis 缓存令牌和会话信息 - **多种存储后端**: 支持本地存储、Cloudflare R2、AWS S3 @@ -109,8 +109,8 @@ Fetcher 用于从 osu! 官方 API 获取数据,使用 osu! 官方 API 的 OAut ### 游戏设置 | 变量名 | 描述 | 默认值 | |--------|------|--------| -| `ENABLE_OSU_RX` | 启用 osu!RX 统计数据 | `false` | -| `ENABLE_OSU_AP` | 启用 osu!AP 统计数据 | `false` | +| `ENABLE_RX` | 启用 RX mod 统计数据 | `false` | +| `ENABLE_AP` | 启用 AP mod 统计数据 | `false` | | `ENABLE_ALL_MODS_PP` | 启用所有 Mod 的 PP 计算 | `false` | | `ENABLE_SUPPORTER_FOR_ALL_USERS` | 启用所有新注册用户的支持者状态 | `false` | | `ENABLE_ALL_BEATMAP_LEADERBOARD` | 启用所有谱面的排行榜 | `false` | diff --git a/app/config.py b/app/config.py index dc3ef61..8350eeb 100644 --- a/app/config.py +++ b/app/config.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum from typing import Annotated, Any -from pydantic import Field, HttpUrl, ValidationInfo, field_validator +from pydantic import AliasChoices, Field, HttpUrl, ValidationInfo, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict @@ -97,8 +97,12 @@ class Settings(BaseSettings): sentry_dsn: HttpUrl | None = None # 游戏设置 - enable_osu_rx: bool = False - enable_osu_ap: bool = False + enable_rx: bool = Field( + default=False, validation_alias=AliasChoices("enable_rx", "enable_osu_rx") + ) + enable_ap: bool = Field( + default=False, validation_alias=AliasChoices("enable_ap", "enable_osu_ap") + ) enable_all_mods_pp: bool = False enable_supporter_for_all_users: bool = False enable_all_beatmap_leaderboard: bool = False diff --git a/app/database/score.py b/app/database/score.py index a400439..0de6647 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -330,12 +330,8 @@ async def get_leaderboard( user: User | None = None, limit: int = 50, ) -> tuple[list[Score], Score | None]: - is_rx = "RX" in (mods or []) - is_ap = "AP" in (mods or []) - if settings.enable_osu_rx and is_rx: - mode = GameMode.OSURX - elif settings.enable_osu_ap and is_ap: - mode = GameMode.OSUAP + mods = mods or [] + mode = mode.to_special_mode(mods) wheres = await _score_where(type, beatmap, mode, mods, user) if wheres is None: @@ -696,14 +692,7 @@ async def process_score( ) -> Score: assert user.id can_get_pp = info.passed and ranked and mods_can_get_pp(info.ruleset_id, info.mods) - acronyms = [mod["acronym"] for mod in info.mods] - is_rx = "RX" in acronyms - is_ap = "AP" in acronyms - gamemode = GameMode.from_int(info.ruleset_id) - if settings.enable_osu_rx and is_rx and gamemode == GameMode.OSU: - gamemode = GameMode.OSURX - elif settings.enable_osu_ap and is_ap and gamemode == GameMode.OSU: - gamemode = GameMode.OSUAP + gamemode = GameMode.from_int(info.ruleset_id).to_special_mode(info.mods) score = Score( accuracy=info.accuracy, max_combo=info.max_combo, diff --git a/app/models/mods.py b/app/models/mods.py index ecedf6a..e92ab89 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -160,9 +160,13 @@ def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool: return True ranked_mods = RANKED_MODS[ruleset_id] for mod in mods: - if app_settings.enable_osu_rx and mod["acronym"] == "RX" and ruleset_id == 0: + if ( + app_settings.enable_rx + and mod["acronym"] == "RX" + and ruleset_id in {0, 1, 2} + ): continue - if app_settings.enable_osu_ap and mod["acronym"] == "AP" and ruleset_id == 0: + if app_settings.enable_ap and mod["acronym"] == "AP" and ruleset_id == 0: continue mod["settings"] = mod.get("settings", {}) diff --git a/app/models/score.py b/app/models/score.py index a9e34e9..ca63f0b 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -1,7 +1,9 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Literal, TypedDict +from typing import TYPE_CHECKING, Literal, TypedDict, cast + +from app.config import settings from .mods import API_MODS, APIMod, init_mods @@ -18,6 +20,8 @@ class GameMode(str, Enum): MANIA = "mania" OSURX = "osurx" OSUAP = "osuap" + TAIKORX = "taikorx" + FRUITSRX = "fruitsrx" def to_rosu(self) -> "rosu.GameMode": import rosu_pp_py as rosu @@ -29,6 +33,8 @@ class GameMode(str, Enum): GameMode.MANIA: rosu.GameMode.Mania, GameMode.OSURX: rosu.GameMode.Osu, GameMode.OSUAP: rosu.GameMode.Osu, + GameMode.TAIKORX: rosu.GameMode.Taiko, + GameMode.FRUITSRX: rosu.GameMode.Catch, }[self] def __int__(self) -> int: @@ -39,6 +45,8 @@ class GameMode(str, Enum): GameMode.MANIA: 3, GameMode.OSURX: 0, GameMode.OSUAP: 0, + GameMode.TAIKORX: 1, + GameMode.FRUITSRX: 2, }[self] @classmethod @@ -59,8 +67,27 @@ class GameMode(str, Enum): 3: GameMode.MANIA, 4: GameMode.OSURX, 5: GameMode.OSUAP, + 6: GameMode.TAIKORX, + 7: GameMode.FRUITSRX, }[v] + def to_special_mode(self, mods: list[APIMod] | list[str]) -> "GameMode": + if self not in (GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS): + return self + if not settings.enable_rx and not settings.enable_ap: + return self + if len(mods) > 0 and isinstance(mods[0], dict): + mods = [mod["acronym"] for mod in cast(list[APIMod], mods)] + if "AP" in mods and settings.enable_ap: + return GameMode.OSUAP + if "RX" in mods and settings.enable_rx: + return { + GameMode.OSU: GameMode.OSURX, + GameMode.TAIKO: GameMode.TAIKORX, + GameMode.FRUITS: GameMode.FRUITSRX, + }[self] + raise ValueError(f"Unknown game mode: {self}") + class Rank(str, Enum): X = "X" diff --git a/app/router/auth.py b/app/router/auth.py index 2466662..a1149fc 100644 --- a/app/router/auth.py +++ b/app/router/auth.py @@ -175,10 +175,11 @@ async def register_user( for i in [GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS, GameMode.MANIA]: statistics = UserStatistics(mode=i, user_id=new_user.id) db.add(statistics) - if settings.enable_osu_rx: - statistics_rx = UserStatistics(mode=GameMode.OSURX, user_id=new_user.id) - db.add(statistics_rx) - if settings.enable_osu_ap: + if settings.enable_rx: + for mode in (GameMode.OSURX, GameMode.TAIKORX, GameMode.FRUITSRX): + statistics_rx = UserStatistics(mode=mode, user_id=new_user.id) + db.add(statistics_rx) + if settings.enable_ap: statistics_ap = UserStatistics(mode=GameMode.OSUAP, user_id=new_user.id) db.add(statistics_ap) daily_challenge_user_stats = DailyChallengeStats(user_id=new_user.id) diff --git a/app/service/osu_rx_statistics.py b/app/service/osu_rx_statistics.py index 8b84a78..8a0441f 100644 --- a/app/service/osu_rx_statistics.py +++ b/app/service/osu_rx_statistics.py @@ -15,19 +15,24 @@ async def create_rx_statistics(): async with AsyncSession(engine) as session: users = (await session.exec(select(User.id))).all() for i in users: - if settings.enable_osu_rx: - is_exist = ( - await session.exec( - select(exists()).where( - UserStatistics.user_id == i, - UserStatistics.mode == GameMode.OSURX, + if settings.enable_rx: + for mode in ( + GameMode.OSURX, + GameMode.TAIKORX, + GameMode.FRUITSRX, + ): + is_exist = ( + await session.exec( + select(exists()).where( + UserStatistics.user_id == i, + UserStatistics.mode == mode, + ) ) - ) - ).first() - if not is_exist: - statistics_rx = UserStatistics(mode=GameMode.OSURX, user_id=i) - session.add(statistics_rx) - if settings.enable_osu_ap: + ).first() + if not is_exist: + statistics_rx = UserStatistics(mode=mode, user_id=i) + session.add(statistics_rx) + if settings.enable_ap: is_exist = ( await session.exec( select(exists()).where( diff --git a/migrations/versions/951a2188e691_score_add_rx_for_taiko_catch.py b/migrations/versions/951a2188e691_score_add_rx_for_taiko_catch.py new file mode 100644 index 0000000..3d8c252 --- /dev/null +++ b/migrations/versions/951a2188e691_score_add_rx_for_taiko_catch.py @@ -0,0 +1,162 @@ +"""score: add rx for taiko & catch + +Revision ID: 951a2188e691 +Revises: 7e9d5e012d37 +Create Date: 2025-08-15 04:38:07.595003 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "951a2188e691" +down_revision: str | Sequence[str] | None = "7e9d5e012d37" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "beatmaps", + "mode", + existing_type=mysql.ENUM( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX" + ), + nullable=False, + ) + op.alter_column( + "best_scores", + "gamemode", + existing_type=mysql.ENUM( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX" + ), + nullable=False, + ) + op.alter_column( + "lazer_user_statistics", + "mode", + existing_type=mysql.ENUM( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX" + ), + nullable=False, + ) + op.alter_column( + "lazer_users", + "playmode", + existing_type=mysql.ENUM( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX" + ), + nullable=False, + ) + op.alter_column( + "score_tokens", + "ruleset_id", + existing_type=mysql.ENUM( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX" + ), + nullable=False, + ) + op.alter_column( + "scores", + "gamemode", + existing_type=mysql.ENUM( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX" + ), + nullable=False, + ) + op.alter_column( + "total_score_best_scores", + "gamemode", + existing_type=mysql.ENUM( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX" + ), + nullable=False, + ) + op.alter_column( + "rank_history", + "mode", + existing_type=mysql.ENUM( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX" + ), + nullable=False, + ) + op.alter_column( + "rank_top", + "mode", + existing_type=mysql.ENUM( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX" + ), + nullable=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "beatmaps", + "mode", + existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"), + nullable=False, + ) + op.alter_column( + "best_scores", + "gamemode", + existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"), + nullable=False, + ) + op.alter_column( + "lazer_user_statistics", + "mode", + existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"), + nullable=False, + ) + op.alter_column( + "lazer_users", + "playmode", + existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"), + nullable=False, + ) + op.alter_column( + "score_tokens", + "ruleset_id", + existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"), + nullable=False, + ) + op.alter_column( + "scores", + "gamemode", + existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"), + nullable=False, + ) + op.alter_column( + "total_score_best_scores", + "gamemode", + existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"), + nullable=False, + ) + op.alter_column( + "rank_top", + "mode", + existing_type=mysql.ENUM( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX" + ), + nullable=False, + ) + op.alter_column( + "rank_top", + "mode", + existing_type=mysql.ENUM( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX" + ), + nullable=False, + ) + # ### end Alembic commands ###