feat(playlist): support leaderboard
**UNTESTED**
This commit is contained in:
@@ -15,7 +15,7 @@ from .lazer_user import (
|
|||||||
User,
|
User,
|
||||||
UserResp,
|
UserResp,
|
||||||
)
|
)
|
||||||
from .playlist_attempts import ItemAttemptsCount
|
from .playlist_attempts import ItemAttemptsCount, ItemAttemptsResp
|
||||||
from .playlist_best_score import PlaylistBestScore
|
from .playlist_best_score import PlaylistBestScore
|
||||||
from .playlists import Playlist, PlaylistResp
|
from .playlists import Playlist, PlaylistResp
|
||||||
from .pp_best_score import PPBestScore
|
from .pp_best_score import PPBestScore
|
||||||
@@ -50,6 +50,7 @@ __all__ = [
|
|||||||
"DailyChallengeStatsResp",
|
"DailyChallengeStatsResp",
|
||||||
"FavouriteBeatmapset",
|
"FavouriteBeatmapset",
|
||||||
"ItemAttemptsCount",
|
"ItemAttemptsCount",
|
||||||
|
"ItemAttemptsResp",
|
||||||
"MultiplayerScores",
|
"MultiplayerScores",
|
||||||
"OAuthToken",
|
"OAuthToken",
|
||||||
"PPBestScore",
|
"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):
|
class ItemAttemptsCountBase(SQLModel):
|
||||||
__tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType]
|
|
||||||
id: int = Field(foreign_key="room_playlists.db_id", primary_key=True, index=True)
|
|
||||||
room_id: int = Field(foreign_key="rooms.id", index=True)
|
room_id: int = Field(foreign_key="rooms.id", index=True)
|
||||||
attempts: int = Field(default=0)
|
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)
|
room_id: int = Field(foreign_key="rooms.id", index=True)
|
||||||
playlist_id: int = Field(foreign_key="room_playlists.id", index=True)
|
playlist_id: int = Field(foreign_key="room_playlists.id", index=True)
|
||||||
total_score: int = Field(default=0, sa_column=Column(BigInteger))
|
total_score: int = Field(default=0, sa_column=Column(BigInteger))
|
||||||
|
attempts: int = Field(default=0) # playlist
|
||||||
|
|
||||||
user: User = Relationship()
|
user: User = Relationship()
|
||||||
score: "Score" = Relationship(
|
score: "Score" = Relationship(
|
||||||
@@ -72,6 +73,7 @@ async def process_playlist_best_score(
|
|||||||
else:
|
else:
|
||||||
previous.score_id = score_id
|
previous.score_id = score_id
|
||||||
previous.total_score = total_score
|
previous.total_score = total_score
|
||||||
|
previous.attempts += 1
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await redis.decr(f"multiplayer:{room_id}:gameplay:players")
|
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 .lazer_user import User, UserResp
|
||||||
from .playlist_attempts import ItemAttemptsCount
|
|
||||||
from .playlists import Playlist, PlaylistResp
|
from .playlists import Playlist, PlaylistResp
|
||||||
|
|
||||||
from sqlmodel import (
|
from sqlmodel import (
|
||||||
@@ -67,13 +66,6 @@ class Room(RoomBase, table=True):
|
|||||||
"overlaps": "room",
|
"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):
|
class RoomResp(RoomBase):
|
||||||
@@ -84,7 +76,6 @@ class RoomResp(RoomBase):
|
|||||||
playlist_item_stats: RoomPlaylistItemStats | None = None
|
playlist_item_stats: RoomPlaylistItemStats | None = None
|
||||||
difficulty_range: RoomDifficultyRange | None = None
|
difficulty_range: RoomDifficultyRange | None = None
|
||||||
current_playlist_item: PlaylistResp | None = None
|
current_playlist_item: PlaylistResp | None = None
|
||||||
playlist_item_attempts: list[ItemAttemptsCount] = []
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_db(cls, room: Room) -> "RoomResp":
|
async def from_db(cls, room: Room) -> "RoomResp":
|
||||||
@@ -112,7 +103,6 @@ class RoomResp(RoomBase):
|
|||||||
resp.playlist_item_stats = stats
|
resp.playlist_item_stats = stats
|
||||||
resp.difficulty_range = difficulty_range
|
resp.difficulty_range = difficulty_range
|
||||||
resp.current_playlist_item = resp.playlist[-1] if resp.playlist else None
|
resp.current_playlist_item = resp.playlist[-1] if resp.playlist else None
|
||||||
# resp.playlist_item_attempts = room.playlist_item_attempts
|
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from time import timezone
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from app.database.lazer_user import User
|
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.playlists import Playlist, PlaylistResp
|
||||||
from app.database.room import Room, RoomBase, RoomResp
|
from app.database.room import Room, RoomBase, RoomResp
|
||||||
from app.dependencies.database import get_db, get_redis
|
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 .api_router import router
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Query
|
from fastapi import Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
from sqlmodel import select
|
from sqlmodel import col, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from starlette.status import HTTP_417_EXPECTATION_FAILED
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rooms", tags=["rooms"], response_model=list[RoomResp])
|
@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
|
return resp
|
||||||
else:
|
else:
|
||||||
raise HTTPException(404, "room not found0")
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from app.calculator import clamp
|
from app.calculator import clamp
|
||||||
from app.database import (
|
from app.database import (
|
||||||
Beatmap,
|
Beatmap,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
Room,
|
||||||
Score,
|
Score,
|
||||||
ScoreResp,
|
ScoreResp,
|
||||||
ScoreToken,
|
ScoreToken,
|
||||||
ScoreTokenResp,
|
ScoreTokenResp,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from app.database.playlist_attempts import ItemAttemptsCount
|
||||||
from app.database.playlist_best_score import (
|
from app.database.playlist_best_score import (
|
||||||
PlaylistBestScore,
|
PlaylistBestScore,
|
||||||
get_position,
|
get_position,
|
||||||
@@ -36,7 +39,6 @@ from app.models.score import (
|
|||||||
Rank,
|
Rank,
|
||||||
SoloScoreSubmissionInfo,
|
SoloScoreSubmissionInfo,
|
||||||
)
|
)
|
||||||
from app.signalr.hub import MultiplayerHubs
|
|
||||||
|
|
||||||
from .api_router import router
|
from .api_router import router
|
||||||
|
|
||||||
@@ -278,9 +280,11 @@ async def create_playlist_score(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
room = MultiplayerHubs.rooms[room_id]
|
room = await session.get(Room, room_id)
|
||||||
if not room:
|
if not room:
|
||||||
raise HTTPException(status_code=404, detail="Room not found")
|
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 = (
|
item = (
|
||||||
await session.exec(
|
await session.exec(
|
||||||
select(Playlist).where(
|
select(Playlist).where(
|
||||||
@@ -301,7 +305,18 @@ async def create_playlist_score(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="Beatmap ID mismatch in playlist item"
|
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:
|
if item.expired:
|
||||||
raise HTTPException(status_code=400, detail="Playlist item has expired")
|
raise HTTPException(status_code=400, detail="Playlist item has expired")
|
||||||
if item.played_at:
|
if item.played_at:
|
||||||
@@ -342,6 +357,8 @@ async def submit_playlist_score(
|
|||||||
).first()
|
).first()
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Playlist item not found")
|
raise HTTPException(status_code=404, detail="Playlist item not found")
|
||||||
|
|
||||||
|
user_id = current_user.id
|
||||||
score_resp = await submit_score(
|
score_resp = await submit_score(
|
||||||
info,
|
info,
|
||||||
item.beatmap_id,
|
item.beatmap_id,
|
||||||
@@ -356,12 +373,13 @@ async def submit_playlist_score(
|
|||||||
await process_playlist_best_score(
|
await process_playlist_best_score(
|
||||||
room_id,
|
room_id,
|
||||||
playlist_id,
|
playlist_id,
|
||||||
current_user.id,
|
user_id,
|
||||||
score_resp.id,
|
score_resp.id,
|
||||||
score_resp.total_score,
|
score_resp.total_score,
|
||||||
session,
|
session,
|
||||||
redis,
|
redis,
|
||||||
)
|
)
|
||||||
|
await ItemAttemptsCount.get_or_create(room_id, user_id, session)
|
||||||
return score_resp
|
return score_resp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user