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"
# 游戏设置
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"
# 游戏设置
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):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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