feat(solo-score): support submit solo scores
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
47
app/database/score_token.py
Normal file
47
app/database/score_token.py
Normal 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)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
7
app/path.py
Normal 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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
5
static/README.md
Normal 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
3656
static/mods.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user