feat(solo-score): support submit solo scores

This commit is contained in:
MingxuanGame
2025-07-27 02:33:42 +00:00
parent 9b5d952f6d
commit b359be3637
14 changed files with 4170 additions and 64 deletions

View File

@@ -9,6 +9,13 @@ from .beatmapset import (
) )
from .legacy import LegacyOAuthToken, LegacyUserStatistics from .legacy import LegacyOAuthToken, LegacyUserStatistics
from .relationship import Relationship, RelationshipResp, RelationshipType from .relationship import Relationship, RelationshipResp, RelationshipType
from .score import (
Score,
ScoreBase,
ScoreResp,
ScoreStatistics,
)
from .score_token import ScoreToken, ScoreTokenResp
from .team import Team, TeamMember from .team import Team, TeamMember
from .user import ( from .user import (
DailyChallengeStats, DailyChallengeStats,
@@ -57,6 +64,12 @@ __all__ = [
"Relationship", "Relationship",
"RelationshipResp", "RelationshipResp",
"RelationshipType", "RelationshipType",
"Score",
"ScoreBase",
"ScoreResp",
"ScoreStatistics",
"ScoreToken",
"ScoreTokenResp",
"Team", "Team",
"TeamMember", "TeamMember",
"User", "User",

View File

@@ -65,6 +65,10 @@ class Beatmap(BeatmapBase, table=True):
# optional # optional
beatmapset: Beatmapset = Relationship(back_populates="beatmaps") beatmapset: Beatmapset = Relationship(back_populates="beatmaps")
@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()

View File

@@ -2,6 +2,7 @@ from datetime import datetime
from typing import TYPE_CHECKING, TypedDict, cast from typing import TYPE_CHECKING, TypedDict, cast
from app.models.beatmap import BeatmapRankStatus, Genre, Language from app.models.beatmap import BeatmapRankStatus, Genre, Language
from app.models.score import GameMode
from pydantic import BaseModel, model_serializer from pydantic import BaseModel, model_serializer
from sqlalchemy import DECIMAL, JSON, Column, DateTime, Text from sqlalchemy import DECIMAL, JSON, Column, DateTime, Text
@@ -68,7 +69,7 @@ class BeatmapNomination(TypedDict):
beatmapset_id: int beatmapset_id: int
reset: bool reset: bool
user_id: int user_id: int
rulesets: list[str] | None rulesets: list[GameMode] | None
class BeatmapDescription(SQLModel): class BeatmapDescription(SQLModel):

View File

@@ -2,15 +2,34 @@ from datetime import datetime
import math import math
from app.database.user import User from app.database.user import User
from app.models.beatmap import BeatmapRankStatus
from app.models.mods import APIMod from app.models.mods import APIMod
from app.models.score import MODE_TO_INT, GameMode, Rank from app.models.score import (
MODE_TO_INT,
GameMode,
HitResult,
LeaderboardType,
Rank,
ScoreStatistics,
)
from .beatmap import Beatmap, BeatmapResp from .beatmap import Beatmap, BeatmapResp
from .beatmapset import BeatmapsetResp from .beatmapset import BeatmapsetResp
from pydantic import BaseModel
from sqlalchemy import Column, DateTime from sqlalchemy import Column, DateTime
from sqlmodel import JSON, BigInteger, Field, Relationship, SQLModel from sqlalchemy.orm import joinedload
from sqlmodel import (
JSON,
BigInteger,
Field,
Relationship,
SQLModel,
col,
false,
func,
select,
)
from sqlmodel.ext.asyncio.session import AsyncSession
class ScoreBase(SQLModel): class ScoreBase(SQLModel):
@@ -34,6 +53,9 @@ class ScoreBase(SQLModel):
room_id: int | None = Field(default=None) # multiplayer room_id: int | None = Field(default=None) # multiplayer
started_at: datetime = Field(sa_column=Column(DateTime)) started_at: datetime = Field(sa_column=Column(DateTime))
total_score: int = Field(default=0, sa_column=Column(BigInteger)) total_score: int = Field(default=0, sa_column=Column(BigInteger))
total_score_without_mods: int = Field(
default=0, sa_column=Column(BigInteger), exclude=True
)
type: str type: str
# optional # optional
@@ -41,20 +63,11 @@ class ScoreBase(SQLModel):
position: int | None = Field(default=None) # multiplayer position: int | None = Field(default=None) # multiplayer
class ScoreStatistics(BaseModel):
count_miss: int
count_50: int
count_100: int
count_300: int
count_geki: int
count_katu: int
count_large_tick_miss: int | None = None
count_slider_tail_hit: int | None = None
class Score(ScoreBase, table=True): class Score(ScoreBase, table=True):
__tablename__ = "scores" # pyright: ignore[reportAssignmentType] __tablename__ = "scores" # pyright: ignore[reportAssignmentType]
id: int = Field(primary_key=True) id: int | None = Field(
default=None, sa_column=Column(BigInteger, autoincrement=True, primary_key=True)
)
beatmap_id: int = Field(index=True, foreign_key="beatmaps.id") beatmap_id: int = Field(index=True, foreign_key="beatmaps.id")
user_id: int = Field(foreign_key="users.id", index=True) user_id: int = Field(foreign_key="users.id", index=True)
# ScoreStatistics # ScoreStatistics
@@ -72,6 +85,10 @@ class Score(ScoreBase, table=True):
beatmap: "Beatmap" = Relationship() beatmap: "Beatmap" = Relationship()
user: "User" = Relationship() user: "User" = Relationship()
@property
def is_perfect_combo(self) -> bool:
return self.max_combo == self.beatmap.max_combo
class ScoreResp(ScoreBase): class ScoreResp(ScoreBase):
id: int id: int
@@ -85,10 +102,13 @@ class ScoreResp(ScoreBase):
beatmapset: BeatmapsetResp | None = None beatmapset: BeatmapsetResp | None = None
# FIXME: user: APIUser | None = None # FIXME: user: APIUser | None = None
statistics: ScoreStatistics | None = None statistics: ScoreStatistics | None = None
rank_global: int | None = None
rank_country: int | None = None
@classmethod @classmethod
def from_db(cls, score: Score) -> "ScoreResp": async def from_db(cls, session: AsyncSession, score: Score) -> "ScoreResp":
s = cls.model_validate(score.model_dump()) s = cls.model_validate(score.model_dump())
assert score.id
s.beatmap = BeatmapResp.from_db(score.beatmap) s.beatmap = BeatmapResp.from_db(score.beatmap)
s.beatmapset = BeatmapsetResp.from_db(score.beatmap.beatmapset) s.beatmapset = BeatmapsetResp.from_db(score.beatmap.beatmapset)
s.is_perfect_combo = s.max_combo == s.beatmap.max_combo s.is_perfect_combo = s.max_combo == s.beatmap.max_combo
@@ -97,14 +117,203 @@ class ScoreResp(ScoreBase):
if score.best_id: if score.best_id:
# https://osu.ppy.sh/wiki/Performance_points/Weighting_system # https://osu.ppy.sh/wiki/Performance_points/Weighting_system
s.weight = math.pow(0.95, score.best_id) s.weight = math.pow(0.95, score.best_id)
s.statistics = ScoreStatistics( s.statistics = {
count_miss=score.nmiss, HitResult.MISS: score.nmiss,
count_50=score.n50, HitResult.MEH: score.n50,
count_100=score.n100, HitResult.OK: score.n100,
count_300=score.n300, HitResult.GREAT: score.n300,
count_geki=score.ngeki, HitResult.PERFECT: score.ngeki,
count_katu=score.nkatu, HitResult.GOOD: score.nkatu,
count_large_tick_miss=score.nlarge_tick_miss, }
count_slider_tail_hit=score.nslider_tail_hit, if score.nlarge_tick_miss is not None:
s.statistics[HitResult.LARGE_TICK_MISS] = score.nlarge_tick_miss
if score.nslider_tail_hit is not None:
s.statistics[HitResult.SLIDER_TAIL_HIT] = score.nslider_tail_hit
# s.user = await convert_db_user_to_api_user(score.user)
s.rank_global = (
await get_score_position_by_id(
session,
score.map_md5,
score.id,
mode=score.gamemode,
user=score.user,
)
or None
)
s.rank_country = (
await get_score_position_by_id(
session,
score.map_md5,
score.id,
score.gamemode,
score.user,
)
or None
) )
return s return s
async def get_leaderboard(
session: AsyncSession,
beatmap_md5: str,
mode: GameMode,
type: LeaderboardType = LeaderboardType.GLOBAL,
mods: list[APIMod] | None = None,
user: User | None = None,
limit: int = 50,
) -> list[Score]:
scores = []
if type == LeaderboardType.GLOBAL:
query = (
select(Score)
.where(
col(Beatmap.beatmap_status).in_(
[
BeatmapRankStatus.RANKED,
BeatmapRankStatus.LOVED,
BeatmapRankStatus.QUALIFIED,
BeatmapRankStatus.APPROVED,
]
),
Score.map_md5 == beatmap_md5,
Score.gamemode == mode,
col(Score.passed).is_(True),
Score.mods == mods if user and user.is_supporter else false(),
)
.limit(limit)
.order_by(
col(Score.total_score).desc(),
)
)
result = await session.exec(query)
scores = list[Score](result.all())
elif type == LeaderboardType.FRIENDS and user and user.is_supporter:
# TODO
...
elif type == LeaderboardType.TEAM and user and user.team_membership:
team_id = user.team_membership.team_id
query = (
select(Score)
.join(Beatmap)
.options(joinedload(Score.user)) # pyright: ignore[reportArgumentType]
.where(
Score.map_md5 == beatmap_md5,
Score.gamemode == mode,
col(Score.passed).is_(True),
col(Score.user.team_membership).is_not(None),
Score.user.team_membership.team_id == team_id, # pyright: ignore[reportOptionalMemberAccess]
Score.mods == mods if user and user.is_supporter else false(),
)
.limit(limit)
.order_by(
col(Score.total_score).desc(),
)
)
result = await session.exec(query)
scores = list[Score](result.all())
if user:
user_score = (
await session.exec(
select(Score).where(
Score.map_md5 == beatmap_md5,
Score.gamemode == mode,
Score.user_id == user.id,
col(Score.passed).is_(True),
)
)
).first()
if user_score and user_score not in scores:
scores.append(user_score)
return scores
async def get_score_position_by_user(
session: AsyncSession,
beatmap_md5: str,
user: User,
mode: GameMode,
type: LeaderboardType = LeaderboardType.GLOBAL,
mods: list[APIMod] | None = None,
) -> int:
where_clause = [
Score.map_md5 == beatmap_md5,
Score.gamemode == mode,
col(Score.passed).is_(True),
col(Beatmap.beatmap_status).in_(
[
BeatmapRankStatus.RANKED,
BeatmapRankStatus.LOVED,
BeatmapRankStatus.QUALIFIED,
BeatmapRankStatus.APPROVED,
]
),
]
if mods and user.is_supporter:
where_clause.append(Score.mods == mods)
else:
where_clause.append(false())
if type == LeaderboardType.FRIENDS and user.is_supporter:
# TODO
...
elif type == LeaderboardType.TEAM and user.team_membership:
team_id = user.team_membership.team_id
where_clause.append(
col(Score.user.team_membership).is_not(None),
)
where_clause.append(
Score.user.team_membership.team_id == team_id, # pyright: ignore[reportOptionalMemberAccess]
)
rownum = (
func.row_number()
.over(
partition_by=Score.map_md5,
order_by=col(Score.total_score).desc(),
)
.label("row_number")
)
subq = select(Score, rownum).join(Beatmap).where(*where_clause).subquery()
stmt = select(subq.c.row_number).where(subq.c.user == user)
result = await session.exec(stmt)
s = result.one_or_none()
return s if s else 0
async def get_score_position_by_id(
session: AsyncSession,
beatmap_md5: str,
score_id: int,
mode: GameMode,
user: User | None = None,
type: LeaderboardType = LeaderboardType.GLOBAL,
mods: list[APIMod] | None = None,
) -> int:
where_clause = [
Score.map_md5 == beatmap_md5,
Score.id == score_id,
Score.gamemode == mode,
col(Beatmap.beatmap_status).in_(
[
BeatmapRankStatus.RANKED,
BeatmapRankStatus.LOVED,
BeatmapRankStatus.QUALIFIED,
BeatmapRankStatus.APPROVED,
]
),
]
if mods and user and user.is_supporter:
where_clause.append(Score.mods == mods)
elif mods:
where_clause.append(false())
rownum = (
func.row_number()
.over(
partition_by=Score.map_md5,
order_by=col(Score.total_score).desc(),
)
.label("row_number")
)
subq = select(Score, rownum).join(Beatmap).where(*where_clause).subquery()
stmt = select(subq.c.row_number).where(subq.c.id == score_id)
result = await session.exec(stmt)
s = result.one_or_none()
return s if s else 0

View File

@@ -0,0 +1,47 @@
from datetime import datetime
from app.models.score import GameMode
from .beatmap import Beatmap
from .user import User
from sqlalchemy import Column, DateTime, Index
from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel
class ScoreTokenBase(SQLModel):
score_id: int | None = Field(sa_column=Column(BigInteger), default=None)
ruleset_id: GameMode
playlist_item_id: int | None = Field(default=None) # playlist
created_at: datetime = Field(
default_factory=datetime.utcnow, sa_column=Column(DateTime)
)
updated_at: datetime = Field(
default_factory=datetime.utcnow, sa_column=Column(DateTime)
)
class ScoreToken(ScoreTokenBase, table=True):
__tablename__ = "score_tokens" # pyright: ignore[reportAssignmentType]
__table_args__ = (Index("idx_user_playlist", "user_id", "playlist_item_id"),)
id: int | None = Field(
default=None,
primary_key=True,
index=True,
sa_column_kwargs={"autoincrement": True},
)
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
beatmap_id: int = Field(sa_column=Column(BigInteger, ForeignKey("beatmaps.id")))
user: "User" = Relationship()
beatmap: "Beatmap" = Relationship()
class ScoreTokenResp(ScoreTokenBase):
id: int
user_id: int
beatmap_id: int
@classmethod
def from_db(cls, obj: ScoreToken) -> "ScoreTokenResp":
return cls.model_validate(obj)

View File

@@ -2,7 +2,6 @@ from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import Column, DateTime from sqlalchemy import Column, DateTime
from sqlalchemy.orm import Mapped
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -20,7 +19,7 @@ class Team(SQLModel, table=True):
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)
) )
members: Mapped[list["TeamMember"]] = Relationship(back_populates="team") members: list["TeamMember"] = Relationship(back_populates="team")
class TeamMember(SQLModel, table=True): class TeamMember(SQLModel, table=True):
@@ -33,5 +32,5 @@ class TeamMember(SQLModel, table=True):
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)
) )
user: Mapped["User"] = Relationship(back_populates="team_membership") user: "User" = Relationship(back_populates="team_membership")
team: Mapped["Team"] = Relationship(back_populates="members") team: "Team" = Relationship(back_populates="members")

View File

@@ -14,7 +14,9 @@ class User(SQLModel, table=True):
__tablename__ = "users" # pyright: ignore[reportAssignmentType] __tablename__ = "users" # pyright: ignore[reportAssignmentType]
# 主键 # 主键
id: int = Field(default=None, primary_key=True, index=True, nullable=False) id: int = Field(
default=None, sa_column=Column(BigInteger, primary_key=True, index=True)
)
# 基本信息(匹配 migrations 中的结构) # 基本信息(匹配 migrations 中的结构)
name: str = Field(max_length=32, unique=True, index=True) # 用户名 name: str = Field(max_length=32, unique=True, index=True) # 用户名
@@ -65,6 +67,10 @@ class User(SQLModel, table=True):
latest_activity = getattr(self, "latest_activity", 0) latest_activity = getattr(self, "latest_activity", 0)
return datetime.fromtimestamp(latest_activity) if latest_activity > 0 else None return datetime.fromtimestamp(latest_activity) if latest_activity > 0 else None
@property
def is_supporter(self):
return self.lazer_profile.is_supporter if self.lazer_profile else False
# 关联关系 # 关联关系
lazer_profile: Optional["LazerUserProfile"] = Relationship(back_populates="user") lazer_profile: Optional["LazerUserProfile"] = Relationship(back_populates="user")
lazer_statistics: list["LazerUserStatistics"] = Relationship(back_populates="user") lazer_statistics: list["LazerUserStatistics"] = Relationship(back_populates="user")
@@ -76,7 +82,7 @@ class User(SQLModel, table=True):
back_populates="user" back_populates="user"
) )
statistics: list["LegacyUserStatistics"] = Relationship(back_populates="user") statistics: list["LegacyUserStatistics"] = Relationship(back_populates="user")
team_membership: list["TeamMember"] = Relationship(back_populates="user") team_membership: Optional["TeamMember"] = Relationship(back_populates="user")
daily_challenge_stats: Optional["DailyChallengeStats"] = Relationship( daily_challenge_stats: Optional["DailyChallengeStats"] = Relationship(
back_populates="user" back_populates="user"
) )

View File

@@ -45,7 +45,7 @@ async def get_current_user_by_token(token: str, db: AsyncSession) -> DBUser | No
selectinload(DBUser.lazer_achievements), # pyright: ignore[reportArgumentType] selectinload(DBUser.lazer_achievements), # pyright: ignore[reportArgumentType]
selectinload(DBUser.lazer_profile_sections), # pyright: ignore[reportArgumentType] selectinload(DBUser.lazer_profile_sections), # pyright: ignore[reportArgumentType]
selectinload(DBUser.statistics), # pyright: ignore[reportArgumentType] selectinload(DBUser.statistics), # pyright: ignore[reportArgumentType]
selectinload(DBUser.team_membership), # pyright: ignore[reportArgumentType] joinedload(DBUser.team_membership), # pyright: ignore[reportArgumentType]
selectinload(DBUser.rank_history), # pyright: ignore[reportArgumentType] selectinload(DBUser.rank_history), # pyright: ignore[reportArgumentType]
selectinload(DBUser.active_banners), # pyright: ignore[reportArgumentType] selectinload(DBUser.active_banners), # pyright: ignore[reportArgumentType]
selectinload(DBUser.lazer_badges), # pyright: ignore[reportArgumentType] selectinload(DBUser.lazer_badges), # pyright: ignore[reportArgumentType]

View File

@@ -1,7 +1,12 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum, IntEnum from enum import Enum
import json
from typing import Any, Literal, TypedDict
from app.path import STATIC_DIR
from pydantic import BaseModel, Field, ValidationInfo, field_validator
import rosu_pp_py as rosu import rosu_pp_py as rosu
@@ -30,40 +35,186 @@ INT_TO_MODE = {v: k for k, v in MODE_TO_INT.items()}
class Rank(str, Enum): class Rank(str, Enum):
X = "ss" X = "X"
XH = "ssh" XH = "XH"
S = "s" S = "S"
SH = "sh" SH = "SH"
A = "a" A = "A"
B = "b" B = "B"
C = "c" C = "C"
D = "d" D = "D"
F = "f" F = "F"
class APIMod(TypedDict, total=False):
acronym: str
settings: dict[str, Any]
legacy_mod: dict[str, int] = {
"NF": 1 << 0, # No Fail
"EZ": 1 << 1, # Easy
"TD": 1 << 2, # Touch Device
"HD": 1 << 3, # Hidden
"HR": 1 << 4, # Hard Rock
"SD": 1 << 5, # Sudden Death
"DT": 1 << 6, # Double Time
"RX": 1 << 7, # Relax
"HT": 1 << 8, # Half Time
"NC": 1 << 9, # Nightcore
"FL": 1 << 10, # Flashlight
"AT": 1 << 11, # Autoplay
"SO": 1 << 12, # Spun Out
"AP": 1 << 13, # Auto Pilot
"PF": 1 << 14, # Perfect
"4K": 1 << 15, # 4K
"5K": 1 << 16, # 5K
"6K": 1 << 17, # 6K
"7K": 1 << 18, # 7K
"8K": 1 << 19, # 8K
"FI": 1 << 20, # Fade In
"RD": 1 << 21, # Random
"CN": 1 << 22, # Cinema
"TP": 1 << 23, # Target Practice
"9K": 1 << 24, # 9K
"CO": 1 << 25, # Key Co-op
"1K": 1 << 26, # 1K
"3K": 1 << 27, # 3K
"2K": 1 << 28, # 2K
"SV2": 1 << 29, # ScoreV2
"MR": 1 << 30, # Mirror
}
legacy_mod["NC"] |= legacy_mod["DT"]
legacy_mod["PF"] |= legacy_mod["SD"]
def api_mod_to_int(mods: list[APIMod]) -> int:
sum_ = 0
for mod in mods:
sum_ |= legacy_mod.get(mod["acronym"], 0)
return sum_
# https://github.com/ppy/osu/blob/master/osu.Game/Rulesets/Scoring/HitResult.cs # https://github.com/ppy/osu/blob/master/osu.Game/Rulesets/Scoring/HitResult.cs
class HitResult(IntEnum): class HitResult(str, Enum):
PERFECT = 0 # [Order(0)] PERFECT = "perfect" # [Order(0)]
GREAT = 1 # [Order(1)] GREAT = "great" # [Order(1)]
GOOD = 2 # [Order(2)] GOOD = "good" # [Order(2)]
OK = 3 # [Order(3)] OK = "ok" # [Order(3)]
MEH = 4 # [Order(4)] MEH = "meh" # [Order(4)]
MISS = 5 # [Order(5)] MISS = "miss" # [Order(5)]
LARGE_TICK_HIT = 6 # [Order(6)] LARGE_TICK_HIT = "large_tick_hit" # [Order(6)]
SMALL_TICK_HIT = 7 # [Order(7)] SMALL_TICK_HIT = "small_tick_hit" # [Order(7)]
SLIDER_TAIL_HIT = 8 # [Order(8)] SLIDER_TAIL_HIT = "slider_tail_hit" # [Order(8)]
LARGE_BONUS = 9 # [Order(9)] LARGE_BONUS = "large_bonus" # [Order(9)]
SMALL_BONUS = 10 # [Order(10)] SMALL_BONUS = "small_bonus" # [Order(10)]
LARGE_TICK_MISS = 11 # [Order(11)] LARGE_TICK_MISS = "large_tick_miss" # [Order(11)]
SMALL_TICK_MISS = 12 # [Order(12)] SMALL_TICK_MISS = "small_tick_miss" # [Order(12)]
IGNORE_HIT = 13 # [Order(13)] IGNORE_HIT = "ignore_hit" # [Order(13)]
IGNORE_MISS = 14 # [Order(14)] IGNORE_MISS = "ignore_miss" # [Order(14)]
NONE = 15 # [Order(15)] NONE = "none" # [Order(15)]
COMBO_BREAK = 16 # [Order(16)] COMBO_BREAK = "combo_break" # [Order(16)]
LEGACY_COMBO_INCREASE = 99 # [Order(99)] @deprecated LEGACY_COMBO_INCREASE = "legacy_combo_increase" # [Order(99)] @deprecated
def is_hit(self) -> bool:
return self not in (
HitResult.NONE,
HitResult.IGNORE_MISS,
HitResult.COMBO_BREAK,
HitResult.LARGE_TICK_MISS,
HitResult.SMALL_TICK_MISS,
HitResult.MISS,
)
class LeaderboardType(Enum):
GLOBAL = "global"
FRIENDS = "friends"
COUNTRY = "country"
TEAM = "team"
# see static/mods.json
class Settings(TypedDict):
Name: str
Type: str
Label: str
Description: str
class Mod(TypedDict):
Acronym: str
Name: str
Description: str
Type: str
Settings: list[Settings]
IncompatibleMods: list[str]
RequiresConfiguration: bool
UserPlayable: bool
ValidForMultiplayer: bool
ValidForFreestyleAsRequiredMod: bool
ValidForMultiplayerAsFreeMod: bool
AlwaysValidForSubmission: bool
MODS: dict[int, dict[str, Mod]] = {}
ScoreStatistics = dict[HitResult, int]
def _init_mods():
mods_file = STATIC_DIR / "mods.json"
raw_mods = json.loads(mods_file.read_text())
for ruleset in raw_mods:
ruleset_mods = {}
for mod in ruleset["Mods"]:
ruleset_mods[mod["Acronym"]] = mod
MODS[ruleset["RulesetID"]] = ruleset_mods
class SoloScoreSubmissionInfo(BaseModel):
rank: Rank
total_score: int = Field(ge=0, le=2**31 - 1)
total_score_without_mods: int = Field(ge=0, le=2**31 - 1)
accuracy: float = Field(ge=0, le=1)
pp: float = Field(default=0, ge=0, le=2**31 - 1)
max_combo: int = 0
ruleset_id: Literal[0, 1, 2, 3]
passed: bool = False
mods: list[APIMod] = Field(default_factory=list)
statistics: ScoreStatistics = Field(default_factory=dict)
maximum_statistics: ScoreStatistics = Field(default_factory=dict)
@field_validator("mods", mode="after")
@classmethod
def validate_mods(cls, mods: list[APIMod], info: ValidationInfo):
if not MODS:
_init_mods()
incompatible_mods = set()
# check incompatible mods
for mod in mods:
if mod["acronym"] in incompatible_mods:
raise ValueError(
f"Mod {mod['acronym']} is incompatible with other mods"
)
setting_mods = MODS[info.data["ruleset_id"]].get(mod["acronym"])
if not setting_mods:
raise ValueError(f"Invalid mod: {mod['acronym']}")
incompatible_mods.update(setting_mods["IncompatibleMods"])
class LegacyReplaySoloScoreInfo(TypedDict):
online_id: int
mods: list[APIMod]
statistics: ScoreStatistics
maximum_statistics: ScoreStatistics
client_version: str
rank: Rank
user_id: int
total_score_without_mods: int

7
app/path.py Normal file
View File

@@ -0,0 +1,7 @@
from __future__ import annotations
from pathlib import Path
STATIC_DIR = Path(__file__).parent.parent / "static"
REPLAY_DIR = Path(__file__).parent.parent / "replays"

View File

@@ -16,7 +16,10 @@ from app.dependencies.user import get_current_user
from app.fetcher import Fetcher from app.fetcher import Fetcher
from app.models.beatmap import BeatmapAttributes from app.models.beatmap import BeatmapAttributes
from app.models.mods import APIMod, int_to_mods from app.models.mods import APIMod, int_to_mods
from app.models.score import INT_TO_MODE, GameMode from app.models.score import (
INT_TO_MODE,
GameMode,
)
from app.utils import calculate_beatmap_attribute from app.utils import calculate_beatmap_attribute
from .api_router import router from .api_router import router

View File

@@ -28,6 +28,11 @@ from app.models.user import (
import rosu_pp_py as rosu import rosu_pp_py as rosu
def unix_timestamp_to_windows(timestamp: int) -> int:
"""Convert a Unix timestamp to a Windows timestamp."""
return timestamp * 10_000 + 11_644_473_600_000_000
async def convert_db_user_to_api_user(db_user: DBUser, ruleset: str = "osu") -> User: async def convert_db_user_to_api_user(db_user: DBUser, ruleset: str = "osu") -> User:
"""将数据库用户模型转换为API用户模型使用 Lazer 表)""" """将数据库用户模型转换为API用户模型使用 Lazer 表)"""
@@ -205,7 +210,7 @@ async def convert_db_user_to_api_user(db_user: DBUser, ruleset: str = "osu") ->
# 转换团队信息 # 转换团队信息
team = None team = None
if db_user.team_membership: if db_user.team_membership:
team_member = db_user.team_membership[0] # 假设用户只属于一个团队 team_member = db_user.team_membership # 假设用户只属于一个团队
team = team_member.team team = team_member.team
# 创建用户对象 # 创建用户对象

5
static/README.md Normal file
View File

@@ -0,0 +1,5 @@
# 静态文件
- `mods.json`: 包含了游戏中的所有可用mod的详细信息。
- Origin: https://github.com/ppy/osu-web/blob/master/database/mods.json
- Version: 2025/6/10 `b68c920b1db3d443b9302fdc3f86010c875fe380`

3656
static/mods.json Normal file

File diff suppressed because it is too large Load Diff