feat(playlist): support leaderboard

**UNTESTED**
This commit is contained in:
MingxuanGame
2025-08-07 14:52:02 +00:00
parent 18d16e2542
commit bc2961de10
6 changed files with 175 additions and 23 deletions

View File

@@ -15,7 +15,7 @@ from .lazer_user import (
User,
UserResp,
)
from .playlist_attempts import ItemAttemptsCount
from .playlist_attempts import ItemAttemptsCount, ItemAttemptsResp
from .playlist_best_score import PlaylistBestScore
from .playlists import Playlist, PlaylistResp
from .pp_best_score import PPBestScore
@@ -50,6 +50,7 @@ __all__ = [
"DailyChallengeStatsResp",
"FavouriteBeatmapset",
"ItemAttemptsCount",
"ItemAttemptsResp",
"MultiplayerScores",
"OAuthToken",
"PPBestScore",

View File

@@ -1,9 +1,116 @@
from sqlmodel import Field, SQLModel
from .lazer_user import User, UserResp
from .playlist_best_score import PlaylistBestScore
from sqlmodel import (
BigInteger,
Column,
Field,
ForeignKey,
Relationship,
SQLModel,
col,
func,
select,
)
from sqlmodel.ext.asyncio.session import AsyncSession
class ItemAttemptsCount(SQLModel, table=True):
__tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType]
id: int = Field(foreign_key="room_playlists.db_id", primary_key=True, index=True)
class ItemAttemptsCountBase(SQLModel):
room_id: int = Field(foreign_key="rooms.id", index=True)
attempts: int = Field(default=0)
passed: int = Field(default=0)
completed: int = Field(default=0)
user_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
)
accuracy: float = 0.0
pp: float = 0
total_score: int = 0
class ItemAttemptsCount(ItemAttemptsCountBase, table=True):
__tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType]
id: int | None = Field(
default=None, foreign_key="room_playlists.db_id", primary_key=True
)
user: User = Relationship()
async def get_position(self, session: AsyncSession) -> int:
rownum = (
func.row_number()
.over(
partition_by=col(ItemAttemptsCountBase.room_id),
order_by=col(ItemAttemptsCountBase.total_score).desc(),
)
.label("rn")
)
subq = select(ItemAttemptsCountBase, rownum).subquery()
stmt = select(subq.c.rn).where(subq.c.user_id == self.user_id)
result = await session.exec(stmt)
return result.one()
async def update(self, session: AsyncSession):
playlist_scores = (
await session.exec(
select(PlaylistBestScore).where(
PlaylistBestScore.room_id == self.room_id,
PlaylistBestScore.user_id == self.user_id,
)
)
).all()
self.attempts = sum(score.attempts for score in playlist_scores)
self.total_score = sum(score.total_score for score in playlist_scores)
self.pp = sum(score.score.pp for score in playlist_scores)
self.completed = len(playlist_scores)
self.accuracy = (
sum(score.score.accuracy * score.attempts for score in playlist_scores)
/ self.completed
if self.completed > 0
else 0.0
)
await session.commit()
await session.refresh(self)
@classmethod
async def get_or_create(
cls,
room_id: int,
user_id: int,
session: AsyncSession,
) -> "ItemAttemptsCount":
item_attempts = await session.exec(
select(cls).where(
cls.room_id == room_id,
cls.user_id == user_id,
)
)
item_attempts = item_attempts.first()
if item_attempts is None:
item_attempts = cls(room_id=room_id, user_id=user_id)
session.add(item_attempts)
await session.commit()
await session.refresh(item_attempts)
await item_attempts.update(session)
return item_attempts
class ItemAttemptsResp(ItemAttemptsCountBase):
user: UserResp | None = None
position: int | None = None
@classmethod
async def from_db(
cls,
item_attempts: ItemAttemptsCount,
session: AsyncSession,
include: list[str] = [],
) -> "ItemAttemptsResp":
resp = cls.model_validate(item_attempts)
resp.user = await UserResp.from_db(
item_attempts.user,
session=session,
include=["statistics", "team", "daily_challenge_user_stats"],
)
if "position" in include:
resp.position = await item_attempts.get_position(session)
return resp

View File

@@ -32,6 +32,7 @@ class PlaylistBestScore(SQLModel, table=True):
room_id: int = Field(foreign_key="rooms.id", index=True)
playlist_id: int = Field(foreign_key="room_playlists.id", index=True)
total_score: int = Field(default=0, sa_column=Column(BigInteger))
attempts: int = Field(default=0) # playlist
user: User = Relationship()
score: "Score" = Relationship(
@@ -72,6 +73,7 @@ async def process_playlist_best_score(
else:
previous.score_id = score_id
previous.total_score = total_score
previous.attempts += 1
await session.commit()
await redis.decr(f"multiplayer:{room_id}:gameplay:players")

View File

@@ -11,7 +11,6 @@ from app.models.room import (
)
from .lazer_user import User, UserResp
from .playlist_attempts import ItemAttemptsCount
from .playlists import Playlist, PlaylistResp
from sqlmodel import (
@@ -67,13 +66,6 @@ class Room(RoomBase, table=True):
"overlaps": "room",
}
)
# playlist_item_attempts: list["ItemAttemptsCount"] = Relationship(
# sa_relationship_kwargs={
# "lazy": "joined",
# "cascade": "all, delete-orphan",
# "primaryjoin": "ItemAttemptsCount.room_id == Room.id",
# }
# )
class RoomResp(RoomBase):
@@ -84,7 +76,6 @@ class RoomResp(RoomBase):
playlist_item_stats: RoomPlaylistItemStats | None = None
difficulty_range: RoomDifficultyRange | None = None
current_playlist_item: PlaylistResp | None = None
playlist_item_attempts: list[ItemAttemptsCount] = []
@classmethod
async def from_db(cls, room: Room) -> "RoomResp":
@@ -112,7 +103,6 @@ class RoomResp(RoomBase):
resp.playlist_item_stats = stats
resp.difficulty_range = difficulty_range
resp.current_playlist_item = resp.playlist[-1] if resp.playlist else None
# resp.playlist_item_attempts = room.playlist_item_attempts
return resp

View File

@@ -1,10 +1,10 @@
from __future__ import annotations
from datetime import UTC, datetime
from time import timezone
from typing import Literal
from app.database.lazer_user import User
from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp
from app.database.playlists import Playlist, PlaylistResp
from app.database.room import Room, RoomBase, RoomResp
from app.dependencies.database import get_db, get_redis
@@ -22,10 +22,10 @@ from app.signalr.hub import MultiplayerHubs
from .api_router import router
from fastapi import Depends, HTTPException, Query
from pydantic import BaseModel, Field
from redis.asyncio import Redis
from sqlmodel import select
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from starlette.status import HTTP_417_EXPECTATION_FAILED
@router.get("/rooms", tags=["rooms"], response_model=list[RoomResp])
@@ -144,3 +144,37 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_
return resp
else:
raise HTTPException(404, "room not found0")
class APILeaderboard(BaseModel):
leaderboard: list[ItemAttemptsResp] = Field(default_factory=list)
user_score: ItemAttemptsResp | None = None
@router.get("/rooms/{room}/leaderboard", tags=["room"], response_model=APILeaderboard)
async def get_room_leaderboard(
room: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
server_room = MultiplayerHubs.rooms[room]
if not server_room:
raise HTTPException(404, "Room not found")
aggs = await db.exec(
select(ItemAttemptsCount)
.where(ItemAttemptsCount.room_id == room)
.order_by(col(ItemAttemptsCount.total_score).desc())
)
aggs_resp = []
user_agg = None
for i, agg in enumerate(aggs):
resp = await ItemAttemptsResp.from_db(agg, db)
resp.position = i + 1
aggs_resp.append(resp)
if agg.user_id == current_user.id:
user_agg = resp
return APILeaderboard(
leaderboard=aggs_resp,
user_score=user_agg,
)

View File

@@ -1,17 +1,20 @@
from __future__ import annotations
from datetime import UTC, datetime
import time
from app.calculator import clamp
from app.database import (
Beatmap,
Playlist,
Room,
Score,
ScoreResp,
ScoreToken,
ScoreTokenResp,
User,
)
from app.database.playlist_attempts import ItemAttemptsCount
from app.database.playlist_best_score import (
PlaylistBestScore,
get_position,
@@ -36,7 +39,6 @@ from app.models.score import (
Rank,
SoloScoreSubmissionInfo,
)
from app.signalr.hub import MultiplayerHubs
from .api_router import router
@@ -278,9 +280,11 @@ async def create_playlist_score(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
room = MultiplayerHubs.rooms[room_id]
room = await session.get(Room, room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
if room.ended_at and room.ended_at < datetime.now(UTC):
raise HTTPException(status_code=400, detail="Room has ended")
item = (
await session.exec(
select(Playlist).where(
@@ -301,7 +305,18 @@ async def create_playlist_score(
raise HTTPException(
status_code=400, detail="Beatmap ID mismatch in playlist item"
)
# TODO: max attempts
agg = await session.exec(
select(ItemAttemptsCount).where(
ItemAttemptsCount.room_id == room_id,
ItemAttemptsCount.user_id == current_user.id,
)
)
agg = agg.first()
if agg and room.max_attempts and agg.attempts >= room.max_attempts:
raise HTTPException(
status_code=422,
detail="You have reached the maximum attempts for this room",
)
if item.expired:
raise HTTPException(status_code=400, detail="Playlist item has expired")
if item.played_at:
@@ -342,6 +357,8 @@ async def submit_playlist_score(
).first()
if not item:
raise HTTPException(status_code=404, detail="Playlist item not found")
user_id = current_user.id
score_resp = await submit_score(
info,
item.beatmap_id,
@@ -356,12 +373,13 @@ async def submit_playlist_score(
await process_playlist_best_score(
room_id,
playlist_id,
current_user.id,
user_id,
score_resp.id,
score_resp.total_score,
session,
redis,
)
await ItemAttemptsCount.get_or_create(room_id, user_id, session)
return score_resp