feat(score): update statistics & return user in score

This commit is contained in:
MingxuanGame
2025-07-29 07:36:33 +00:00
parent 223fa99692
commit 70399a2e50
6 changed files with 377 additions and 73 deletions

View File

@@ -1,12 +1,26 @@
from __future__ import annotations from __future__ import annotations
from app.database.score import Score import math
from typing import TYPE_CHECKING
from app.models.beatmap import BeatmapAttributes from app.models.beatmap import BeatmapAttributes
from app.models.mods import APIMod from app.models.mods import APIMod
from app.models.score import GameMode from app.models.score import GameMode
import rosu_pp_py as rosu import rosu_pp_py as rosu
if TYPE_CHECKING:
from app.database.score import Score
def clamp[T: int | float](n: T, min_value: T, max_value: T) -> T:
if n < min_value:
return min_value
elif n > max_value:
return max_value
else:
return n
def calculate_beatmap_attribute( def calculate_beatmap_attribute(
beatmap: str, beatmap: str,
@@ -32,7 +46,7 @@ def calculate_beatmap_attribute(
def calculate_pp( def calculate_pp(
score: Score, score: "Score",
beatmap: str, beatmap: str,
) -> float: ) -> float:
map = rosu.Beatmap(content=beatmap) map = rosu.Beatmap(content=beatmap)
@@ -57,3 +71,34 @@ def calculate_pp(
) )
attrs = perf.calculate(map) attrs = perf.calculate(map)
return attrs.pp return attrs.pp
# https://osu.ppy.sh/wiki/Gameplay/Score/Total_score
def calculate_level_to_score(level: int) -> float:
if level <= 100:
# 55 = 4^3 - 3^2
return 5000 / 3 * (55 - level) + 1.25 * math.pow(1.8, level - 60)
else:
return 26_931_190_827 + 99_999_999_999 * (level - 100)
def calculate_score_to_level(score: float) -> int:
if score < 5000:
return int(55 - (3 * score / 5000)) # 55 = 4^3 - 3^2
elif score < 26_931_190_827:
return int(60 + math.log(score / 1.25, 1.8))
else:
return int((score - 26_931_190_827) / 99_999_999_999 + 100)
# https://osu.ppy.sh/wiki/Performance_points/Weighting_system
def calculate_pp_weight(index: int) -> float:
return math.pow(0.95, index)
def calculate_weighted_pp(pp: float, index: int) -> float:
return calculate_pp_weight(index) * pp if pp > 0 else 0.0
def calculate_weighted_acc(acc: float, index: int) -> float:
return calculate_pp_weight(index) * acc if acc > 0 else 0.0

View File

@@ -7,6 +7,7 @@ from .beatmapset import (
Beatmapset as Beatmapset, Beatmapset as Beatmapset,
BeatmapsetResp as BeatmapsetResp, BeatmapsetResp as BeatmapsetResp,
) )
from .best_score import BestScore
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 ( from .score import (
@@ -44,6 +45,7 @@ __all__ = [
"BeatmapResp", "BeatmapResp",
"Beatmapset", "Beatmapset",
"BeatmapsetResp", "BeatmapsetResp",
"BestScore",
"DailyChallengeStats", "DailyChallengeStats",
"LazerUserAchievement", "LazerUserAchievement",
"LazerUserBadge", "LazerUserBadge",

View File

@@ -0,0 +1,41 @@
from typing import TYPE_CHECKING
from app.models.score import GameMode
from .user import User
from sqlmodel import (
BigInteger,
Column,
Field,
Float,
ForeignKey,
Relationship,
SQLModel,
)
if TYPE_CHECKING:
from .beatmap import Beatmap
from .score import Score
class BestScore(SQLModel, table=True):
__tablename__ = "best_scores" # pyright: ignore[reportAssignmentType]
user_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("users.id"), index=True)
)
score_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("scores.id"), primary_key=True)
)
beatmap_id: int = Field(foreign_key="beatmaps.id", index=True)
gamemode: GameMode = Field(index=True)
pp: float = Field(
sa_column=Column(Float, default=0),
)
acc: float = Field(
sa_column=Column(Float, default=0),
)
user: User = Relationship()
score: "Score" = Relationship()
beatmap: "Beatmap" = Relationship()

View File

@@ -1,21 +1,38 @@
from datetime import datetime import asyncio
from collections.abc import Sequence
from datetime import UTC, datetime
import math import math
from typing import TYPE_CHECKING
from app.database.user import User from app.calculator import (
calculate_pp,
calculate_pp_weight,
calculate_score_to_level,
calculate_weighted_acc,
calculate_weighted_pp,
clamp,
)
from app.database.score_token import ScoreToken
from app.database.user import LazerUserStatistics, User
from app.models.beatmap import BeatmapRankStatus from app.models.beatmap import BeatmapRankStatus
from app.models.mods import APIMod from app.models.mods import APIMod, mods_can_get_pp
from app.models.score import ( from app.models.score import (
INT_TO_MODE,
MODE_TO_INT, MODE_TO_INT,
GameMode, GameMode,
HitResult, HitResult,
LeaderboardType, LeaderboardType,
Rank, Rank,
ScoreStatistics, ScoreStatistics,
SoloScoreSubmissionInfo,
) )
from app.models.user import User as APIUser
from .beatmap import Beatmap, BeatmapResp from .beatmap import Beatmap, BeatmapResp
from .beatmapset import Beatmapset, BeatmapsetResp from .beatmapset import Beatmapset, BeatmapsetResp
from .best_score import BestScore
from redis import Redis
from sqlalchemy import Column, ColumnExpressionArgument, DateTime from sqlalchemy import Column, ColumnExpressionArgument, DateTime
from sqlalchemy.orm import aliased, joinedload from sqlalchemy.orm import aliased, joinedload
from sqlmodel import ( from sqlmodel import (
@@ -33,6 +50,9 @@ from sqlmodel import (
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql._expression_select_cls import SelectOfScalar from sqlmodel.sql._expression_select_cls import SelectOfScalar
if TYPE_CHECKING:
from app.fetcher import Fetcher
class ScoreBase(SQLModel): class ScoreBase(SQLModel):
# 基本字段 # 基本字段
@@ -101,15 +121,17 @@ class Score(ScoreBase, table=True):
return self.max_combo == self.beatmap.max_combo return self.max_combo == self.beatmap.max_combo
@staticmethod @staticmethod
def select_clause() -> SelectOfScalar["Score"]: def select_clause(with_user: bool = True) -> SelectOfScalar["Score"]:
return select(Score).options( clause = select(Score).options(
joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] joinedload(Score.beatmap) # pyright: ignore[reportArgumentType]
.joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType]
.selectinload( .selectinload(
Beatmapset.beatmaps # pyright: ignore[reportArgumentType] Beatmapset.beatmaps # pyright: ignore[reportArgumentType]
), ),
joinedload(Score.user).joinedload(User.lazer_profile), # pyright: ignore[reportArgumentType]
) )
if with_user:
clause.options(joinedload(Score.user).options(*User.all_select_option())) # pyright: ignore[reportArgumentType]
return clause
@staticmethod @staticmethod
def select_clause_unique( def select_clause_unique(
@@ -133,7 +155,7 @@ class Score(ScoreBase, table=True):
.selectinload( .selectinload(
Beatmapset.beatmaps # pyright: ignore[reportArgumentType] Beatmapset.beatmaps # pyright: ignore[reportArgumentType]
), ),
joinedload(best.user).joinedload(User.lazer_profile), # pyright: ignore[reportArgumentType] joinedload(best.user).options(*User.all_select_option()), # pyright: ignore[reportArgumentType]
) )
) )
@@ -149,13 +171,18 @@ class ScoreResp(ScoreBase):
ruleset_id: int | None = None ruleset_id: int | None = None
beatmap: BeatmapResp | None = None beatmap: BeatmapResp | None = None
beatmapset: BeatmapsetResp | None = None beatmapset: BeatmapsetResp | None = None
# FIXME: user: APIUser | None = None user: APIUser | None = None
statistics: ScoreStatistics | None = None statistics: ScoreStatistics | None = None
maximum_statistics: ScoreStatistics | None = None
rank_global: int | None = None rank_global: int | None = None
rank_country: int | None = None rank_country: int | None = None
@classmethod @classmethod
async def from_db(cls, session: AsyncSession, score: Score) -> "ScoreResp": async def from_db(
cls, session: AsyncSession, score: Score, user: User | None = None
) -> "ScoreResp":
from app.utils import convert_db_user_to_api_user
s = cls.model_validate(score.model_dump()) s = cls.model_validate(score.model_dump())
assert score.id assert score.id
s.beatmap = BeatmapResp.from_db(score.beatmap) s.beatmap = BeatmapResp.from_db(score.beatmap)
@@ -164,8 +191,7 @@ class ScoreResp(ScoreBase):
s.legacy_perfect = s.max_combo == s.beatmap.max_combo s.legacy_perfect = s.max_combo == s.beatmap.max_combo
s.ruleset_id = MODE_TO_INT[score.gamemode] s.ruleset_id = MODE_TO_INT[score.gamemode]
if score.best_id: if score.best_id:
# https://osu.ppy.sh/wiki/Performance_points/Weighting_system s.weight = calculate_pp_weight(score.best_id)
s.weight = math.pow(0.95, score.best_id)
s.statistics = { s.statistics = {
HitResult.MISS: score.nmiss, HitResult.MISS: score.nmiss,
HitResult.MEH: score.n50, HitResult.MEH: score.n50,
@@ -182,7 +208,16 @@ class ScoreResp(ScoreBase):
s.statistics[HitResult.SMALL_TICK_HIT] = score.nsmall_tick_hit s.statistics[HitResult.SMALL_TICK_HIT] = score.nsmall_tick_hit
if score.nlarge_tick_hit is not None: if score.nlarge_tick_hit is not None:
s.statistics[HitResult.LARGE_TICK_HIT] = score.nlarge_tick_hit s.statistics[HitResult.LARGE_TICK_HIT] = score.nlarge_tick_hit
# s.user = await convert_db_user_to_api_user(score.user) if score.gamemode == GameMode.MANIA:
s.maximum_statistics = {
HitResult.PERFECT: score.beatmap.max_combo,
}
else:
s.maximum_statistics = {
HitResult.GREAT: score.beatmap.max_combo,
}
if user:
s.user = await convert_db_user_to_api_user(user)
s.rank_global = ( s.rank_global = (
await get_score_position_by_id( await get_score_position_by_id(
session, session,
@@ -387,3 +422,207 @@ async def get_score_position_by_id(
result = await session.exec(stmt) result = await session.exec(stmt)
s = result.one_or_none() s = result.one_or_none()
return s if s else 0 return s if s else 0
async def get_user_best_score_in_beatmap(
session: AsyncSession,
beatmap: int,
user: int,
mode: GameMode | None = None,
) -> Score | None:
return (
await session.exec(
Score.select_clause(False)
.where(
Score.gamemode == mode if mode is not None else True,
Score.beatmap_id == beatmap,
Score.user_id == user,
)
.order_by(col(Score.total_score).desc())
)
).first()
async def get_user_best_pp_in_beatmap(
session: AsyncSession,
beatmap: int,
user: int,
mode: GameMode,
) -> BestScore | None:
return (
await session.exec(
select(BestScore).where(
BestScore.beatmap_id == beatmap,
BestScore.user_id == user,
BestScore.gamemode == mode,
)
)
).first()
async def get_user_best_pp(
session: AsyncSession,
user: int,
limit: int = 200,
) -> Sequence[BestScore]:
return (
await session.exec(
select(BestScore)
.where(BestScore.user_id == user)
.order_by(col(BestScore.pp).desc())
.limit(limit)
)
).all()
async def process_user(
session: AsyncSession, user: User, score: Score, ranked: bool = False
):
previous_score_best = await get_user_best_score_in_beatmap(
session, score.beatmap_id, user.id, score.gamemode
)
statistics = None
add_to_db = False
for i in user.lazer_statistics:
if i.mode == score.gamemode.value:
statistics = i
break
if statistics is None:
statistics = LazerUserStatistics(
mode=score.gamemode.value,
user_id=user.id,
)
add_to_db = True
# pc, pt, tth, tts
statistics.total_score += score.total_score
difference = (
score.total_score - previous_score_best.total_score
if previous_score_best and previous_score_best.id != score.id
else score.total_score
)
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.ranked_score)
statistics.maximum_combo = max(statistics.maximum_combo, score.max_combo)
statistics.play_count += 1
statistics.play_time += int((score.ended_at - score.started_at).total_seconds())
statistics.total_hits += (
score.n300 + score.n100 + score.n50 + score.ngeki + score.nkatu
)
if score.passed and ranked:
best_pp_scores = await get_user_best_pp(session, user.id)
pp_sum = 0.0
acc_sum = 0.0
for i, bp in enumerate(best_pp_scores):
pp_sum += calculate_weighted_pp(bp.pp, i)
acc_sum += calculate_weighted_acc(bp.acc, i)
if len(best_pp_scores):
# 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(best_pp_scores))))
acc_sum = clamp(acc_sum, 0.0, 100.0)
statistics.pp = pp_sum
statistics.hit_accuracy = acc_sum
statistics.updated_at = datetime.now(UTC)
if add_to_db:
session.add(statistics)
await session.commit()
await session.refresh(user)
async def process_score(
user: User,
beatmap_id: int,
ranked: bool,
score_token: ScoreToken,
info: SoloScoreSubmissionInfo,
fetcher: "Fetcher",
session: AsyncSession,
redis: Redis,
) -> Score:
score = Score(
accuracy=info.accuracy,
max_combo=info.max_combo,
# maximum_statistics=info.maximum_statistics,
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=datetime.now(UTC),
gamemode=INT_TO_MODE[info.ruleset_id],
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),
)
if info.passed and ranked and mods_can_get_pp(info.ruleset_id, info.mods):
beatmap_raw = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id)
pp = await asyncio.get_event_loop().run_in_executor(
None, calculate_pp, score, beatmap_raw
)
score.pp = pp
session.add(score)
user_id = user.id
await session.commit()
await session.refresh(score)
if score.passed and ranked:
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:
assert score.id
best_score = BestScore(
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)
session.delete(previous_pp_best) if previous_pp_best else None
await session.commit()
await session.refresh(score)
await session.refresh(score_token)
await session.refresh(user)
return score

View File

@@ -44,6 +44,16 @@ class Rank(str, Enum):
D = "D" D = "D"
F = "F" F = "F"
@property
def in_statisctics(self):
return self in {
Rank.X,
Rank.XH,
Rank.S,
Rank.SH,
Rank.A,
}
# 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(str, Enum): class HitResult(str, Enum):

View File

@@ -1,24 +1,18 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import datetime
from app.calculator import calculate_pp
from app.database import ( from app.database import (
User as DBUser, User as DBUser,
) )
from app.database.beatmap import Beatmap from app.database.beatmap import Beatmap
from app.database.score import Score, ScoreResp from app.database.score import Score, ScoreResp, process_score, process_user
from app.database.score_token import ScoreToken, ScoreTokenResp from app.database.score_token import ScoreToken, ScoreTokenResp
from app.dependencies.database import get_db, get_redis 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.models.beatmap import BeatmapRankStatus from app.models.beatmap import BeatmapRankStatus
from app.models.mods import mods_can_get_pp
from app.models.score import ( from app.models.score import (
INT_TO_MODE, INT_TO_MODE,
GameMode, GameMode,
HitResult,
Rank, Rank,
SoloScoreSubmissionInfo, SoloScoreSubmissionInfo,
) )
@@ -77,8 +71,10 @@ async def get_beatmap_scores(
).first() ).first()
return BeatmapScores( return BeatmapScores(
scores=[await ScoreResp.from_db(db, score) for score in all_scores], scores=[await ScoreResp.from_db(db, score, score.user) for score in all_scores],
userScore=await ScoreResp.from_db(db, user_score) if user_score else None, userScore=await ScoreResp.from_db(db, user_score, user_score.user)
if user_score
else None,
) )
@@ -107,7 +103,7 @@ async def get_user_beatmap_score(
) )
user_score = ( user_score = (
await db.exec( await db.exec(
Score.select_clause() Score.select_clause(True)
.where( .where(
Score.gamemode == mode if mode is not None else True, Score.gamemode == mode if mode is not None else True,
Score.beatmap_id == beatmap, Score.beatmap_id == beatmap,
@@ -124,7 +120,7 @@ async def get_user_beatmap_score(
else: else:
return BeatmapUserScore( return BeatmapUserScore(
position=user_score.position if user_score.position is not None else 0, position=user_score.position if user_score.position is not None else 0,
score=await ScoreResp.from_db(db, user_score), score=await ScoreResp.from_db(db, user_score, user_score.user),
) )
@@ -157,7 +153,9 @@ async def get_user_all_beatmap_scores(
) )
).all() ).all()
return [await ScoreResp.from_db(db, score) for score in all_user_scores] return [
await ScoreResp.from_db(db, score, current_user) for score in all_user_scores
]
@router.post( @router.post(
@@ -230,57 +228,26 @@ async def submit_solo_score(
).first() ).first()
if beatmap_status is None: if beatmap_status is None:
raise HTTPException(status_code=404, detail="Beatmap not found") raise HTTPException(status_code=404, detail="Beatmap not found")
score = Score( ranked = beatmap_status in {
accuracy=info.accuracy, BeatmapRankStatus.RANKED,
max_combo=info.max_combo, BeatmapRankStatus.APPROVED,
# maximum_statistics=info.maximum_statistics, }
mods=info.mods, score = await process_score(
passed=info.passed, current_user,
rank=info.rank, beatmap,
total_score=info.total_score, ranked,
total_score_without_mods=info.total_score_without_mods, score_token,
beatmap_id=beatmap, info,
ended_at=datetime.datetime.now(datetime.UTC), fetcher,
gamemode=INT_TO_MODE[info.ruleset_id], db,
started_at=score_token.created_at, redis,
user_id=current_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),
) )
if ( await db.refresh(current_user)
info.passed
and beatmap_status
in {
BeatmapRankStatus.RANKED,
BeatmapRankStatus.APPROVED,
}
and mods_can_get_pp(info.ruleset_id, info.mods)
):
beatmap_raw = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap)
pp = await asyncio.get_event_loop().run_in_executor(
None, calculate_pp, score, beatmap_raw
)
score.pp = pp
db.add(score)
await db.commit()
await db.refresh(score)
score_id = score.id score_id = score.id
score_token.score_id = score_id score_token.score_id = score_id
await db.commit() await process_user(db, current_user, score, ranked)
score = ( score = (
await db.exec(Score.select_clause().where(Score.id == score_id)) await db.exec(Score.select_clause().where(Score.id == score_id))
).first() ).first()
assert score is not None assert score is not None
return await ScoreResp.from_db(db, score) return await ScoreResp.from_db(db, score, current_user)