feat(score): support osu-rx & osu-ap & all beatmap leaderboard like osu.ppy.sb

This commit is contained in:
MingxuanGame
2025-08-10 07:02:55 +00:00
parent f165ae5dc3
commit efc784d903
14 changed files with 262 additions and 19 deletions

View File

@@ -32,3 +32,10 @@ FETCHER_CALLBACK_URL="http://localhost:8000/fetcher/callback"
# 日志设置 # 日志设置
LOG_LEVEL="INFO" 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 状态返回)

View File

@@ -40,6 +40,13 @@ class Settings(BaseSettings):
# 日志设置 # 日志设置
log_level: str = "INFO" 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") @field_validator("fetcher_scopes", mode="before")
def validate_fetcher_scopes(cls, v: Any) -> list[str]: def validate_fetcher_scopes(cls, v: Any) -> list[str]:
if isinstance(v, str): if isinstance(v, str):

View File

@@ -1,6 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from app.config import settings
from app.models.beatmap import BeatmapRankStatus from app.models.beatmap import BeatmapRankStatus
from app.models.score import MODE_TO_INT, GameMode 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"} back_populates="beatmaps", sa_relationship_kwargs={"lazy": "joined"}
) )
@property
def can_ranked(self) -> bool:
return self.beatmap_status > BeatmapRankStatus.PENDING
@classmethod @classmethod
async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap": async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap":
d = resp.model_dump() d = resp.model_dump()
@@ -160,12 +157,20 @@ class BeatmapResp(BeatmapBase):
) -> "BeatmapResp": ) -> "BeatmapResp":
from .score import Score from .score import Score
beatmap_status = beatmap.beatmap_status
beatmap_ = beatmap.model_dump() beatmap_ = beatmap.model_dump()
if query_mode is not None and beatmap.mode != query_mode: if query_mode is not None and beatmap.mode != query_mode:
beatmap_["convert"] = True beatmap_["convert"] = True
beatmap_["is_scoreable"] = beatmap.beatmap_status > BeatmapRankStatus.PENDING beatmap_["is_scoreable"] = beatmap_status.has_leaderboard()
beatmap_["status"] = beatmap.beatmap_status.name.lower() if (
beatmap_["ranked"] = beatmap.beatmap_status.value 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] beatmap_["mode_int"] = MODE_TO_INT[beatmap.mode]
if not from_set: if not from_set:
beatmap_["beatmapset"] = await BeatmapsetResp.from_db( beatmap_["beatmapset"] = await BeatmapsetResp.from_db(

View File

@@ -1,6 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, NotRequired, TypedDict from typing import TYPE_CHECKING, NotRequired, TypedDict
from app.config import settings
from app.models.beatmap import BeatmapRankStatus, Genre, Language from app.models.beatmap import BeatmapRankStatus, Genre, Language
from app.models.score import GameMode from app.models.score import GameMode
@@ -228,11 +229,21 @@ class BeatmapsetResp(BeatmapsetBase):
required=beatmapset.nominations_required, required=beatmapset.nominations_required,
current=beatmapset.nominations_current, current=beatmapset.nominations_current,
), ),
"status": beatmapset.beatmap_status.name.lower(), "is_scoreable": beatmapset.beatmap_status.has_leaderboard(),
"ranked": beatmapset.beatmap_status.value,
"is_scoreable": beatmapset.beatmap_status > BeatmapRankStatus.PENDING,
**beatmapset.model_dump(), **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: if session and user:
existing_favourite = ( existing_favourite = (
await session.exec( await session.exec(

View File

@@ -13,6 +13,7 @@ from app.calculator import (
calculate_weighted_pp, calculate_weighted_pp,
clamp, clamp,
) )
from app.config import settings
from app.database.team import TeamMember from app.database.team import TeamMember
from app.models.model import RespWithCursor, UTCBaseModel from app.models.model import RespWithCursor, UTCBaseModel
from app.models.mods import APIMod, mods_can_get_pp from app.models.mods import APIMod, mods_can_get_pp
@@ -324,6 +325,13 @@ async def get_leaderboard(
user: User | None = None, user: User | None = None,
limit: int = 50, limit: int = 50,
) -> tuple[list[Score], Score | None]: ) -> 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) wheres = await _score_where(type, beatmap, mode, mods, user)
if wheres is None: if wheres is None:
return [], None return [], None
@@ -637,6 +645,14 @@ async def process_score(
) -> Score: ) -> Score:
assert user.id assert user.id
can_get_pp = info.passed and ranked and mods_can_get_pp(info.ruleset_id, info.mods) 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( score = Score(
accuracy=info.accuracy, accuracy=info.accuracy,
max_combo=info.max_combo, max_combo=info.max_combo,
@@ -648,7 +664,7 @@ async def process_score(
total_score_without_mods=info.total_score_without_mods, total_score_without_mods=info.total_score_without_mods,
beatmap_id=beatmap_id, beatmap_id=beatmap_id,
ended_at=datetime.now(UTC), ended_at=datetime.now(UTC),
gamemode=INT_TO_MODE[info.ruleset_id], gamemode=gamemode,
started_at=score_token.created_at, started_at=score_token.created_at,
user_id=user.id, user_id=user.id,
preserve=info.passed, preserve=info.passed,

View File

@@ -14,6 +14,20 @@ class BeatmapRankStatus(IntEnum):
QUALIFIED = 3 QUALIFIED = 3
LOVED = 4 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): class Genre(IntEnum):
ANY = 0 ANY = 0

View File

@@ -4,6 +4,7 @@ from copy import deepcopy
import json import json
from typing import Literal, NotRequired, TypedDict from typing import Literal, NotRequired, TypedDict
from app.config import settings as app_settings
from app.path import STATIC_DIR 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: 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] ranked_mods = RANKED_MODS[ruleset_id]
for mod in mods: 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", {}) mod["settings"] = mod.get("settings", {})
if (settings := ranked_mods.get(mod["acronym"])) is None: if (settings := ranked_mods.get(mod["acronym"])) is None:
return False return False

View File

@@ -16,6 +16,8 @@ class GameMode(str, Enum):
TAIKO = "taiko" TAIKO = "taiko"
FRUITS = "fruits" FRUITS = "fruits"
MANIA = "mania" MANIA = "mania"
OSURX = "osurx"
OSUAP = "osuap"
def to_rosu(self) -> "rosu.GameMode": def to_rosu(self) -> "rosu.GameMode":
import rosu_pp_py as rosu import rosu_pp_py as rosu
@@ -25,6 +27,8 @@ class GameMode(str, Enum):
GameMode.TAIKO: rosu.GameMode.Taiko, GameMode.TAIKO: rosu.GameMode.Taiko,
GameMode.FRUITS: rosu.GameMode.Catch, GameMode.FRUITS: rosu.GameMode.Catch,
GameMode.MANIA: rosu.GameMode.Mania, GameMode.MANIA: rosu.GameMode.Mania,
GameMode.OSURX: rosu.GameMode.Osu,
GameMode.OSUAP: rosu.GameMode.Osu,
}[self] }[self]
@@ -33,8 +37,11 @@ MODE_TO_INT = {
GameMode.TAIKO: 1, GameMode.TAIKO: 1,
GameMode.FRUITS: 2, GameMode.FRUITS: 2,
GameMode.MANIA: 3, 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 = {v: k for k, v in MODE_TO_INT.items()}
INT_TO_MODE[0] = GameMode.OSU
class Rank(str, Enum): class Rank(str, Enum):

View File

@@ -159,14 +159,22 @@ async def register_user(
country_code="CN", # 默认国家 country_code="CN", # 默认国家
join_date=datetime.now(UTC), join_date=datetime.now(UTC),
last_visit=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) db.add(new_user)
await db.commit() await db.commit()
await db.refresh(new_user) await db.refresh(new_user)
assert new_user.id is not None, "New user ID should not be None" 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) statistics = UserStatistics(mode=i, user_id=new_user.id)
db.add(statistics) 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) daily_challenge_user_stats = DailyChallengeStats(user_id=new_user.id)
db.add(daily_challenge_user_stats) db.add(daily_challenge_user_stats)
await db.commit() await db.commit()

View File

@@ -4,6 +4,7 @@ from datetime import UTC, datetime
import time import time
from app.calculator import clamp from app.calculator import clamp
from app.config import settings
from app.database import ( from app.database import (
Beatmap, Beatmap,
Playlist, Playlist,
@@ -31,7 +32,6 @@ from app.dependencies.database import get_db, get_redis
from app.dependencies.fetcher import get_fetcher from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user from app.dependencies.user import get_current_user
from app.fetcher import Fetcher from app.fetcher import Fetcher
from app.models.beatmap import BeatmapRankStatus
from app.models.room import RoomCategory from app.models.room import RoomCategory
from app.models.score import ( from app.models.score import (
INT_TO_MODE, INT_TO_MODE,
@@ -92,10 +92,9 @@ async def submit_score(
db_beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap) db_beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap)
except HTTPError: except HTTPError:
raise HTTPException(status_code=404, detail="Beatmap not found") raise HTTPException(status_code=404, detail="Beatmap not found")
ranked = db_beatmap.beatmap_status in { ranked = (
BeatmapRankStatus.RANKED, db_beatmap.beatmap_status.has_pp() | settings.enable_all_beatmap_leaderboard
BeatmapRankStatus.APPROVED, )
}
score = await process_score( score = await process_score(
current_user, current_user,
beatmap, beatmap,

View File

@@ -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()

View File

@@ -7,12 +7,12 @@ import struct
import time import time
from typing import override from typing import override
from app.config import settings
from app.database import Beatmap, User from app.database import Beatmap, User
from app.database.score import Score from app.database.score import Score
from app.database.score_token import ScoreToken from app.database.score_token import ScoreToken
from app.dependencies.database import engine from app.dependencies.database import engine
from app.dependencies.fetcher import get_fetcher from app.dependencies.fetcher import get_fetcher
from app.models.beatmap import BeatmapRankStatus
from app.models.mods import mods_to_int from app.models.mods import mods_to_int
from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatistics from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatistics
from app.models.spectator_hub import ( from app.models.spectator_hub import (
@@ -244,7 +244,8 @@ class SpectatorHub(Hub[StoreClientState]):
): ):
return return
if ( 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()): ) and any(k.is_hit() and v > 0 for k, v in score.score_info.statistics.items()):
await self._process_score(store, client) await self._process_score(store, client)
store.state = None store.state = None

View File

@@ -14,6 +14,7 @@ from app.router import (
signalr_router, signalr_router,
) )
from app.service.daily_challenge import daily_challenge_job from app.service.daily_challenge import daily_challenge_job
from app.service.osu_rx_statistics import create_rx_statistics
from fastapi import FastAPI from fastapi import FastAPI
@@ -21,6 +22,7 @@ from fastapi import FastAPI
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# on startup # on startup
await create_rx_statistics()
await get_fetcher() # 初始化 fetcher await get_fetcher() # 初始化 fetcher
init_scheduler() init_scheduler()
await daily_challenge_job() await daily_challenge_job()

View File

@@ -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 ###