feat(score): support osu-rx & osu-ap & all beatmap leaderboard like osu.ppy.sb
This commit is contained in:
@@ -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 状态返回)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
42
app/service/osu_rx_statistics.py
Normal file
42
app/service/osu_rx_statistics.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
2
main.py
2
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()
|
||||
|
||||
116
migrations/versions/19cdc9ce4dcb_gamemode_add_osurx_osupp.py
Normal file
116
migrations/versions/19cdc9ce4dcb_gamemode_add_osurx_osupp.py
Normal 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 ###
|
||||
Reference in New Issue
Block a user