diff --git a/.idea/misc.xml b/.idea/misc.xml index e5787b9..d7ee3e6 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/.idea/osu_lazer_api.iml b/.idea/osu_lazer_api.iml index e2e520d..5e25259 100644 --- a/.idea/osu_lazer_api.iml +++ b/.idea/osu_lazer_api.iml @@ -4,7 +4,7 @@ - + diff --git a/app/database/score.py b/app/database/score.py new file mode 100644 index 0000000..f68f4e8 --- /dev/null +++ b/app/database/score.py @@ -0,0 +1,110 @@ +# ruff: noqa: I002 + +from datetime import datetime +import math +from typing import Literal + +from app.models.score import Rank + +from .beatmap import Beatmap, BeatmapResp +from .beatmapset import Beatmapset, BeatmapsetResp + +from pydantic import BaseModel +from sqlalchemy import Column, DateTime +from sqlmodel import BigInteger, Field, Relationship, SQLModel + + +class ScoreBase(SQLModel): + # 基本字段 + accuracy: float + map_md5: str = Field(max_length=32, index=True) + best_id: int | None = Field(default=None) + 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 + max_combo: int + mods: int = Field(index=True) + 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 + + # optional + # TODO: current_user_attributes + 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): + __tablename__ = "scores" # pyright: ignore[reportAssignmentType] + id: int = Field(primary_key=True) + beatmap_id: int = Field(index=True, foreign_key="beatmap.id") + user_id: int = Field(foreign_key="user.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) + nslider_tail_hit: int | None = Field(default=None, exclude=True) + + # optional + beatmap: "Beatmap" = Relationship(back_populates="scores") + beatmapset: "Beatmapset" = Relationship(back_populates="scores") + # FIXME: user: "User" = Relationship(back_populates="scores") + + +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 + beatmap: BeatmapResp | None = None + beatmapset: BeatmapsetResp | None = None + # FIXME: user: APIUser | None = None + statistics: ScoreStatistics | None = None + + @classmethod + def from_db(cls, score: Score) -> "ScoreResp": + s = cls.model_validate(score) + s.beatmap = BeatmapResp.from_db(score.beatmap) + s.beatmapset = BeatmapsetResp.from_db(score.beatmap.beatmapset) + 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 diff --git a/app/database/user.py b/app/database/user.py index d1a24fb..d10e7dc 100644 --- a/app/database/user.py +++ b/app/database/user.py @@ -1,4 +1,6 @@ # ruff: noqa: I002 +from __future__ import annotations + from dataclasses import dataclass from datetime import datetime from typing import Optional @@ -95,7 +97,6 @@ class User(SQLModel, table=True): back_populates="user" ) - # ============================================ # Lazer API 专用表模型 # ============================================ diff --git a/app/models/score.py b/app/models/score.py index 736a32d..416b68d 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -19,6 +19,19 @@ MODE_TO_INT = { GameMode.FRUITS: 2, GameMode.MANIA: 3, } +INT_TO_MODE = {v: k for k, v in MODE_TO_INT.items()} + + +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): diff --git a/requirements.txt b/requirements.txt index de329af..3e2146d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,17 @@ -fastapi==0.104.1 +fastapi~=0.116.1 uvicorn[standard]==0.24.0 -sqlalchemy==2.0.23 +sqlalchemy~=2.0.41 alembic==1.12.1 +pymysql~=1.1.1 cryptography==41.0.7 -redis==5.0.1 -python-jose[cryptography]==3.3.0 +redis~=6.2.0 +python-jose[cryptography]~=3.5.0 passlib[bcrypt]==1.7.4 python-multipart==0.0.6 -pydantic[email]==2.5.0 -python-dotenv==1.0.0 -bcrypt==4.1.2 +pydantic[email]~=2.11.7 +python-dotenv~=1.1.1 +bcrypt~=4.3.0 +msgpack~=1.1.1 +sqlmodel~=0.0.24 +starlette~=0.47.2 aiomysql==0.2.0