diff --git a/app/database/__init__.py b/app/database/__init__.py index b3e65f9..dbfd3b8 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -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", diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py index 5b4710a..da49981 100644 --- a/app/database/playlist_attempts.py +++ b/app/database/playlist_attempts.py @@ -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 diff --git a/app/database/playlist_best_score.py b/app/database/playlist_best_score.py index 49fb459..46bbfba 100644 --- a/app/database/playlist_best_score.py +++ b/app/database/playlist_best_score.py @@ -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") diff --git a/app/database/room.py b/app/database/room.py index 08f1466..e01dece 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -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 diff --git a/app/router/room.py b/app/router/room.py index 1c51753..d5bc713 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -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, + ) diff --git a/app/router/score.py b/app/router/score.py index 818155d..5db171d 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -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