feat(playlist): support leaderboard
**UNTESTED**
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user