fix(score): rewrite DB model & API model
This commit is contained in:
@@ -1,41 +1,104 @@
|
|||||||
from __future__ import annotations
|
# ruff: noqa: I002
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from . import User
|
import math
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from app.models.score import Rank
|
||||||
|
|
||||||
|
from .beatmap import Beatmap
|
||||||
|
from .beatmapset import Beatmapset
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import Column, DateTime
|
from sqlalchemy import Column, DateTime
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import BigInteger, Field, Relationship, SQLModel
|
||||||
|
|
||||||
class Score(SQLModel, table=True):
|
|
||||||
"""
|
|
||||||
成绩数据库模型,对应osu! API中的Score对象
|
|
||||||
参考: https://osu.ppy.sh/docs/index.html#score
|
|
||||||
数据库表结构参考: migrations/base.sql
|
|
||||||
"""
|
|
||||||
__tablename__ = "scores"
|
|
||||||
|
|
||||||
|
class ScoreBase(SQLModel):
|
||||||
# 基本字段
|
# 基本字段
|
||||||
id: int = Field(primary_key=True)
|
accuracy: float
|
||||||
|
beatmap_id: int = Field(index=True, foreign_key="_beatmap.id")
|
||||||
map_md5: str = Field(max_length=32, index=True)
|
map_md5: str = Field(max_length=32, index=True)
|
||||||
score: int
|
best_id: int | None = Field(default=None)
|
||||||
pp: float
|
build_id: int | None = Field(default=None)
|
||||||
acc: float
|
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
|
||||||
max_combo: int
|
max_combo: int
|
||||||
mods: int = Field(index=True)
|
mods: int = Field(index=True)
|
||||||
n300: int
|
passed: bool
|
||||||
|
playlist_item_id: int | None = Field(default=None) # multiplayer
|
||||||
|
pp: float
|
||||||
|
preserve: bool = Field(default=True)
|
||||||
|
rank: Rank
|
||||||
|
room_id: int | None = Field(default=None) # multiplayer
|
||||||
|
ruleset_id: Literal[0, 1, 2, 3] = Field(index=True)
|
||||||
|
started_at: datetime = Field(sa_column=Column(DateTime))
|
||||||
|
total_score: int = Field(default=0, sa_column=Column(BigInteger))
|
||||||
|
type: str
|
||||||
|
user_id: int = Field(foreign_key="user.id", index=True)
|
||||||
|
# ScoreStatistics
|
||||||
|
n300: int = Field(default=0, exclude=True)
|
||||||
n100: int
|
n100: int
|
||||||
n50: int
|
n50: int
|
||||||
nmiss: int
|
nmiss: int
|
||||||
ngeki: int
|
ngeki: int
|
||||||
nkatu: int
|
nkatu: int
|
||||||
grade: str = Field(default="N", max_length=2)
|
nlarge_tick_miss: int | None = Field(default=None, exclude=True)
|
||||||
status: int = Field(index=True)
|
nslider_tail_hit: int | None = Field(default=None, exclude=True)
|
||||||
mode: int = Field(index=True)
|
|
||||||
play_time: datetime = Field(sa_column=Column(DateTime, index=True))
|
|
||||||
time_elapsed: int
|
|
||||||
client_flags: int
|
|
||||||
userid: int = Field(index=True)
|
|
||||||
perfect: bool
|
|
||||||
online_checksum: str = Field(max_length=32, index=True)
|
|
||||||
|
|
||||||
# 关联关系
|
# optional
|
||||||
|
beatmap: "Beatmap" = Relationship(back_populates="scores")
|
||||||
|
beatmapset: "Beatmapset" = Relationship(back_populates="scores")
|
||||||
|
# TODO: current_user_attributes
|
||||||
|
position: int | None = Field(default=None) # multiplayer
|
||||||
user: "User" = Relationship(back_populates="scores")
|
user: "User" = Relationship(back_populates="scores")
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
__tablename__ = "scores" # pyright: ignore[reportAssignmentType]
|
||||||
|
id: int = Field(primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreResp(ScoreBase):
|
||||||
|
id: int
|
||||||
|
is_perfect_combo: bool = False
|
||||||
|
legacy_perfect: bool = False
|
||||||
|
legacy_total_score: int = 0 # FIXME
|
||||||
|
processed: bool = False # solo_score
|
||||||
|
weight: float = 0.0
|
||||||
|
statistics: ScoreStatistics | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_db(cls, score: Score) -> "ScoreResp":
|
||||||
|
s = cls.model_validate(score)
|
||||||
|
s.is_perfect_combo = s.max_combo == s.beatmap.max_combo
|
||||||
|
s.legacy_perfect = s.max_combo == s.beatmap.max_combo
|
||||||
|
if score.best_id:
|
||||||
|
# https://osu.ppy.sh/wiki/Performance_points/Weighting_system
|
||||||
|
s.weight = math.pow(0.95, score.best_id)
|
||||||
|
s.statistics = ScoreStatistics(
|
||||||
|
count_miss=score.nmiss,
|
||||||
|
count_50=score.n50,
|
||||||
|
count_100=score.n100,
|
||||||
|
count_300=score.n300,
|
||||||
|
count_geki=score.ngeki,
|
||||||
|
count_katu=score.nkatu,
|
||||||
|
count_large_tick_miss=score.nlarge_tick_miss,
|
||||||
|
count_slider_tail_hit=score.nslider_tail_hit,
|
||||||
|
)
|
||||||
|
return s
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum, IntEnum
|
from enum import Enum, IntEnum
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime
|
|
||||||
from .user import User
|
|
||||||
|
|
||||||
|
|
||||||
class GameMode(str, Enum):
|
class GameMode(str, Enum):
|
||||||
@@ -23,6 +21,18 @@ MODE_TO_INT = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Rank(str, Enum):
|
||||||
|
X = "ss"
|
||||||
|
XH = "ssh"
|
||||||
|
S = "s"
|
||||||
|
SH = "sh"
|
||||||
|
A = "a"
|
||||||
|
B = "b"
|
||||||
|
C = "c"
|
||||||
|
D = "d"
|
||||||
|
F = "f"
|
||||||
|
|
||||||
|
|
||||||
class APIMod(BaseModel):
|
class APIMod(BaseModel):
|
||||||
acronym: str
|
acronym: str
|
||||||
settings: dict[str, Any] = {}
|
settings: dict[str, Any] = {}
|
||||||
@@ -54,63 +64,3 @@ class HitResult(IntEnum):
|
|||||||
COMBO_BREAK = 16 # [Order(16)]
|
COMBO_BREAK = 16 # [Order(16)]
|
||||||
|
|
||||||
LEGACY_COMBO_INCREASE = 99 # [Order(99)] @deprecated
|
LEGACY_COMBO_INCREASE = 99 # [Order(99)] @deprecated
|
||||||
|
|
||||||
class Score(BaseModel):
|
|
||||||
# 基本信息
|
|
||||||
id: int
|
|
||||||
user_id: int
|
|
||||||
mode: GameMode
|
|
||||||
mode_int: int
|
|
||||||
beatmap_id: int
|
|
||||||
best_id: int
|
|
||||||
build_id: int
|
|
||||||
|
|
||||||
# 分数和准确度
|
|
||||||
score: int
|
|
||||||
accuracy: float
|
|
||||||
mods: list[APIMod]
|
|
||||||
total_score: int
|
|
||||||
|
|
||||||
# 命中统计
|
|
||||||
statistics: dict[HitResult, int]
|
|
||||||
maximum_statistics: dict[HitResult, int]
|
|
||||||
|
|
||||||
# 排名相关
|
|
||||||
rank: str # 等级 (SS, S, A, B, C, D, F)
|
|
||||||
ranked: bool
|
|
||||||
rank_country: Optional[int] = None
|
|
||||||
rank_global: Optional[int] = None
|
|
||||||
|
|
||||||
# PP值
|
|
||||||
pp: Optional[float] = None
|
|
||||||
pp_exp: Optional[float] = None
|
|
||||||
|
|
||||||
# 连击
|
|
||||||
maximum_combo: int
|
|
||||||
combo: int
|
|
||||||
|
|
||||||
# 游戏设置
|
|
||||||
is_perfect_combo: bool
|
|
||||||
passed: bool # 是否通过谱面
|
|
||||||
|
|
||||||
# 时间信息
|
|
||||||
started_at: datetime
|
|
||||||
ended_at: datetime
|
|
||||||
|
|
||||||
# 最佳成绩相关
|
|
||||||
best_id: Optional[int] = None
|
|
||||||
is_best: bool = False
|
|
||||||
|
|
||||||
# 额外信息
|
|
||||||
has_replay: bool # 是否有回放
|
|
||||||
preserve: bool # 是否保留
|
|
||||||
processed: bool # 是否已处理
|
|
||||||
|
|
||||||
# Legacy字段
|
|
||||||
legacy_score_id: Optional[int] = None
|
|
||||||
legacy_total_score: int
|
|
||||||
legacy_perfect: bool
|
|
||||||
|
|
||||||
# mp字段
|
|
||||||
playlist_item_id: Optional[int] = None
|
|
||||||
room_id: Optional[int] = None
|
|
||||||
Reference in New Issue
Block a user