From efc784d903ba3eceafe9bd2507fc5f7eb427e7d3 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 07:02:55 +0000 Subject: [PATCH] feat(score): support osu-rx & osu-ap & all beatmap leaderboard like osu.ppy.sb --- .env.example | 7 ++ app/config.py | 7 ++ app/database/beatmap.py | 19 +-- app/database/beatmapset.py | 17 ++- app/database/score.py | 18 ++- app/models/beatmap.py | 14 +++ app/models/mods.py | 8 ++ app/models/score.py | 7 ++ app/router/auth.py | 10 +- app/router/score.py | 9 +- app/service/osu_rx_statistics.py | 42 +++++++ app/signalr/hub/spectator.py | 5 +- main.py | 2 + .../19cdc9ce4dcb_gamemode_add_osurx_osupp.py | 116 ++++++++++++++++++ 14 files changed, 262 insertions(+), 19 deletions(-) create mode 100644 app/service/osu_rx_statistics.py create mode 100644 migrations/versions/19cdc9ce4dcb_gamemode_add_osurx_osupp.py diff --git a/.env.example b/.env.example index 88437ff..47b770c 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,10 @@ FETCHER_CALLBACK_URL="http://localhost:8000/fetcher/callback" # 日志设置 LOG_LEVEL="INFO" + +# 游戏设置 +ENABLE_OSU_RX=false # 启用 osu!RX 统计数据 +ENABLE_OSU_AP=false # 启用 osu!AP 统计数据 +ENABLE_ALL_MODS_PP=false # 启用所有 Mod 的 PP 计算 +ENABLE_SUPPORTER_FOR_ALL_USERS=false # 启用所有新注册用户的支持者状态 +ENABLE_ALL_BEATMAP_LEADERBOARD=false # 启用所有谱面的排行榜(没有排行榜的谱面会以 APPROVED 状态返回) diff --git a/app/config.py b/app/config.py index d008ccb..e9ea027 100644 --- a/app/config.py +++ b/app/config.py @@ -40,6 +40,13 @@ class Settings(BaseSettings): # 日志设置 log_level: str = "INFO" + # 游戏设置 + enable_osu_rx: bool = False + enable_osu_ap: bool = False + enable_all_mods_pp: bool = False + enable_supporter_for_all_users: bool = False + enable_all_beatmap_leaderboard: bool = False + @field_validator("fetcher_scopes", mode="before") def validate_fetcher_scopes(cls, v: Any) -> list[str]: if isinstance(v, str): diff --git a/app/database/beatmap.py b/app/database/beatmap.py index 096e9b9..d3ba940 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -1,6 +1,7 @@ from datetime import datetime from typing import TYPE_CHECKING +from app.config import settings from app.models.beatmap import BeatmapRankStatus from app.models.score import MODE_TO_INT, GameMode @@ -62,10 +63,6 @@ class Beatmap(BeatmapBase, table=True): back_populates="beatmaps", sa_relationship_kwargs={"lazy": "joined"} ) - @property - def can_ranked(self) -> bool: - return self.beatmap_status > BeatmapRankStatus.PENDING - @classmethod async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap": d = resp.model_dump() @@ -160,12 +157,20 @@ class BeatmapResp(BeatmapBase): ) -> "BeatmapResp": from .score import Score + beatmap_status = beatmap.beatmap_status beatmap_ = beatmap.model_dump() if query_mode is not None and beatmap.mode != query_mode: beatmap_["convert"] = True - beatmap_["is_scoreable"] = beatmap.beatmap_status > BeatmapRankStatus.PENDING - beatmap_["status"] = beatmap.beatmap_status.name.lower() - beatmap_["ranked"] = beatmap.beatmap_status.value + beatmap_["is_scoreable"] = beatmap_status.has_leaderboard() + if ( + settings.enable_all_beatmap_leaderboard + and not beatmap_status.has_leaderboard() + ): + beatmap_["ranked"] = BeatmapRankStatus.APPROVED.value + beatmap_["status"] = BeatmapRankStatus.APPROVED.name.lower() + else: + beatmap_["status"] = beatmap_status.name.lower() + beatmap_["ranked"] = beatmap_status.value beatmap_["mode_int"] = MODE_TO_INT[beatmap.mode] if not from_set: beatmap_["beatmapset"] = await BeatmapsetResp.from_db( diff --git a/app/database/beatmapset.py b/app/database/beatmapset.py index df7bc57..7ead18f 100644 --- a/app/database/beatmapset.py +++ b/app/database/beatmapset.py @@ -1,6 +1,7 @@ from datetime import datetime from typing import TYPE_CHECKING, NotRequired, TypedDict +from app.config import settings from app.models.beatmap import BeatmapRankStatus, Genre, Language from app.models.score import GameMode @@ -228,11 +229,21 @@ class BeatmapsetResp(BeatmapsetBase): required=beatmapset.nominations_required, current=beatmapset.nominations_current, ), - "status": beatmapset.beatmap_status.name.lower(), - "ranked": beatmapset.beatmap_status.value, - "is_scoreable": beatmapset.beatmap_status > BeatmapRankStatus.PENDING, + "is_scoreable": beatmapset.beatmap_status.has_leaderboard(), **beatmapset.model_dump(), } + + beatmap_status = beatmapset.beatmap_status + if ( + settings.enable_all_beatmap_leaderboard + and not beatmap_status.has_leaderboard() + ): + update["status"] = BeatmapRankStatus.APPROVED.name.lower() + update["ranked"] = BeatmapRankStatus.APPROVED.value + else: + update["status"] = beatmap_status.name.lower() + update["ranked"] = beatmap_status.value + if session and user: existing_favourite = ( await session.exec( diff --git a/app/database/score.py b/app/database/score.py index adeeec9..ea1ccf2 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -13,6 +13,7 @@ from app.calculator import ( calculate_weighted_pp, clamp, ) +from app.config import settings from app.database.team import TeamMember from app.models.model import RespWithCursor, UTCBaseModel from app.models.mods import APIMod, mods_can_get_pp @@ -324,6 +325,13 @@ 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 + wheres = await _score_where(type, beatmap, mode, mods, user) if wheres is None: return [], None @@ -637,6 +645,14 @@ 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 = INT_TO_MODE[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 score = Score( accuracy=info.accuracy, max_combo=info.max_combo, @@ -648,7 +664,7 @@ async def process_score( total_score_without_mods=info.total_score_without_mods, beatmap_id=beatmap_id, ended_at=datetime.now(UTC), - gamemode=INT_TO_MODE[info.ruleset_id], + gamemode=gamemode, started_at=score_token.created_at, user_id=user.id, preserve=info.passed, diff --git a/app/models/beatmap.py b/app/models/beatmap.py index fae18ba..d9bdd5c 100644 --- a/app/models/beatmap.py +++ b/app/models/beatmap.py @@ -14,6 +14,20 @@ class BeatmapRankStatus(IntEnum): QUALIFIED = 3 LOVED = 4 + def has_leaderboard(self) -> bool: + return self in { + BeatmapRankStatus.RANKED, + BeatmapRankStatus.APPROVED, + BeatmapRankStatus.QUALIFIED, + BeatmapRankStatus.LOVED, + } + + def has_pp(self) -> bool: + return self in { + BeatmapRankStatus.RANKED, + BeatmapRankStatus.APPROVED, + } + class Genre(IntEnum): ANY = 0 diff --git a/app/models/mods.py b/app/models/mods.py index 95e0f83..ecedf6a 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -4,6 +4,7 @@ from copy import deepcopy import json from typing import Literal, NotRequired, TypedDict +from app.config import settings as app_settings from app.path import STATIC_DIR @@ -155,8 +156,15 @@ for i in range(4, 10): def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool: + if app_settings.enable_all_mods_pp: + 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: + continue + if app_settings.enable_osu_ap and mod["acronym"] == "AP" and ruleset_id == 0: + continue + mod["settings"] = mod.get("settings", {}) if (settings := ranked_mods.get(mod["acronym"])) is None: return False diff --git a/app/models/score.py b/app/models/score.py index 1da72a8..ab968e9 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -16,6 +16,8 @@ class GameMode(str, Enum): TAIKO = "taiko" FRUITS = "fruits" MANIA = "mania" + OSURX = "osurx" + OSUAP = "osuap" def to_rosu(self) -> "rosu.GameMode": import rosu_pp_py as rosu @@ -25,6 +27,8 @@ class GameMode(str, Enum): GameMode.TAIKO: rosu.GameMode.Taiko, GameMode.FRUITS: rosu.GameMode.Catch, GameMode.MANIA: rosu.GameMode.Mania, + GameMode.OSURX: rosu.GameMode.Osu, + GameMode.OSUAP: rosu.GameMode.Osu, }[self] @@ -33,8 +37,11 @@ MODE_TO_INT = { GameMode.TAIKO: 1, GameMode.FRUITS: 2, GameMode.MANIA: 3, + GameMode.OSURX: 0, + GameMode.OSUAP: 0, } INT_TO_MODE = {v: k for k, v in MODE_TO_INT.items()} +INT_TO_MODE[0] = GameMode.OSU class Rank(str, Enum): diff --git a/app/router/auth.py b/app/router/auth.py index 2fa69ad..f5015ab 100644 --- a/app/router/auth.py +++ b/app/router/auth.py @@ -159,14 +159,22 @@ async def register_user( country_code="CN", # 默认国家 join_date=datetime.now(UTC), last_visit=datetime.now(UTC), + is_supporter=settings.enable_supporter_for_all_users, + support_level=int(settings.enable_supporter_for_all_users), ) db.add(new_user) await db.commit() await db.refresh(new_user) assert new_user.id is not None, "New user ID should not be None" - for i in GameMode: + 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: + 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) db.add(daily_challenge_user_stats) await db.commit() diff --git a/app/router/score.py b/app/router/score.py index d826fd0..2ec6da4 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -4,6 +4,7 @@ from datetime import UTC, datetime import time from app.calculator import clamp +from app.config import settings from app.database import ( Beatmap, Playlist, @@ -31,7 +32,6 @@ from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher -from app.models.beatmap import BeatmapRankStatus from app.models.room import RoomCategory from app.models.score import ( INT_TO_MODE, @@ -92,10 +92,9 @@ async def submit_score( db_beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap) except HTTPError: raise HTTPException(status_code=404, detail="Beatmap not found") - ranked = db_beatmap.beatmap_status in { - BeatmapRankStatus.RANKED, - BeatmapRankStatus.APPROVED, - } + ranked = ( + db_beatmap.beatmap_status.has_pp() | settings.enable_all_beatmap_leaderboard + ) score = await process_score( current_user, beatmap, diff --git a/app/service/osu_rx_statistics.py b/app/service/osu_rx_statistics.py new file mode 100644 index 0000000..8b84a78 --- /dev/null +++ b/app/service/osu_rx_statistics.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from app.config import settings +from app.database.lazer_user import User +from app.database.statistics import UserStatistics +from app.dependencies.database import engine +from app.models.score import GameMode + +from sqlalchemy import exists +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + + +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, + ) + ) + ).first() + if not is_exist: + statistics_rx = UserStatistics(mode=GameMode.OSURX, user_id=i) + session.add(statistics_rx) + if settings.enable_osu_ap: + is_exist = ( + await session.exec( + select(exists()).where( + UserStatistics.user_id == i, + UserStatistics.mode == GameMode.OSUAP, + ) + ) + ).first() + if not is_exist: + statistics_ap = UserStatistics(mode=GameMode.OSUAP, user_id=i) + session.add(statistics_ap) + await session.commit() diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index d5a12ff..7764994 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -7,12 +7,12 @@ import struct import time from typing import override +from app.config import settings from app.database import Beatmap, User from app.database.score import Score from app.database.score_token import ScoreToken from app.dependencies.database import engine from app.dependencies.fetcher import get_fetcher -from app.models.beatmap import BeatmapRankStatus from app.models.mods import mods_to_int from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatistics from app.models.spectator_hub import ( @@ -244,7 +244,8 @@ class SpectatorHub(Hub[StoreClientState]): ): return if ( - BeatmapRankStatus.PENDING < store.beatmap_status <= BeatmapRankStatus.LOVED + settings.enable_all_beatmap_leaderboard + and store.beatmap_status.has_leaderboard() ) and any(k.is_hit() and v > 0 for k, v in score.score_info.statistics.items()): await self._process_score(store, client) store.state = None diff --git a/main.py b/main.py index ade04f2..5c88a6b 100644 --- a/main.py +++ b/main.py @@ -14,6 +14,7 @@ from app.router import ( signalr_router, ) from app.service.daily_challenge import daily_challenge_job +from app.service.osu_rx_statistics import create_rx_statistics from fastapi import FastAPI @@ -21,6 +22,7 @@ from fastapi import FastAPI @asynccontextmanager async def lifespan(app: FastAPI): # on startup + await create_rx_statistics() await get_fetcher() # 初始化 fetcher init_scheduler() await daily_challenge_job() diff --git a/migrations/versions/19cdc9ce4dcb_gamemode_add_osurx_osupp.py b/migrations/versions/19cdc9ce4dcb_gamemode_add_osurx_osupp.py new file mode 100644 index 0000000..3cd82c7 --- /dev/null +++ b/migrations/versions/19cdc9ce4dcb_gamemode_add_osurx_osupp.py @@ -0,0 +1,116 @@ +"""gamemode: add osurx & osupp + +Revision ID: 19cdc9ce4dcb +Revises: fdb3822a30ba +Create Date: 2025-08-10 06:10:08.093591 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "19cdc9ce4dcb" +down_revision: str | Sequence[str] | None = "fdb3822a30ba" +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( + "lazer_users", + "playmode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "beatmaps", + "mode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "lazer_user_statistics", + "mode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "score_tokens", + "ruleset_id", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "scores", + "gamemode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "best_scores", + "gamemode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "total_score_best_scores", + "gamemode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "total_score_best_scores", + "gamemode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "best_scores", + "gamemode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "scores", + "gamemode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "score_tokens", + "ruleset_id", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "lazer_user_statistics", + "mode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "beatmaps", + "mode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "lazer_users", + "playmode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + # ### end Alembic commands ###