Files
g0v0-server/app/database/score.py
2025-10-03 15:46:53 +00:00

1300 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from collections.abc import Sequence
from datetime import date, datetime
import json
import math
import sys
from typing import TYPE_CHECKING, Any
from app.calculator import (
calculate_pp_weight,
calculate_score_to_level,
calculate_weighted_acc,
calculate_weighted_pp,
clamp,
pre_fetch_and_calculate_pp,
)
from app.config import settings
from app.database.team import TeamMember
from app.dependencies.database import get_redis
from app.log import log
from app.models.beatmap import BeatmapRankStatus
from app.models.model import (
CurrentUserAttributes,
PinAttributes,
RespWithCursor,
UTCBaseModel,
)
from app.models.mods import APIMod, get_speed_rate, mod_to_save, mods_can_get_pp
from app.models.score import (
GameMode,
HitResult,
LeaderboardType,
Rank,
ScoreStatistics,
SoloScoreSubmissionInfo,
)
from app.storage import StorageService
from app.utils import utcnow
from .beatmap import Beatmap, BeatmapResp
from .beatmapset import BeatmapsetResp
from .best_scores import PPBestScore
from .counts import MonthlyPlaycounts
from .events import Event, EventType
from .playlist_best_score import PlaylistBestScore
from .relationship import (
Relationship as DBRelationship,
RelationshipType,
)
from .score_token import ScoreToken
from .total_score_best_scores import BestScore
from .user import User, UserResp
from pydantic import BaseModel, field_serializer, field_validator
from redis.asyncio import Redis
from sqlalchemy import Boolean, Column, DateTime, TextClause
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import Mapped, joinedload
from sqlalchemy.sql.elements import ColumnElement
from sqlmodel import (
JSON,
BigInteger,
Field,
ForeignKey,
Relationship,
SQLModel,
col,
func,
select,
text,
true,
)
from sqlmodel.ext.asyncio.session import AsyncSession
if TYPE_CHECKING:
from app.fetcher import Fetcher
logger = log("Score")
class ScoreBase(AsyncAttrs, SQLModel, UTCBaseModel):
# 基本字段
accuracy: float
map_md5: str = Field(max_length=32, index=True)
build_id: int | None = Field(default=None)
classic_total_score: int | None = Field(default=0, sa_column=Column(BigInteger)) # solo_score
ended_at: datetime = Field(sa_column=Column(DateTime))
has_replay: bool = Field(sa_column=Column(Boolean))
max_combo: int
mods: list[APIMod] = Field(sa_column=Column(JSON))
passed: bool = Field(sa_column=Column(Boolean))
playlist_item_id: int | None = Field(default=None) # multiplayer
pp: float = Field(default=0.0)
preserve: bool = Field(default=True, sa_column=Column(Boolean))
rank: Rank
room_id: int | None = Field(default=None) # multiplayer
started_at: datetime = Field(sa_column=Column(DateTime))
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
beatmap_id: int = Field(index=True, foreign_key="beatmaps.id")
maximum_statistics: ScoreStatistics = Field(sa_column=Column(JSON), default_factory=dict)
processed: bool = False # solo_score
ranked: bool = False
@field_validator("maximum_statistics", mode="before")
@classmethod
def validate_maximum_statistics(cls, v):
"""处理 maximum_statistics 字段中的字符串键,转换为 HitResult 枚举"""
if isinstance(v, dict):
converted = {}
for key, value in v.items():
if isinstance(key, str):
try:
# 尝试将字符串转换为 HitResult 枚举
enum_key = HitResult(key)
converted[enum_key] = value
except ValueError:
# 如果转换失败,跳过这个键值对
continue
else:
converted[key] = value
return converted
return v
@field_serializer("maximum_statistics", when_used="json")
def serialize_maximum_statistics(self, v):
"""序列化 maximum_statistics 字段,确保枚举值正确转换为字符串"""
if isinstance(v, dict):
serialized = {}
for key, value in v.items():
if hasattr(key, "value"):
# 如果是枚举,使用其值
serialized[key.value] = value
else:
# 否则直接使用键
serialized[str(key)] = value
return serialized
return v
@field_serializer("rank", when_used="json")
def serialize_rank(self, v):
"""序列化等级,确保枚举值正确转换为字符串"""
if hasattr(v, "value"):
return v.value
return str(v)
# optional
# TODO: current_user_attributes
class Score(ScoreBase, table=True):
__tablename__: str = "scores"
id: int = Field(default=None, sa_column=Column(BigInteger, autoincrement=True, primary_key=True))
user_id: int = Field(
default=None,
sa_column=Column(
BigInteger,
ForeignKey("lazer_users.id"),
index=True,
),
)
# ScoreStatistics
n300: int = Field(exclude=True)
n100: int = Field(exclude=True)
n50: int = Field(exclude=True)
nmiss: int = Field(exclude=True)
ngeki: int = Field(exclude=True)
nkatu: int = Field(exclude=True)
nlarge_tick_miss: int | None = Field(default=None, exclude=True)
nlarge_tick_hit: int | None = Field(default=None, exclude=True)
nslider_tail_hit: int | None = Field(default=None, exclude=True)
nsmall_tick_hit: int | None = Field(default=None, exclude=True)
gamemode: GameMode = Field(index=True)
pinned_order: int = Field(default=0, exclude=True)
@field_validator("gamemode", mode="before")
@classmethod
def validate_gamemode(cls, v):
"""将字符串转换为 GameMode 枚举"""
if isinstance(v, str):
try:
return GameMode(v)
except ValueError:
# 如果转换失败,返回默认值
return GameMode.OSU
return v
@field_serializer("gamemode", when_used="json")
def serialize_gamemode(self, v):
"""序列化游戏模式,确保枚举值正确转换为字符串"""
if hasattr(v, "value"):
return v.value
return str(v)
# optional
beatmap: Mapped[Beatmap] = Relationship()
user: Mapped[User] = Relationship(sa_relationship_kwargs={"lazy": "joined"})
best_score: Mapped[BestScore | None] = Relationship(
back_populates="score",
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
},
)
ranked_score: Mapped[PPBestScore | None] = Relationship(
back_populates="score",
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
},
)
playlist_item_score: Mapped[PlaylistBestScore | None] = Relationship(
back_populates="score",
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
},
)
@property
def is_perfect_combo(self) -> bool:
return self.max_combo == self.beatmap.max_combo
@property
def replay_filename(self) -> str:
return f"replays/{self.id}_{self.beatmap_id}_{self.user_id}_lazer_replay.osr"
async def to_resp(self, session: AsyncSession, api_version: int) -> "ScoreResp | LegacyScoreResp":
if api_version >= 20220705:
return await ScoreResp.from_db(session, self)
return await LegacyScoreResp.from_db(session, self)
async def delete(
self,
session: AsyncSession,
storage_service: StorageService,
):
if await self.awaitable_attrs.best_score:
assert self.best_score is not None
await self.best_score.delete(session)
await session.refresh(self)
if await self.awaitable_attrs.ranked_score:
assert self.ranked_score is not None
await self.ranked_score.delete(session)
await session.refresh(self)
if await self.awaitable_attrs.playlist_item_score:
await session.delete(self.playlist_item_score)
await storage_service.delete_file(self.replay_filename)
await session.delete(self)
class ScoreResp(ScoreBase):
id: int
user_id: int
is_perfect_combo: bool = False
legacy_perfect: bool = False
legacy_total_score: int = 0 # FIXME
weight: float = 0.0
best_id: int | None = None
ruleset_id: int | None = None
beatmap: BeatmapResp | None = None
beatmapset: BeatmapsetResp | None = None
user: UserResp | None = None
statistics: ScoreStatistics | None = None
maximum_statistics: ScoreStatistics | None = None
rank_global: int | None = None
rank_country: int | None = None
position: int | None = None
scores_around: "ScoreAround | None" = None
current_user_attributes: CurrentUserAttributes | None = None
@field_validator(
"has_replay",
"passed",
"preserve",
"is_perfect_combo",
"legacy_perfect",
"processed",
"ranked",
mode="before",
)
@classmethod
def validate_bool_fields(cls, v):
"""将整数 0/1 转换为布尔值,处理数据库中的布尔字段"""
if isinstance(v, int):
return bool(v)
return v
@field_validator("statistics", "maximum_statistics", mode="before")
@classmethod
def validate_statistics_fields(cls, v):
"""处理统计字段中的字符串键,转换为 HitResult 枚举"""
if isinstance(v, dict):
converted = {}
for key, value in v.items():
if isinstance(key, str):
try:
# 尝试将字符串转换为 HitResult 枚举
enum_key = HitResult(key)
converted[enum_key] = value
except ValueError:
# 如果转换失败,跳过这个键值对
continue
else:
converted[key] = value
return converted
return v
@field_serializer("statistics", when_used="json")
def serialize_statistics_fields(self, v):
"""序列化统计字段,确保枚举值正确转换为字符串"""
if isinstance(v, dict):
serialized = {}
for key, value in v.items():
if hasattr(key, "value"):
# 如果是枚举,使用其值
serialized[key.value] = value
else:
# 否则直接使用键
serialized[str(key)] = value
return serialized
return v
@classmethod
async def from_db(cls, session: AsyncSession, score: Score) -> "ScoreResp":
# 确保 score 对象完全加载,避免懒加载问题
await session.refresh(score)
s = cls.model_validate(score.model_dump())
await score.awaitable_attrs.beatmap
s.beatmap = await BeatmapResp.from_db(score.beatmap)
s.beatmapset = await BeatmapsetResp.from_db(score.beatmap.beatmapset, session=session, user=score.user)
s.is_perfect_combo = s.max_combo == s.beatmap.max_combo
s.legacy_perfect = s.max_combo == s.beatmap.max_combo
s.ruleset_id = int(score.gamemode)
best_id = await get_best_id(session, score.id)
if best_id:
s.best_id = best_id
s.weight = calculate_pp_weight(best_id - 1)
s.statistics = {
HitResult.MISS: score.nmiss,
HitResult.MEH: score.n50,
HitResult.OK: score.n100,
HitResult.GREAT: score.n300,
HitResult.PERFECT: score.ngeki,
HitResult.GOOD: score.nkatu,
}
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
if score.nsmall_tick_hit is not None:
s.statistics[HitResult.SMALL_TICK_HIT] = score.nsmall_tick_hit
if score.nlarge_tick_hit is not None:
s.statistics[HitResult.LARGE_TICK_HIT] = score.nlarge_tick_hit
s.user = await UserResp.from_db(
score.user,
session,
include=["statistics", "team", "daily_challenge_user_stats"],
ruleset=score.gamemode,
)
s.rank_global = (
await get_score_position_by_id(
session,
score.beatmap_id,
score.id,
mode=score.gamemode,
user=score.user,
)
or None
)
s.rank_country = (
await get_score_position_by_id(
session,
score.beatmap_id,
score.id,
score.gamemode,
score.user,
type=LeaderboardType.COUNTRY,
)
or None
)
s.current_user_attributes = CurrentUserAttributes(
pin=PinAttributes(is_pinned=bool(score.pinned_order), score_id=score.id)
)
return s
class LegacyStatistics(BaseModel):
count_300: int
count_100: int
count_50: int
count_miss: int
count_geki: int | None = None
count_katu: int | None = None
class LegacyScoreResp(UTCBaseModel):
accuracy: float
best_id: int
created_at: datetime
id: int
max_combo: int
mode: GameMode
mode_int: int
mods: list[str] # acronym
passed: bool
perfect: bool = False
pp: float
rank: Rank
replay: bool
score: int
statistics: LegacyStatistics
type: str
user_id: int
current_user_attributes: CurrentUserAttributes
user: UserResp
beatmap: BeatmapResp
rank_global: int | None = Field(default=None, exclude=True)
@classmethod
async def from_db(cls, session: AsyncSession, score: Score) -> "LegacyScoreResp":
await session.refresh(score)
await score.awaitable_attrs.beatmap
return cls(
accuracy=score.accuracy,
best_id=await get_best_id(session, score.id) or 0,
created_at=score.started_at,
id=score.id,
max_combo=score.max_combo,
mode=score.gamemode,
mode_int=int(score.gamemode),
mods=[m["acronym"] for m in score.mods],
passed=score.passed,
pp=score.pp,
rank=score.rank,
replay=score.has_replay,
score=score.total_score,
statistics=LegacyStatistics(
count_300=score.n300,
count_100=score.n100,
count_50=score.n50,
count_miss=score.nmiss,
count_geki=score.ngeki or 0,
count_katu=score.nkatu or 0,
),
type=score.type,
user_id=score.user_id,
current_user_attributes=CurrentUserAttributes(
pin=PinAttributes(is_pinned=bool(score.pinned_order), score_id=score.id)
),
user=await UserResp.from_db(
score.user,
session,
include=["statistics", "team", "daily_challenge_user_stats"],
ruleset=score.gamemode,
),
beatmap=await BeatmapResp.from_db(score.beatmap),
perfect=score.is_perfect_combo,
rank_global=(
await get_score_position_by_id(
session,
score.beatmap_id,
score.id,
mode=score.gamemode,
user=score.user,
)
or None
),
)
class MultiplayerScores(RespWithCursor):
scores: list[ScoreResp] = Field(default_factory=list)
params: dict[str, Any] = Field(default_factory=dict)
class ScoreAround(SQLModel):
higher: MultiplayerScores | None = None
lower: MultiplayerScores | None = None
async def get_best_id(session: AsyncSession, score_id: int) -> int | None:
rownum = (
func.row_number()
.over(partition_by=(col(PPBestScore.user_id), col(PPBestScore.gamemode)), order_by=col(PPBestScore.pp).desc())
.label("rn")
)
subq = select(PPBestScore, rownum).subquery()
stmt = select(subq.c.rn).where(subq.c.score_id == score_id)
result = await session.exec(stmt)
return result.one_or_none()
async def _score_where(
type: LeaderboardType,
beatmap: int,
mode: GameMode,
mods: list[str] | None = None,
user: User | None = None,
) -> list[ColumnElement[bool] | TextClause] | None:
wheres: list[ColumnElement[bool] | TextClause] = [
col(BestScore.beatmap_id) == beatmap,
col(BestScore.gamemode) == mode,
]
if type == LeaderboardType.FRIENDS:
if user and user.is_supporter:
subq = (
select(DBRelationship.target_id)
.where(
DBRelationship.type == RelationshipType.FOLLOW,
DBRelationship.user_id == user.id,
)
.subquery()
)
wheres.append(col(BestScore.user_id).in_(select(subq.c.target_id)))
else:
return None
elif type == LeaderboardType.COUNTRY:
if user and user.is_supporter:
wheres.append(col(BestScore.user).has(col(User.country_code) == user.country_code))
else:
return None
elif type == LeaderboardType.TEAM and user:
team_membership = await user.awaitable_attrs.team_membership
if team_membership:
team_id = team_membership.team_id
wheres.append(col(BestScore.user).has(col(User.team_membership).has(TeamMember.team_id == team_id)))
if mods:
if user and user.is_supporter:
wheres.append(
text(
"JSON_CONTAINS(total_score_best_scores.mods, :w)"
" AND JSON_CONTAINS(:w, total_score_best_scores.mods)"
).params(w=json.dumps(mods))
)
else:
return None
return wheres
async def get_leaderboard(
session: AsyncSession,
beatmap: int,
mode: GameMode,
type: LeaderboardType = LeaderboardType.GLOBAL,
mods: list[str] | None = None,
user: User | None = None,
limit: int = 50,
) -> tuple[list[Score], Score | None, int]:
mods = mods or []
mode = mode.to_special_mode(mods)
wheres = await _score_where(type, beatmap, mode, mods, user)
if wheres is None:
return [], None, 0
count = (await session.exec(select(func.count()).where(*wheres))).one()
scores: dict[int, Score] = {}
max_score = sys.maxsize
while limit > 0:
query = (
select(BestScore)
.where(*wheres, BestScore.total_score < max_score)
.limit(limit)
.order_by(col(BestScore.total_score).desc())
)
extra_need = 0
for s in await session.exec(query):
if s.user_id in scores:
extra_need += 1
count -= 1
if s.total_score > scores[s.user_id].total_score:
scores[s.user_id] = s.score
else:
scores[s.user_id] = s.score
if max_score > s.total_score:
max_score = s.total_score
limit = extra_need
result_scores = sorted(scores.values(), key=lambda u: u.total_score, reverse=True)
user_score = None
if user:
self_query = (
select(BestScore)
.where(BestScore.user_id == user.id)
.where(
col(BestScore.beatmap_id) == beatmap,
col(BestScore.gamemode) == mode,
)
.order_by(col(BestScore.total_score).desc())
.limit(1)
)
if mods:
self_query = self_query.where(
text(
"JSON_CONTAINS(total_score_best_scores.mods, :w)"
" AND JSON_CONTAINS(:w, total_score_best_scores.mods)"
)
).params(w=json.dumps(mods))
user_bs = (await session.exec(self_query)).first()
if user_bs:
user_score = user_bs.score
if user_score and user_score not in result_scores:
result_scores.append(user_score)
return result_scores, user_score, count
async def get_score_position_by_user(
session: AsyncSession,
beatmap: int,
user: User,
mode: GameMode,
type: LeaderboardType = LeaderboardType.GLOBAL,
mods: list[str] | None = None,
) -> int:
wheres = await _score_where(type, beatmap, mode, mods, user=user)
if wheres is None:
return 0
rownum = (
func.row_number()
.over(
partition_by=(
col(BestScore.beatmap_id),
col(BestScore.gamemode),
),
order_by=col(BestScore.total_score).desc(),
)
.label("row_number")
)
subq = select(BestScore, rownum).join(Beatmap).where(*wheres).subquery()
stmt = select(subq.c.row_number).where(subq.c.user_id == user.id)
result = await session.exec(stmt)
s = result.first()
return s if s else 0
async def get_score_position_by_id(
session: AsyncSession,
beatmap: int,
score_id: int,
mode: GameMode,
user: User | None = None,
type: LeaderboardType = LeaderboardType.GLOBAL,
mods: list[str] | None = None,
) -> int:
wheres = await _score_where(type, beatmap, mode, mods, user=user)
if wheres is None:
return 0
rownum = (
func.row_number()
.over(
partition_by=(
col(BestScore.beatmap_id),
col(BestScore.gamemode),
),
order_by=col(BestScore.total_score).desc(),
)
.label("row_number")
)
subq = select(BestScore, rownum).join(Beatmap).where(*wheres).subquery()
stmt = select(subq.c.row_number).where(subq.c.score_id == score_id)
result = await session.exec(stmt)
s = result.one_or_none()
return s if s else 0
async def get_user_best_score_in_beatmap(
session: AsyncSession,
beatmap: int,
user: int,
mode: GameMode | None = None,
) -> BestScore | None:
return (
await session.exec(
select(BestScore)
.where(
BestScore.gamemode == mode if mode is not None else true(),
BestScore.beatmap_id == beatmap,
BestScore.user_id == user,
)
.order_by(col(BestScore.total_score).desc())
)
).first()
async def get_user_best_score_with_mod_in_beatmap(
session: AsyncSession,
beatmap: int,
user: int,
mod: list[str],
mode: GameMode | None = None,
) -> BestScore | None:
return (
await session.exec(
select(BestScore)
.where(
BestScore.gamemode == mode if mode is not None else True,
BestScore.beatmap_id == beatmap,
BestScore.user_id == user,
text(
"JSON_CONTAINS(total_score_best_scores.mods, :w)"
" AND JSON_CONTAINS(:w, total_score_best_scores.mods)"
).params(w=json.dumps(mod)),
)
.order_by(col(BestScore.total_score).desc())
)
).first()
async def get_user_first_scores(
session: AsyncSession, user_id: int, mode: GameMode, limit: int = 5, offset: int = 0
) -> list[BestScore]:
rownum = (
func.row_number()
.over(
partition_by=(col(BestScore.beatmap_id), col(BestScore.gamemode)),
order_by=col(BestScore.total_score).desc(),
)
.label("rn")
)
# Step 1: Fetch top score_ids in Python
subq = (
select(
col(BestScore.score_id).label("score_id"),
col(BestScore.user_id).label("user_id"),
rownum,
)
.where(col(BestScore.gamemode) == mode)
.subquery()
)
top_ids_stmt = select(subq.c.score_id).where(subq.c.rn == 1, subq.c.user_id == user_id).limit(limit).offset(offset)
top_ids = await session.exec(top_ids_stmt)
top_ids = list(top_ids)
stmt = select(BestScore).where(col(BestScore.score_id).in_(top_ids)).order_by(col(BestScore.total_score).desc())
result = await session.exec(stmt)
return list(result.all())
async def get_user_first_score_count(session: AsyncSession, user_id: int, mode: GameMode) -> int:
rownum = (
func.row_number()
.over(
partition_by=(col(BestScore.beatmap_id), col(BestScore.gamemode)),
order_by=col(BestScore.total_score).desc(),
)
.label("rn")
)
subq = (
select(
col(BestScore.score_id).label("score_id"),
col(BestScore.user_id).label("user_id"),
rownum,
)
.where(col(BestScore.gamemode) == mode)
.subquery()
)
count_stmt = select(func.count()).where(subq.c.rn == 1, subq.c.user_id == user_id)
result = await session.exec(count_stmt)
return result.one()
async def get_user_best_pp_in_beatmap(
session: AsyncSession,
beatmap: int,
user: int,
mode: GameMode,
) -> PPBestScore | None:
return (
await session.exec(
select(PPBestScore).where(
PPBestScore.beatmap_id == beatmap,
PPBestScore.user_id == user,
PPBestScore.gamemode == mode,
)
)
).first()
async def calculate_user_pp(session: AsyncSession, user_id: int, mode: GameMode) -> tuple[float, float]:
pp_sum = 0
acc_sum = 0
bps = await get_user_best_pp(session, user_id, mode)
for i, s in enumerate(bps):
pp_sum += calculate_weighted_pp(s.pp, i)
acc_sum += calculate_weighted_acc(s.acc, i)
if len(bps):
# https://github.com/ppy/osu-queue-score-statistics/blob/c538ae/osu.Server.Queues.ScoreStatisticsProcessor/Helpers/UserTotalPerformanceAggregateHelper.cs#L41-L45
acc_sum *= 100 / (20 * (1 - math.pow(0.95, len(bps))))
acc_sum = clamp(acc_sum, 0.0, 100.0)
return pp_sum, acc_sum
async def get_user_best_pp(
session: AsyncSession,
user: int,
mode: GameMode,
limit: int = 1000,
) -> Sequence[PPBestScore]:
return (
await session.exec(
select(PPBestScore)
.where(PPBestScore.user_id == user, PPBestScore.gamemode == mode)
.order_by(col(PPBestScore.pp).desc())
.limit(limit)
)
).all()
# https://github.com/ppy/osu-queue-score-statistics/blob/master/osu.Server.Queues.ScoreStatisticsProcessor/Helpers/PlayValidityHelper.cs
def get_play_length(score: Score, beatmap_length: int):
speed_rate = get_speed_rate(score.mods)
length = beatmap_length / speed_rate
return int(min(length, (score.ended_at - score.started_at).total_seconds()))
def calculate_playtime(score: Score, beatmap_length: int) -> tuple[int, bool]:
total_length = get_play_length(score, beatmap_length)
total_obj_hited = (
score.n300
+ score.n100
+ score.n50
+ score.ngeki
+ score.nkatu
+ (score.nlarge_tick_hit or 0)
+ (score.nlarge_tick_miss or 0)
+ (score.nslider_tail_hit or 0)
+ (score.nsmall_tick_hit or 0)
)
total_obj = 0
for statistics, count in score.maximum_statistics.items() if score.maximum_statistics else {}:
if not isinstance(statistics, HitResult):
statistics = HitResult(statistics)
if statistics.is_scorable():
total_obj += count
return total_length, score.passed or (
total_length > 8 and score.total_score >= 5000 and total_obj_hited >= min(0.1 * total_obj, 20)
)
async def process_score(
user: User,
beatmap_id: int,
ranked: bool,
score_token: ScoreToken,
info: SoloScoreSubmissionInfo,
session: AsyncSession,
item_id: int | None = None,
room_id: int | None = None,
) -> Score:
gamemode = GameMode.from_int(info.ruleset_id).to_special_mode(info.mods)
logger.info(
"Creating score for user {user_id} | beatmap={beatmap_id} ruleset={ruleset} passed={passed} total={total}",
user_id=user.id,
beatmap_id=beatmap_id,
ruleset=gamemode,
passed=info.passed,
total=info.total_score,
)
score = Score(
accuracy=info.accuracy,
max_combo=info.max_combo,
mods=info.mods,
passed=info.passed,
rank=info.rank,
total_score=info.total_score,
total_score_without_mods=info.total_score_without_mods,
beatmap_id=beatmap_id,
ended_at=utcnow(),
gamemode=gamemode,
started_at=score_token.created_at,
user_id=user.id,
preserve=info.passed,
map_md5=score_token.beatmap.checksum,
has_replay=False,
type="solo",
n300=info.statistics.get(HitResult.GREAT, 0),
n100=info.statistics.get(HitResult.OK, 0),
n50=info.statistics.get(HitResult.MEH, 0),
nmiss=info.statistics.get(HitResult.MISS, 0),
ngeki=info.statistics.get(HitResult.PERFECT, 0),
nkatu=info.statistics.get(HitResult.GOOD, 0),
nlarge_tick_miss=info.statistics.get(HitResult.LARGE_TICK_MISS, 0),
nsmall_tick_hit=info.statistics.get(HitResult.SMALL_TICK_HIT, 0),
nlarge_tick_hit=info.statistics.get(HitResult.LARGE_TICK_HIT, 0),
nslider_tail_hit=info.statistics.get(HitResult.SLIDER_TAIL_HIT, 0),
playlist_item_id=item_id,
room_id=room_id,
maximum_statistics=info.maximum_statistics,
processed=True,
ranked=ranked,
)
session.add(score)
logger.debug(
"Score staged for commit | token={token} mods={mods} total_hits={hits}",
token=score_token.id,
mods=info.mods,
hits=sum(info.statistics.values()) if info.statistics else 0,
)
await session.commit()
await session.refresh(score)
return score
async def _process_score_pp(score: Score, session: AsyncSession, redis: Redis, fetcher: "Fetcher"):
if score.pp != 0:
logger.debug(
"Skipping PP calculation for score {score_id} | already set {pp:.2f}",
score_id=score.id,
pp=score.pp,
)
return
can_get_pp = score.passed and score.ranked and mods_can_get_pp(int(score.gamemode), score.mods)
if not can_get_pp:
logger.debug(
"Skipping PP calculation for score {score_id} | passed={passed} ranked={ranked} mods={mods}",
score_id=score.id,
passed=score.passed,
ranked=score.ranked,
mods=score.mods,
)
return
pp, successed = await pre_fetch_and_calculate_pp(score, session, redis, fetcher)
if not successed:
await redis.rpush("score:need_recalculate", score.id) # pyright: ignore[reportGeneralTypeIssues]
logger.warning("Queued score {score_id} for PP recalculation", score_id=score.id)
return
score.pp = pp
logger.info("Calculated PP for score {score_id} | pp={pp:.2f}", score_id=score.id, pp=pp)
user_id = score.user_id
beatmap_id = score.beatmap_id
previous_pp_best = await get_user_best_pp_in_beatmap(session, beatmap_id, user_id, score.gamemode)
if previous_pp_best is None or score.pp > previous_pp_best.pp:
best_score = PPBestScore(
user_id=user_id,
score_id=score.id,
beatmap_id=beatmap_id,
gamemode=score.gamemode,
pp=score.pp,
acc=score.accuracy,
)
session.add(best_score)
await session.delete(previous_pp_best) if previous_pp_best else None
logger.info(
"Updated PP best for user {user_id} | score_id={score_id} pp={pp:.2f}",
user_id=user_id,
score_id=score.id,
pp=score.pp,
)
async def _process_score_events(score: Score, session: AsyncSession):
total_users = (await session.exec(select(func.count()).select_from(User))).one()
rank_global = await get_score_position_by_id(
session,
score.beatmap_id,
score.id,
mode=score.gamemode,
user=score.user,
)
if rank_global == 0 or total_users == 0:
logger.debug(
"Skipping event creation for score {score_id} | rank_global={rank_global} total_users={total_users}",
score_id=score.id,
rank_global=rank_global,
total_users=total_users,
)
return
logger.debug(
"Processing events for score {score_id} | rank_global={rank_global} total_users={total_users}",
score_id=score.id,
rank_global=rank_global,
total_users=total_users,
)
if rank_global <= min(math.ceil(float(total_users) * 0.01), 50):
rank_event = Event(
created_at=utcnow(),
type=EventType.RANK,
user_id=score.user_id,
user=score.user,
)
rank_event.event_payload = {
"scorerank": score.rank.value,
"rank": rank_global,
"mode": score.gamemode.readable(),
"beatmap": {
"title": (
f"{score.beatmap.beatmapset.artist} - {score.beatmap.beatmapset.title} [{score.beatmap.version}]"
),
"url": score.beatmap.url.replace("https://osu.ppy.sh/", settings.web_url),
},
"user": {
"username": score.user.username,
"url": settings.web_url + "users/" + str(score.user.id),
},
}
session.add(rank_event)
logger.info(
"Registered rank event for user {user_id} | score_id={score_id} rank={rank}",
user_id=score.user_id,
score_id=score.id,
rank=rank_global,
)
if rank_global == 1:
displaced_score = (
await session.exec(
select(BestScore)
.where(
BestScore.beatmap_id == score.beatmap_id,
BestScore.gamemode == score.gamemode,
)
.order_by(col(BestScore.total_score).desc())
.limit(1)
.offset(1)
)
).first()
if displaced_score and displaced_score.user_id != score.user_id:
username = (await session.exec(select(User.username).where(User.id == displaced_score.user_id))).one()
rank_lost_event = Event(
created_at=utcnow(),
type=EventType.RANK_LOST,
user_id=displaced_score.user_id,
)
rank_lost_event.event_payload = {
"mode": score.gamemode.readable(),
"beatmap": {
"title": (
f"{score.beatmap.beatmapset.artist} - {score.beatmap.beatmapset.title} "
f"[{score.beatmap.version}]"
),
"url": score.beatmap.url.replace("https://osu.ppy.sh/", settings.web_url),
},
"user": {
"username": username,
"url": settings.web_url + "users/" + str(displaced_score.user.id),
},
}
session.add(rank_lost_event)
logger.info(
"Registered rank lost event | displaced_user={user_id} new_score_id={score_id}",
user_id=displaced_score.user_id,
score_id=score.id,
)
logger.debug(
"Event processing committed for score {score_id}",
score_id=score.id,
)
async def _process_statistics(
session: AsyncSession,
redis: Redis,
user: User,
score: Score,
score_token: int,
beatmap_length: int,
beatmap_status: BeatmapRankStatus,
):
has_pp = beatmap_status.has_pp() or settings.enable_all_beatmap_pp
ranked = beatmap_status.ranked() or settings.enable_all_beatmap_pp
has_leaderboard = beatmap_status.has_leaderboard() or settings.enable_all_beatmap_leaderboard
mod_for_save = mod_to_save(score.mods)
previous_score_best = await get_user_best_score_in_beatmap(session, score.beatmap_id, user.id, score.gamemode)
previous_score_best_mod = await get_user_best_score_with_mod_in_beatmap(
session, score.beatmap_id, user.id, mod_for_save, score.gamemode
)
logger.debug(
"Existing best scores for user {user_id} | global={global_id} mod={mod_id}",
user_id=user.id,
global_id=previous_score_best.score_id if previous_score_best else None,
mod_id=previous_score_best_mod.score_id if previous_score_best_mod else None,
)
add_to_db = False
mouthly_playcount = (
await session.exec(
select(MonthlyPlaycounts).where(
MonthlyPlaycounts.user_id == user.id,
MonthlyPlaycounts.year == date.today().year,
MonthlyPlaycounts.month == date.today().month,
)
)
).first()
if mouthly_playcount is None:
mouthly_playcount = MonthlyPlaycounts(user_id=user.id, year=date.today().year, month=date.today().month)
add_to_db = True
statistics = None
for i in await user.awaitable_attrs.statistics:
if i.mode == score.gamemode.value:
statistics = i
break
if statistics is None:
raise ValueError(f"User {user.id} does not have statistics for mode {score.gamemode.value}")
# pc, pt, tth, tts
statistics.total_score += score.total_score
difference = score.total_score - previous_score_best.total_score if previous_score_best else score.total_score
logger.debug(
"Score delta computed for {score_id}: {difference}",
score_id=score.id,
difference=difference,
)
if difference > 0 and score.passed and ranked:
match score.rank:
case Rank.X:
statistics.grade_ss += 1
case Rank.XH:
statistics.grade_ssh += 1
case Rank.S:
statistics.grade_s += 1
case Rank.SH:
statistics.grade_sh += 1
case Rank.A:
statistics.grade_a += 1
if previous_score_best is not None:
match previous_score_best.rank:
case Rank.X:
statistics.grade_ss -= 1
case Rank.XH:
statistics.grade_ssh -= 1
case Rank.S:
statistics.grade_s -= 1
case Rank.SH:
statistics.grade_sh -= 1
case Rank.A:
statistics.grade_a -= 1
statistics.ranked_score += difference
statistics.level_current = calculate_score_to_level(statistics.total_score)
statistics.maximum_combo = max(statistics.maximum_combo, score.max_combo)
if score.passed and has_leaderboard:
# 情况1: 没有最佳分数记录,直接添加
# 情况2: 有最佳分数记录但没有该mod组合的记录添加新记录
if previous_score_best is None or previous_score_best_mod is None:
session.add(
BestScore(
user_id=user.id,
beatmap_id=score.beatmap_id,
gamemode=score.gamemode,
score_id=score.id,
total_score=score.total_score,
rank=score.rank,
mods=mod_for_save,
)
)
logger.info(
"Created new best score entry for user {user_id} | score_id={score_id} mods={mods}",
user_id=user.id,
score_id=score.id,
mods=mod_for_save,
)
# 情况3: 有最佳分数记录和该mod组合的记录且是同一个记录更新得分更高的情况
elif previous_score_best.score_id == previous_score_best_mod.score_id and difference > 0:
previous_score_best.total_score = score.total_score
previous_score_best.rank = score.rank
previous_score_best.score_id = score.id
logger.info(
"Updated existing best score for user {user_id} | score_id={score_id} total={total}",
user_id=user.id,
score_id=score.id,
total=score.total_score,
)
# 情况4: 有最佳分数记录和该mod组合的记录但不是同一个记录
elif previous_score_best.score_id != previous_score_best_mod.score_id:
# 更新全局最佳记录(如果新分数更高)
if difference > 0:
# 下方的 if 一定会触发。将高分设置为此分数,删除自己防止重复的 score_id
logger.info(
"Replacing global best score for user {user_id} | old_score_id={old_score_id}",
user_id=user.id,
old_score_id=previous_score_best.score_id,
)
await session.delete(previous_score_best)
# 更新mod特定最佳记录如果新分数更高
mod_diff = score.total_score - previous_score_best_mod.total_score
if mod_diff > 0:
previous_score_best_mod.total_score = score.total_score
previous_score_best_mod.rank = score.rank
previous_score_best_mod.score_id = score.id
logger.info(
"Replaced mod-specific best for user {user_id} | mods={mods} score_id={score_id}",
user_id=user.id,
mods=mod_for_save,
score_id=score.id,
)
playtime, is_valid = calculate_playtime(score, beatmap_length)
if is_valid:
redis = get_redis()
await redis.xadd(f"score:existed_time:{score_token}", {"time": playtime})
statistics.play_count += 1
mouthly_playcount.count += 1
statistics.play_time += playtime
logger.debug(
"Recorded playtime {playtime}s for score {score_id} (user {user_id})",
playtime=playtime,
score_id=score.id,
user_id=user.id,
)
else:
logger.debug(
"Playtime {playtime}s for score {score_id} did not meet validity checks",
playtime=playtime,
score_id=score.id,
)
nlarge_tick_miss = score.nlarge_tick_miss or 0
nsmall_tick_hit = score.nsmall_tick_hit or 0
nlarge_tick_hit = score.nlarge_tick_hit or 0
statistics.count_100 += score.n100 + score.nkatu
statistics.count_300 += score.n300 + score.ngeki
statistics.count_50 += score.n50
statistics.count_miss += score.nmiss
statistics.total_hits += (
score.n300
+ score.n100
+ score.n50
+ score.ngeki
+ score.nkatu
+ nlarge_tick_hit
+ nlarge_tick_miss
+ nsmall_tick_hit
)
if score.gamemode in {GameMode.FRUITS, GameMode.FRUITSRX}:
statistics.count_miss += nlarge_tick_miss
statistics.count_50 += nsmall_tick_hit
statistics.count_100 += nlarge_tick_hit
if score.passed and has_pp:
statistics.pp, statistics.hit_accuracy = await calculate_user_pp(session, statistics.user_id, score.gamemode)
if add_to_db:
session.add(mouthly_playcount)
logger.debug(
"Created monthly playcount record for user {user_id} ({year}-{month})",
user_id=user.id,
year=mouthly_playcount.year,
month=mouthly_playcount.month,
)
async def process_user(
session: AsyncSession,
redis: Redis,
fetcher: "Fetcher",
user: User,
score: Score,
score_token: int,
beatmap_length: int,
beatmap_status: BeatmapRankStatus,
):
score_id = score.id
user_id = user.id
logger.info(
"Processing score {score_id} for user {user_id} on beatmap {beatmap_id}",
score_id=score_id,
user_id=user_id,
beatmap_id=score.beatmap_id,
)
await _process_score_pp(score, session, redis, fetcher)
await session.commit()
await session.refresh(score)
await session.refresh(user)
await _process_statistics(
session,
redis,
user,
score,
score_token,
beatmap_length,
beatmap_status,
)
await redis.publish("osu-channel:score:processed", f'{{"ScoreId": {score_id}}}')
await session.commit()
score_ = (await session.exec(select(Score).where(Score.id == score_id).options(joinedload(Score.beatmap)))).first()
if score_ is None:
logger.warning(
"Score {score_id} disappeared after commit, skipping event processing",
score_id=score_id,
)
return
await _process_score_events(score_, session)
await session.commit()
logger.info(
"Finished processing score {score_id} for user {user_id}",
score_id=score_id,
user_id=user_id,
)