From d16a2ac1b506007c4240ce3a66a8ea444f81a9bc Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Sun, 27 Jul 2025 15:04:30 +0000 Subject: [PATCH 01/65] =?UTF-8?q?feat(multiplayer):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=88=BF=E9=97=B4=E7=94=A8=E6=88=B7=E6=B7=BB=E5=8A=A0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96=E6=88=BF=E9=97=B4=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 APIUser 模型用于表示房间内的用户 - 扩展 MultiplayerRoom 模型以支持更多房间相关功能 - 添加用户加入房间的路由和相关逻辑 - 优化 Room 模型,增加从 MultiplayerRoom 转换的方法 --- app/models/room.py | 140 +++++++++++++++++++++++++++++++++++++++++++-- app/models/user.py | 4 ++ app/router/room.py | 36 +++++++++++- 3 files changed, 174 insertions(+), 6 deletions(-) diff --git a/app/models/room.py b/app/models/room.py index 9ca24d2..793635e 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -1,11 +1,12 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from enum import Enum +from typing import Any from app.database.beatmap import Beatmap -from app.database.user import User from app.models.mods import APIMod +from app.models.user import APIUser from pydantic import BaseModel @@ -40,6 +41,75 @@ class RoomStatus(str, Enum): PLAYING = "playing" +class MultiplayerRoomState(str, Enum): + OPEN = "open" + WAITING_FOR_LOAD = "waiting_for_load" + PLAYING = "playing" + CLOSED = "closed" + + +class MultiplayerUserState(str, Enum): + IDLE = "idle" + READY = "ready" + WAITING_FOR_LOAD = "waiting_for_load" + LOADED = "loaded" + READY_FOR_GAMEPLAY = "ready_for_gameplay" + PLAYING = "playing" + FINISHED_PLAY = "finished_play" + RESULTS = "results" + SPECTATING = "spectating" + + +class DownloadState(str, Enum): + UNKONWN = "unkown" + NOT_DOWNLOADED = "not_downloaded" + DOWNLOADING = "downloading" + IMPORTING = "importing" + LOCALLY_AVAILABLE = "locally_available" + + +class BeatmapAvailability(BaseModel): + state: DownloadState + downloadProgress: float | None + + +class MultiplayerPlaylistItem(BaseModel): + id: int = 0 + ownerId: int = 0 + beatmapId: int = 0 + beatmapChecksum: str = "" + rulesetId: int = 0 + requiredMods: list[APIMod] = [] + allowedMods: list[APIMod] = [] + expired: bool = False + playlistOrder: int = 0 + playedAt: datetime | None + starRating: float = 0.0 + freestyle: bool = False + + +class MultiplayerRoomSettings(BaseModel): + name: str = "Unnamed room" + playlistItemId: int = 0 + password: str = "" + matchType: MatchType = MatchType.HEAD_TO_HEAD + queueMode: QueueMode = QueueMode.HOST_ONLY + autoStartDuration: timedelta = timedelta(0) + autoSkip: bool = False + + +class MultiplayerRoomUser(BaseModel): + id: int + state: MultiplayerUserState = MultiplayerUserState.IDLE + beatmapAvailability: BeatmapAvailability = BeatmapAvailability( + state=DownloadState.UNKONWN, downloadProgress=None + ) + mods: list[APIMod] = [] + matchState: dict[str, Any] | None + rulesetId: int | None + beatmapId: int | None + + class PlaylistItem(BaseModel): id: int | None owner_id: int @@ -75,18 +145,23 @@ class PlaylistAggregateScore(BaseModel): playlist_item_attempts: list[ItemAttemptsCount] +class MultiplayerCountdown(BaseModel): + id: int + timeRemaining: timedelta + + class Room(BaseModel): id: int | None name: str = "" password: str | None has_password: bool = False - host: User | None + host: APIUser category: RoomCategory = RoomCategory.NORMAL duration: int | None starts_at: datetime | None ends_at: datetime | None participant_count: int = 0 - recent_participants: list[User] = [] + recent_participants: list[APIUser] = [] max_attempts: int | None playlist: list[PlaylistItem] = [] playlist_item_stats: RoomPlaylistItemStats | None @@ -101,3 +176,60 @@ class Room(BaseModel): status: RoomStatus = RoomStatus.IDLE # availability 字段在当前序列化中未包含,但可能在某些场景下需要 availability: RoomAvailability | None + + @classmethod + def from_MultiplayerRoom(cls, room: MultiplayerRoom): + r = cls.model_validate(room.model_dump()) + r.id = room.roomId + r.name = room.settings.name + r.password = room.settings.password + r.has_password = bool(room.settings.password) + if room.host: + r.host.id = room.host.id + r.type = room.settings.matchType + r.queue_mode = room.settings.queueMode + r.auto_start_duration = room.settings.autoStartDuration.seconds + r.auto_skip = room.settings.autoSkip + r.channel_id = room.channelId + if room.state == MultiplayerRoomState.OPEN: + r.status = RoomStatus.IDLE + elif ( + room.state == MultiplayerRoomState.WAITING_FOR_LOAD + or room.state == MultiplayerRoomState.PLAYING + ): + r.status = RoomStatus.PLAYING + elif room.state == MultiplayerRoomState.CLOSED: + r.status = RoomStatus.IDLE + r.ends_at = datetime.utcnow() + playlist_items = [] + for multiplayer_item in room.playlist: + playlist_item = PlaylistItem( + id=multiplayer_item.id, + owner_id=multiplayer_item.ownerId, + ruleset_id=multiplayer_item.rulesetId, + expired=multiplayer_item.expired, + playlist_order=multiplayer_item.playlistOrder, + played_at=multiplayer_item.playedAt, + freestyle=multiplayer_item.freestyle, + beatmap_id=multiplayer_item.beatmapId, + beatmap=None, + ) + playlist_items.append(playlist_item) + r.playlist = playlist_items + r.participant_count = len(playlist_items) + return r + + +class MultiplayerRoom(BaseModel): + roomId: int + state: MultiplayerRoomState = MultiplayerRoomState.OPEN + settings: MultiplayerRoomSettings = MultiplayerRoomSettings() + users: list[MultiplayerRoomUser] = [] + host: MultiplayerRoomUser | None + matchState: dict[str, Any] | None + playlist: list[MultiplayerPlaylistItem] = [] + activeCountdowns: list[MultiplayerCountdown] = [] + channelId: int = 0 + + def __init__(self, roomId: int, **data): + super().__init__(roomId=roomId, **data) diff --git a/app/models/user.py b/app/models/user.py index 42251e9..0f5dab9 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -209,3 +209,7 @@ class User(BaseModel): replays_watched_counts: list[dict] = [] team: Team | None = None user_achievements: list[UserAchievement] = [] + + +class APIUser(BaseModel): + id: int diff --git a/app/router/room.py b/app/router/room.py index ed540fc..ebd8326 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -2,7 +2,11 @@ from __future__ import annotations from app.database.room import RoomIndex from app.dependencies.database import get_db, get_redis -from app.models.room import Room +from app.models.room import ( + MultiplayerRoom, + MultiplayerRoomUser, + Room, +) from .api_router import router @@ -27,9 +31,37 @@ async def get_all_rooms( for room_index in all_room_ids: dumped_room = redis.get(str(room_index.id)) if dumped_room: - actual_room = Room.model_validate_json(str(dumped_room)) + actual_room = MultiplayerRoom.model_validate_json(str(dumped_room)) + actual_room = Room.from_MultiplayerRoom(actual_room) if actual_room.status == status and actual_room.category == category: roomsList.append(actual_room) return roomsList else: raise HTTPException(status_code=500, detail="Redis Error") + + +@router.put("/rooms/{room}/users/{user}", tags=["rooms"], response_model=Room) +async def add_user_to_room( + room: int, user: int, password: str, db: AsyncSession = Depends(dependency=get_db) +): + redis = get_redis() + if redis: + dumped_room = redis.get(str(room)) + if not dumped_room: + raise HTTPException(status_code=404, detail="房间不存在") + actual_room = MultiplayerRoom.model_validate_json(str(dumped_room)) + + # 验证密码 + if password != actual_room.settings.password: + raise HTTPException(status_code=403, detail="Invalid password") + + # 继续处理加入房间的逻辑 + actual_room.users.append( + MultiplayerRoomUser( + id=user, matchState=None, rulesetId=None, beatmapId=None + ) + ) + actual_room = Room.from_MultiplayerRoom(actual_room) + return actual_room + else: + raise HTTPException(status_code=500, detail="Redis Error") From 605ad934ccc623843478b42086073c8ed0fffc35 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Tue, 29 Jul 2025 06:17:56 +0000 Subject: [PATCH 02/65] =?UTF-8?q?refactor(multiplayer):=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=88=BF=E9=97=B4=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/room.py | 297 ++++++++++++++++++++++++++------------------- 1 file changed, 169 insertions(+), 128 deletions(-) diff --git a/app/models/room.py b/app/models/room.py index 793635e..f8a4a73 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -2,13 +2,16 @@ from __future__ import annotations from datetime import datetime, timedelta from enum import Enum -from typing import Any -from app.database.beatmap import Beatmap +from app.database.beatmap import Beatmap, BeatmapResp +from app.database.user import User as DBUser +from app.fetcher import Fetcher from app.models.mods import APIMod -from app.models.user import APIUser +from app.models.user import User +from app.utils import convert_db_user_to_api_user from pydantic import BaseModel +from sqlmodel.ext.asyncio.session import AsyncSession class RoomCategory(str, Enum): @@ -61,57 +64,15 @@ class MultiplayerUserState(str, Enum): class DownloadState(str, Enum): - UNKONWN = "unkown" + UNKOWN = "unkown" NOT_DOWNLOADED = "not_downloaded" DOWNLOADING = "downloading" IMPORTING = "importing" LOCALLY_AVAILABLE = "locally_available" -class BeatmapAvailability(BaseModel): - state: DownloadState - downloadProgress: float | None - - -class MultiplayerPlaylistItem(BaseModel): - id: int = 0 - ownerId: int = 0 - beatmapId: int = 0 - beatmapChecksum: str = "" - rulesetId: int = 0 - requiredMods: list[APIMod] = [] - allowedMods: list[APIMod] = [] - expired: bool = False - playlistOrder: int = 0 - playedAt: datetime | None - starRating: float = 0.0 - freestyle: bool = False - - -class MultiplayerRoomSettings(BaseModel): - name: str = "Unnamed room" - playlistItemId: int = 0 - password: str = "" - matchType: MatchType = MatchType.HEAD_TO_HEAD - queueMode: QueueMode = QueueMode.HOST_ONLY - autoStartDuration: timedelta = timedelta(0) - autoSkip: bool = False - - -class MultiplayerRoomUser(BaseModel): - id: int - state: MultiplayerUserState = MultiplayerUserState.IDLE - beatmapAvailability: BeatmapAvailability = BeatmapAvailability( - state=DownloadState.UNKONWN, downloadProgress=None - ) - mods: list[APIMod] = [] - matchState: dict[str, Any] | None - rulesetId: int | None - beatmapId: int | None - - class PlaylistItem(BaseModel): - id: int | None + id: int owner_id: int ruleset_id: int expired: bool @@ -120,9 +81,12 @@ class PlaylistItem(BaseModel): allowed_mods: list[APIMod] = [] required_mods: list[APIMod] = [] beatmap_id: int - beatmap: Beatmap | None + beatmap: BeatmapResp | None freestyle: bool + class Config: + exclude_none = True + class RoomPlaylistItemStats(BaseModel): count_active: int @@ -145,91 +109,168 @@ class PlaylistAggregateScore(BaseModel): playlist_item_attempts: list[ItemAttemptsCount] -class MultiplayerCountdown(BaseModel): - id: int - timeRemaining: timedelta +class MultiplayerRoomSettings(BaseModel): + Name: str = "Unnamed Room" + PlaylistItemId: int + Password: str = "" + MatchType: MatchType + QueueMode: QueueMode + AutoStartDuration: timedelta + AutoSkip: bool -class Room(BaseModel): - id: int | None - name: str = "" - password: str | None - has_password: bool = False - host: APIUser - category: RoomCategory = RoomCategory.NORMAL - duration: int | None - starts_at: datetime | None - ends_at: datetime | None - participant_count: int = 0 - recent_participants: list[APIUser] = [] - max_attempts: int | None - playlist: list[PlaylistItem] = [] - playlist_item_stats: RoomPlaylistItemStats | None - difficulty_range: RoomDifficultyRange | None - type: MatchType = MatchType.PLAYLISTS - queue_mode: QueueMode = QueueMode.HOST_ONLY - auto_skip: bool = False - auto_start_duration: int = 0 - current_user_score: PlaylistAggregateScore | None - current_playlist_item: PlaylistItem | None - channel_id: int = 0 - status: RoomStatus = RoomStatus.IDLE - # availability 字段在当前序列化中未包含,但可能在某些场景下需要 - availability: RoomAvailability | None +class BeatmapAvailability(BaseModel): + State: DownloadState + DownloadProgress: float | None + + +class MatchUserState(BaseModel): + class Config: + extra = "allow" + + +class TeamVersusState(MatchUserState): + TeamId: int + + +MatchUserStateType = TeamVersusState | MatchUserState + + +class MultiplayerRoomUser(BaseModel): + UserID: int + State: MultiplayerUserState = MultiplayerUserState.IDLE + BeatmapAvailability: BeatmapAvailability + Mods: list[APIMod] = [] + MatchUserState: MatchUserStateType | None + RulesetId: int | None + BeatmapId: int | None + User: User | None @classmethod - def from_MultiplayerRoom(cls, room: MultiplayerRoom): - r = cls.model_validate(room.model_dump()) - r.id = room.roomId - r.name = room.settings.name - r.password = room.settings.password - r.has_password = bool(room.settings.password) - if room.host: - r.host.id = room.host.id - r.type = room.settings.matchType - r.queue_mode = room.settings.queueMode - r.auto_start_duration = room.settings.autoStartDuration.seconds - r.auto_skip = room.settings.autoSkip - r.channel_id = room.channelId - if room.state == MultiplayerRoomState.OPEN: - r.status = RoomStatus.IDLE - elif ( - room.state == MultiplayerRoomState.WAITING_FOR_LOAD - or room.state == MultiplayerRoomState.PLAYING - ): - r.status = RoomStatus.PLAYING - elif room.state == MultiplayerRoomState.CLOSED: - r.status = RoomStatus.IDLE - r.ends_at = datetime.utcnow() - playlist_items = [] - for multiplayer_item in room.playlist: - playlist_item = PlaylistItem( - id=multiplayer_item.id, - owner_id=multiplayer_item.ownerId, - ruleset_id=multiplayer_item.rulesetId, - expired=multiplayer_item.expired, - playlist_order=multiplayer_item.playlistOrder, - played_at=multiplayer_item.playedAt, - freestyle=multiplayer_item.freestyle, - beatmap_id=multiplayer_item.beatmapId, - beatmap=None, + async def from_id(cls, id: int, db: AsyncSession): + actualUser = ( + await db.exec( + DBUser.all_select_clause().where( + DBUser.id == id, + ) ) - playlist_items.append(playlist_item) - r.playlist = playlist_items - r.participant_count = len(playlist_items) - return r + ).first() + user = ( + await convert_db_user_to_api_user(actualUser) + if actualUser is not None + else None + ) + return MultiplayerRoomUser( + UserID=id, + MatchUserState=None, + BeatmapAvailability=BeatmapAvailability( + State=DownloadState.UNKOWN, DownloadProgress=None + ), + RulesetId=None, + BeatmapId=None, + User=user, + ) + + +class MatchRoomState(BaseModel): + class Config: + extra = "allow" + + +class MultiPlayerTeam(BaseModel): + id: int = 0 + name: str = "" + + +class TeamVersusRoomState(BaseModel): + teams: list[MultiPlayerTeam] = [] + + class Config: + pass + + @classmethod + def create_default(cls): + return cls( + teams=[ + MultiPlayerTeam(id=0, name="Team Red"), + MultiPlayerTeam(id=1, name="Team Blue"), + ] + ) + + +MatchRoomStateType = TeamVersusRoomState | MatchRoomState + + +class MultiPlayerListItem(BaseModel): + id: int + OwnerID: int + BeatmapID: int + BeatmapChecksum: str = "" + RulesetID: int + RequierdMods: list[APIMod] + AllowedMods: list[APIMod] + Expired: bool + PlaylistOrder: int | None + PlayedAt: datetime | None + StarRating: float + Freestyle: bool + + @classmethod + async def from_apiItem(cls, item: PlaylistItem, db: AsyncSession, fetcher: Fetcher): + s = cls.model_validate(item.model_dump()) + s.id = item.id + s.OwnerID = item.owner_id + if item.beatmap is None: # 从客户端接受的一定没有这字段 + cur_beatmap = await Beatmap.get_or_fetch( + db, fetcher=fetcher, bid=item.beatmap_id + ) + s.BeatmapID = cur_beatmap.id if cur_beatmap.id is not None else 0 + s.BeatmapChecksum = cur_beatmap.checksum + s.StarRating = cur_beatmap.difficulty_rating + s.RulesetID = item.ruleset_id + s.RequierdMods = item.required_mods + s.AllowedMods = item.allowed_mods + s.Expired = item.expired + s.PlaylistOrder = item.playlist_order if item.playlist_order is not None else 0 + s.PlayedAt = item.played_at + s.Freestyle = item.freestyle + + +class MultiplayerCountdown(BaseModel): + id: int = 0 + time_remaining: timedelta = timedelta(seconds=0) + is_exclusive: bool = True + + class Config: + extra = "allow" + + +class MatchStartCountdown(MultiplayerCountdown): + pass + + +class ForceGameplayStartCountdown(MultiplayerCountdown): + pass + + +class ServerShuttingCountdown(MultiplayerCountdown): + pass + + +MultiplayerCountdownType = ( + MatchStartCountdown + | ForceGameplayStartCountdown + | ServerShuttingCountdown + | MultiplayerCountdown +) class MultiplayerRoom(BaseModel): - roomId: int - state: MultiplayerRoomState = MultiplayerRoomState.OPEN - settings: MultiplayerRoomSettings = MultiplayerRoomSettings() - users: list[MultiplayerRoomUser] = [] - host: MultiplayerRoomUser | None - matchState: dict[str, Any] | None - playlist: list[MultiplayerPlaylistItem] = [] - activeCountdowns: list[MultiplayerCountdown] = [] - channelId: int = 0 - - def __init__(self, roomId: int, **data): - super().__init__(roomId=roomId, **data) + RoomId: int + State: MultiplayerRoomState + Users: list[MultiplayerRoomUser] + Host: MultiplayerRoomUser + MatchState: MatchRoomState | None + Playlist: list[MultiPlayerListItem] + ActivecCountDowns: list[MultiplayerCountdownType] + ChannelID: int From 6d736528e3fc8903fd65fc70d29874b7a87a8c1a Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Tue, 29 Jul 2025 07:05:44 +0000 Subject: [PATCH 03/65] =?UTF-8?q?refactor(multiplayer):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E6=88=BF=E9=97=B4=E6=A8=A1=E5=9E=8B=E5=B9=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 Room 模型,增加多个新字段和方法 - 新增 PlaylistItem 和 MultiplayerRoom 类 - 优化 MultiPlayerListItem 类,添加 from_mpListItem 方法 - 调整 Beatmap 类,将 id 字段标记为非可选 --- app/database/beatmap.py | 2 +- app/models/room.py | 90 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/app/database/beatmap.py b/app/database/beatmap.py index 48e7fa0..b28473e 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -62,7 +62,7 @@ class BeatmapBase(SQLModel): class Beatmap(BeatmapBase, table=True): __tablename__ = "beatmaps" # pyright: ignore[reportAssignmentType] - id: int | None = Field(default=None, primary_key=True, index=True) + id: int = Field(primary_key=True, index=True) beatmapset_id: int = Field(foreign_key="beatmapsets.id", index=True) beatmap_status: BeatmapRankStatus # optional diff --git a/app/models/room.py b/app/models/room.py index f8a4a73..8b07878 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -10,7 +10,7 @@ from app.models.mods import APIMod from app.models.user import User from app.utils import convert_db_user_to_api_user -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlmodel.ext.asyncio.session import AsyncSession @@ -87,6 +87,27 @@ class PlaylistItem(BaseModel): class Config: exclude_none = True + @classmethod + async def from_mpListItem( + cls, item: MultiPlayerListItem, db: AsyncSession, fetcher: Fetcher + ): + s = cls.model_validate(item.model_dump()) + s.id = item.id + s.owner_id = item.OwnerID + s.ruleset_id = item.RulesetID + s.expired = item.Expired + s.playlist_order = item.PlaylistOrder + s.played_at = item.PlayedAt + s.required_mods = item.RequierdMods + s.allowed_mods = item.AllowedMods + s.freestyle = item.Freestyle + cur_beatmap = await Beatmap.get_or_fetch( + db, fetcher=fetcher, bid=item.BeatmapID + ) + s.beatmap = BeatmapResp.from_db(cur_beatmap) + s.beatmap_id = item.BeatmapID + return s + class RoomPlaylistItemStats(BaseModel): count_active: int @@ -234,6 +255,7 @@ class MultiPlayerListItem(BaseModel): s.PlaylistOrder = item.playlist_order if item.playlist_order is not None else 0 s.PlayedAt = item.played_at s.Freestyle = item.freestyle + return s class MultiplayerCountdown(BaseModel): @@ -265,12 +287,78 @@ MultiplayerCountdownType = ( ) +class PlaylistStatus(BaseModel): + count_active: int + count_total: int + ruleset_ids: list[int] + + class MultiplayerRoom(BaseModel): RoomId: int State: MultiplayerRoomState + Settings: MultiplayerRoomSettings = MultiplayerRoomSettings( + PlaylistItemId=0, + MatchType=MatchType.HEAD_TO_HEAD, + QueueMode=QueueMode.HOST_ONLY, + AutoStartDuration=timedelta(0), + AutoSkip=False, + ) Users: list[MultiplayerRoomUser] Host: MultiplayerRoomUser MatchState: MatchRoomState | None Playlist: list[MultiPlayerListItem] ActivecCountDowns: list[MultiplayerCountdownType] ChannelID: int + + @classmethod + def CanAddPlayistItem(cls, user: MultiplayerRoomUser) -> bool: + return user == cls.Host or cls.Settings.QueueMode != QueueMode.HOST_ONLY + + +class Room(BaseModel): + room_id: int + name: str + password: str | None + has_password: bool = Field(exclude=True) + host: User | None + category: RoomCategory + duration: int | None + starts_at: datetime | None + ends_at: datetime | None + max_particapants: int | None = Field(exclude=True) + particapant_count: int + recent_particapants: list[User] + type: MatchType + max_attempts: int | None + playlist: list[PlaylistItem] + playlist_item_status: list[RoomPlaylistItemStats] + difficulity_range: RoomDifficultyRange + queue_mode: QueueMode + auto_skip: bool + auto_start_duration: int + current_user_score: PlaylistAggregateScore | None + current_playlist_item: PlaylistItem | None + channel_id: int + status: RoomStatus + availability: RoomAvailability = Field(exclude=True) + + class Config: + exclude_none = True + + @classmethod + async def from_mpRoom( + cls, room: MultiplayerRoom, db: AsyncSession, fetcher: Fetcher + ): + s = cls.model_validate(room.model_dump()) + s.room_id = room.RoomId + s.name = room.Settings.Name + s.password = room.Settings.Password + s.type = room.Settings.MatchType + s.queue_mode = room.Settings.QueueMode + s.auto_skip = room.Settings.AutoSkip + s.host = room.Host.User + s.playlist = [ + await PlaylistItem.from_mpListItem(item, db, fetcher) + for item in room.Playlist + ] + return s From 9402eaece6e246468fa0375110717d80016306ef Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Tue, 29 Jul 2025 07:53:34 +0000 Subject: [PATCH 04/65] =?UTF-8?q?refactor(room):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=88=BF=E9=97=B4=E7=9B=B8=E5=85=B3=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化了房间列表获取逻辑,增加了对房间状态的筛选 - 重构了单个房间获取路由,提高了代码可读性和性能 - 移除了未使用的导入和冗余代码,提高了代码整洁度 - 增加了对 Redis 的错误处理,提高了系统稳定性 --- app/router/room.py | 82 ++++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index ebd8326..b19b6f9 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -2,14 +2,11 @@ from __future__ import annotations from app.database.room import RoomIndex from app.dependencies.database import get_db, get_redis -from app.models.room import ( - MultiplayerRoom, - MultiplayerRoomUser, - Room, -) - -from .api_router import router +from app.dependencies.fetcher import get_fetcher +from app.fetcher import Fetcher +from app.models.room import MultiplayerRoom, MultiplayerRoomState, Room +from api_router import router from fastapi import Depends, HTTPException, Query from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -17,51 +14,52 @@ from sqlmodel.ext.asyncio.session import AsyncSession @router.get("/rooms", tags=["rooms"], response_model=list[Room]) async def get_all_rooms( - mode: str = Query( - None - ), # TODO: lazer源码显示房间不会是除了open以外的其他状态,先放在这里 + mode: str = Query(None), # TODO: 对房间根据状态进行筛选 status: str = Query(None), - category: str = Query(None), + category: str = Query(None), # TODO: 对房间根据分类进行筛选(真的有人用这功能吗) db: AsyncSession = Depends(get_db), + fetcher: Fetcher = Depends(get_fetcher), ): - all_room_ids = (await db.exec(select(RoomIndex).where(True))).all() + all_roomID = (await db.exec(select(RoomIndex))).all() redis = get_redis() - roomsList: list[Room] = [] - if redis: - for room_index in all_room_ids: - dumped_room = redis.get(str(room_index.id)) - if dumped_room: - actual_room = MultiplayerRoom.model_validate_json(str(dumped_room)) - actual_room = Room.from_MultiplayerRoom(actual_room) - if actual_room.status == status and actual_room.category == category: - roomsList.append(actual_room) - return roomsList + if redis is not None: + resp: list[Room] = [] + for id in all_roomID: + dumped_room = redis.get(str(id)) + validated_room = MultiplayerRoom.model_validate_json(str(dumped_room)) + flag: bool = False + if validated_room.State == MultiplayerRoomState.OPEN and status == "idle": + flag = True + elif validated_room != MultiplayerRoomState.CLOSED: + flag = True + if flag: + resp.append( + await Room.from_mpRoom( + MultiplayerRoom.model_validate_json(str(dumped_room)), + db, + fetcher, + ) + ) + return resp else: raise HTTPException(status_code=500, detail="Redis Error") -@router.put("/rooms/{room}/users/{user}", tags=["rooms"], response_model=Room) -async def add_user_to_room( - room: int, user: int, password: str, db: AsyncSession = Depends(dependency=get_db) +@router.get("/rooms/{room}", tags=["room"], response_model=Room) +async def get_room( + room: int, + db: AsyncSession = Depends(get_db), + fetcher: Fetcher = Depends(get_fetcher), ): redis = get_redis() if redis: - dumped_room = redis.get(str(room)) - if not dumped_room: - raise HTTPException(status_code=404, detail="房间不存在") - actual_room = MultiplayerRoom.model_validate_json(str(dumped_room)) - - # 验证密码 - if password != actual_room.settings.password: - raise HTTPException(status_code=403, detail="Invalid password") - - # 继续处理加入房间的逻辑 - actual_room.users.append( - MultiplayerRoomUser( - id=user, matchState=None, rulesetId=None, beatmapId=None + dumped_room = str(redis.get(str(room))) + if dumped_room is not None: + resp = await Room.from_mpRoom( + MultiplayerRoom.model_validate_json(str(dumped_room)), db, fetcher ) - ) - actual_room = Room.from_MultiplayerRoom(actual_room) - return actual_room + return resp + else: + raise HTTPException(status_code=404, detail="Room Not Found") else: - raise HTTPException(status_code=500, detail="Redis Error") + raise HTTPException(status_code=500, detail="Redis error") From 804700d5022778899a073f858436e3eca84fa273 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Tue, 29 Jul 2025 14:57:30 +0000 Subject: [PATCH 05/65] =?UTF-8?q?feat(room):=20=E6=B7=BB=E5=8A=A0=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E6=88=BF=E9=97=B4=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=88=BF=E9=97=B4=E8=8E=B7=E5=8F=96=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 room 路由中添加 POST 请求处理,用于创建新房间 - 实现 MultiplayerRoom 和 MultiplayerRoomSettings 的 from_apiRoom 方法 - 优化 get_all_rooms 接口,增加对 status 参数的处理 - 调整 RoomIndex 表结构,将 id 字段类型改为 int --- app/database/room.py | 2 +- app/models/room.py | 24 ++++++++++++++++++ app/router/room.py | 58 +++++++++++++++++++++++++++++++++----------- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/app/database/room.py b/app/database/room.py index 7a1aff8..0b79ee6 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -3,4 +3,4 @@ from sqlmodel import Field, SQLModel class RoomIndex(SQLModel, table=True): __tablename__ = "mp_room_index" # pyright: ignore[reportAssignmentType] - id: int | None = Field(default=None, primary_key=True, index=True) # pyright: ignore[reportCallIssue] + id: int = Field(default=None, primary_key=True, index=True) # pyright: ignore[reportCallIssue] diff --git a/app/models/room.py b/app/models/room.py index 8b07878..2d01a26 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -139,6 +139,17 @@ class MultiplayerRoomSettings(BaseModel): AutoStartDuration: timedelta AutoSkip: bool + @classmethod + def from_apiRoom(cls, room: Room): + s = cls.model_validate(room.model_dump()) + s.Name = room.name + s.Password = room.password if room.password is not None else "" + s.MatchType = room.type + s.QueueMode = room.queue_mode + s.AutoStartDuration = timedelta(seconds=room.auto_start_duration) + s.AutoSkip = room.auto_skip + return s + class BeatmapAvailability(BaseModel): State: DownloadState @@ -314,6 +325,19 @@ class MultiplayerRoom(BaseModel): def CanAddPlayistItem(cls, user: MultiplayerRoomUser) -> bool: return user == cls.Host or cls.Settings.QueueMode != QueueMode.HOST_ONLY + @classmethod + async def from_apiRoom(cls, room: Room, db: AsyncSession, fetcher: Fetcher): + s = cls.model_validate(room.model_dump()) + s.RoomId = room.room_id if room.room_id is not None else 0 + s.ChannelID = room.channel_id + s.Settings = MultiplayerRoomSettings.from_apiRoom(room) + s.Host = await MultiplayerRoomUser.from_id(room.host.id if room.host else 0, db) + s.Playlist = [ + await MultiPlayerListItem.from_apiItem(item, db, fetcher) + for item in room.playlist + ] + return s + class Room(BaseModel): room_id: int diff --git a/app/router/room.py b/app/router/room.py index b19b6f9..385e992 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -14,9 +14,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession @router.get("/rooms", tags=["rooms"], response_model=list[Room]) async def get_all_rooms( - mode: str = Query(None), # TODO: 对房间根据状态进行筛选 - status: str = Query(None), - category: str = Query(None), # TODO: 对房间根据分类进行筛选(真的有人用这功能吗) + mode: str | None = Query(None), # TODO: 对房间根据状态进行筛选 + status: str | None = Query(None), + category: str | None = Query( + None + ), # TODO: 对房间根据分类进行筛选(真的有人用这功能吗) db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), ): @@ -28,18 +30,22 @@ async def get_all_rooms( dumped_room = redis.get(str(id)) validated_room = MultiplayerRoom.model_validate_json(str(dumped_room)) flag: bool = False - if validated_room.State == MultiplayerRoomState.OPEN and status == "idle": - flag = True - elif validated_room != MultiplayerRoomState.CLOSED: - flag = True - if flag: - resp.append( - await Room.from_mpRoom( - MultiplayerRoom.model_validate_json(str(dumped_room)), - db, - fetcher, + if status is not None: + if ( + validated_room.State == MultiplayerRoomState.OPEN + and status == "idle" + ): + flag = True + elif validated_room != MultiplayerRoomState.CLOSED: + flag = True + if flag: + resp.append( + await Room.from_mpRoom( + MultiplayerRoom.model_validate_json(str(dumped_room)), + db, + fetcher, + ) ) - ) return resp else: raise HTTPException(status_code=500, detail="Redis Error") @@ -63,3 +69,27 @@ async def get_room( raise HTTPException(status_code=404, detail="Room Not Found") else: raise HTTPException(status_code=500, detail="Redis error") + + +class APICreatedRoom(Room): + error: str | None + + +@router.post("/rooms", tags=["beatmap"], response_model=APICreatedRoom) +async def create_room( + room: Room, + db: AsyncSession = Depends(get_db), + fetcher: Fetcher = Depends(get_fetcher), +): + redis = get_redis() + if redis: + room_index = RoomIndex() + db.add(room_index) + await db.commit() + await db.refresh(room_index) + server_room = await MultiplayerRoom.from_apiRoom(room, db, fetcher) + redis.set(str(room_index.id), server_room.model_dump_json()) + room.room_id = room_index.id + return APICreatedRoom(**room.model_dump(), error=None) + else: + raise HTTPException(status_code=500, detail="redis error") From 1f8211ec3070f4ec47f14c6b7acedcb998bc6bb0 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Tue, 29 Jul 2025 15:05:44 +0000 Subject: [PATCH 06/65] =?UTF-8?q?feat(room):=20=E6=B7=BB=E5=8A=A0=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=88=BF=E9=97=B4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了删除房间的 API 接口 - 删除房间时,同时从 Redis 和数据库中移除相关数据 --- app/router/room.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/router/room.py b/app/router/room.py index 385e992..6e6f4a1 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -93,3 +93,14 @@ async def create_room( return APICreatedRoom(**room.model_dump(), error=None) else: raise HTTPException(status_code=500, detail="redis error") + + +@router.delete("/rooms/{room}", tags=["room"]) +async def remove_room(room: int, db: AsyncSession = Depends(get_db)): + redis = get_redis() + if redis: + redis.delete(str(room)) + room_index = await db.get(RoomIndex, room) + if room_index: + await db.delete(room_index) + await db.commit() From d399cb52e261571fb946bb0b6e809023f932bd6d Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 1 Aug 2025 11:00:57 +0000 Subject: [PATCH 07/65] fix(signarl): wrong msgpack encode --- app/models/signalr.py | 46 +++++++++++++++++-- app/signalr/packet.py | 2 +- .../msgpack_lazer_api/msgpack_lazer_api.pyi | 2 +- packages/msgpack_lazer_api/src/decode.rs | 4 +- packages/msgpack_lazer_api/src/encode.rs | 10 ++-- 5 files changed, 50 insertions(+), 14 deletions(-) diff --git a/app/models/signalr.py b/app/models/signalr.py index 37b2741..202da4f 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -1,10 +1,12 @@ from __future__ import annotations import datetime -from typing import Any, get_origin +from enum import Enum +from typing import Any from pydantic import ( BaseModel, + BeforeValidator, ConfigDict, Field, TypeAdapter, @@ -17,22 +19,56 @@ def serialize_to_list(value: BaseModel) -> list[Any]: data = [] for field, info in value.__class__.model_fields.items(): v = getattr(value, field) - anno = get_origin(info.annotation) - if anno and issubclass(anno, BaseModel): + typ = v.__class__ + if issubclass(typ, BaseModel): data.append(serialize_to_list(v)) - elif anno and issubclass(anno, list): + elif issubclass(typ, list): data.append( TypeAdapter( info.annotation, config=ConfigDict(arbitrary_types_allowed=True) ).dump_python(v) ) - elif isinstance(v, datetime.datetime): + elif issubclass(typ, datetime.datetime): data.append([v, 0]) + elif issubclass(typ, Enum): + list_ = list(typ) + data.append(list_.index(v) if v in list_ else v.value) else: data.append(v) return data +def _by_index(v: Any, class_: type[Enum]): + enum_list = list(class_) + if not isinstance(v, int): + return v + if 0 <= v < len(enum_list): + return enum_list[v] + raise ValueError( + f"Value {v} is out of range for enum " + f"{class_.__name__} with {len(enum_list)} items" + ) + + +def EnumByIndex(enum_class: type[Enum]) -> BeforeValidator: + return BeforeValidator(lambda v: _by_index(v, enum_class)) + + +def msgpack_union(v): + data = v[1] + data.append(v[0]) + return data + + +def msgpack_union_dump(v: BaseModel) -> list[Any]: + _type = getattr(v, "type", None) + if _type is None: + raise ValueError( + f"Model {v.__class__.__name__} does not have a '_type' attribute" + ) + return [_type, serialize_to_list(v)] + + class MessagePackArrayModel(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index e361ef8..387231c 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -158,7 +158,7 @@ class MsgpackProtocol: result_kind = 2 if packet.error: result_kind = 1 - elif packet.result is None: + elif packet.result is not None: result_kind = 3 payload.extend( [ diff --git a/packages/msgpack_lazer_api/msgpack_lazer_api.pyi b/packages/msgpack_lazer_api/msgpack_lazer_api.pyi index 88b79c5..b8653f0 100644 --- a/packages/msgpack_lazer_api/msgpack_lazer_api.pyi +++ b/packages/msgpack_lazer_api/msgpack_lazer_api.pyi @@ -5,7 +5,7 @@ class APIMod: @property def acronym(self) -> str: ... @property - def settings(self) -> str: ... + def settings(self) -> dict[str, Any]: ... def encode(obj: Any) -> bytes: ... def decode(data: bytes) -> Any: ... diff --git a/packages/msgpack_lazer_api/src/decode.rs b/packages/msgpack_lazer_api/src/decode.rs index 15156ca..b8e239b 100644 --- a/packages/msgpack_lazer_api/src/decode.rs +++ b/packages/msgpack_lazer_api/src/decode.rs @@ -13,6 +13,8 @@ pub fn read_object( match rmp::decode::read_marker(cursor) { Ok(marker) => match marker { rmp::Marker::Null => Ok(py.None()), + rmp::Marker::True => Ok(true.into_py_any(py)?), + rmp::Marker::False => Ok(false.into_py_any(py)?), rmp::Marker::FixPos(val) => Ok(val.into_pyobject(py)?.into_any().unbind()), rmp::Marker::FixNeg(val) => Ok(val.into_pyobject(py)?.into_any().unbind()), rmp::Marker::U8 => { @@ -86,8 +88,6 @@ pub fn read_object( cursor.read_exact(&mut data).map_err(to_py_err)?; Ok(data.into_pyobject(py)?.into_any().unbind()) } - rmp::Marker::True => Ok(true.into_py_any(py)?), - rmp::Marker::False => Ok(false.into_py_any(py)?), rmp::Marker::FixStr(len) => read_string(py, cursor, len as u32), rmp::Marker::Str8 => { let mut buf = [0u8; 1]; diff --git a/packages/msgpack_lazer_api/src/encode.rs b/packages/msgpack_lazer_api/src/encode.rs index 88a732b..0e0907c 100644 --- a/packages/msgpack_lazer_api/src/encode.rs +++ b/packages/msgpack_lazer_api/src/encode.rs @@ -110,12 +110,12 @@ pub fn write_object(buf: &mut Vec, obj: &Bound<'_, PyAny>) { write_list(buf, list); } else if let Ok(string) = obj.downcast::() { write_string(buf, string); - } else if let Ok(integer) = obj.downcast::() { - write_integer(buf, integer); - } else if let Ok(float) = obj.downcast::() { - write_float(buf, float); } else if let Ok(boolean) = obj.downcast::() { - write_bool(buf, boolean); + write_bool(buf, boolean); + } else if let Ok(float) = obj.downcast::() { + write_float(buf, float); + } else if let Ok(integer) = obj.downcast::() { + write_integer(buf, integer); } else if let Ok(bytes) = obj.downcast::() { write_bin(buf, bytes); } else if let Ok(dict) = obj.downcast::() { From a25cb852d9cd0a39b7b4beb44ab2edd70538d09e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 1 Aug 2025 11:08:59 +0000 Subject: [PATCH 08/65] feat(multiplay): support `CreateRoom` hub method --- app/database/__init__.py | 8 + app/database/playlist_attempts.py | 9 + app/database/playlists.py | 85 ++++++++ app/database/room.py | 137 ++++++++++++- app/models/mods.py | 14 +- app/models/multiplayer_hub.py | 168 ++++++++++++++++ app/models/room.py | 313 +----------------------------- app/router/__init__.py | 8 +- app/router/room.py | 143 ++++++-------- app/signalr/hub/multiplayer.py | 101 +++++++++- main.py | 7 +- 11 files changed, 590 insertions(+), 403 deletions(-) create mode 100644 app/database/playlist_attempts.py create mode 100644 app/database/playlists.py create mode 100644 app/models/multiplayer_hub.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 6e2e8c5..2c01f7a 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -15,8 +15,11 @@ from .lazer_user import ( User, UserResp, ) +from .playlist_attempts import ItemAttemptsCount +from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore from .relationship import Relationship, RelationshipResp, RelationshipType +from .room import Room, RoomResp from .score import ( Score, ScoreBase, @@ -43,11 +46,16 @@ __all__ = [ "DailyChallengeStats", "DailyChallengeStatsResp", "FavouriteBeatmapset", + "ItemAttemptsCount", "OAuthToken", "PPBestScore", + "Playlist", + "PlaylistResp", "Relationship", "RelationshipResp", "RelationshipType", + "Room", + "RoomResp", "Score", "ScoreBase", "ScoreResp", diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py new file mode 100644 index 0000000..5b4710a --- /dev/null +++ b/app/database/playlist_attempts.py @@ -0,0 +1,9 @@ +from sqlmodel import Field, SQLModel + + +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) + room_id: int = Field(foreign_key="rooms.id", index=True) + attempts: int = Field(default=0) + passed: int = Field(default=0) diff --git a/app/database/playlists.py b/app/database/playlists.py new file mode 100644 index 0000000..42567b6 --- /dev/null +++ b/app/database/playlists.py @@ -0,0 +1,85 @@ +from datetime import datetime +from typing import TYPE_CHECKING + +from app.models.model import UTCBaseModel +from app.models.mods import APIMod, msgpack_to_apimod +from app.models.multiplayer_hub import PlaylistItem + +from .beatmap import Beatmap, BeatmapResp + +from sqlmodel import ( + JSON, + BigInteger, + Column, + DateTime, + Field, + ForeignKey, + Relationship, + SQLModel, +) + +if TYPE_CHECKING: + from .room import Room + + +class PlaylistBase(SQLModel, UTCBaseModel): + id: int = 0 + owner_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"))) + ruleset_id: int = Field(ge=0, le=3) + expired: bool = Field(default=False) + playlist_order: int = Field(default=0) + played_at: datetime | None = Field( + sa_column=Column(DateTime(timezone=True)), + default=None, + ) + allowed_mods: list[APIMod] = Field( + default_factory=list, + sa_column=Column(JSON), + ) + required_mods: list[APIMod] = Field( + default_factory=list, + sa_column=Column(JSON), + ) + beatmap_id: int = Field( + foreign_key="beatmaps.id", + ) + freestyle: bool = Field(default=False) + + +class Playlist(PlaylistBase, table=True): + __tablename__ = "room_playlists" # pyright: ignore[reportAssignmentType] + db_id: int = Field(default=None, primary_key=True, index=True, exclude=True) + room_id: int = Field(foreign_key="rooms.id", exclude=True) + + beatmap: Beatmap = Relationship( + sa_relationship_kwargs={ + "lazy": "joined", + } + ) + room: "Room" = Relationship() + + @classmethod + async def from_hub(cls, playlist: PlaylistItem, room_id: int) -> "Playlist": + return cls( + id=playlist.id, + owner_id=playlist.owner_id, + ruleset_id=playlist.ruleset_id, + beatmap_id=playlist.beatmap_id, + required_mods=[msgpack_to_apimod(mod) for mod in playlist.required_mods], + allowed_mods=[msgpack_to_apimod(mod) for mod in playlist.allowed_mods], + expired=playlist.expired, + playlist_order=playlist.order, + played_at=playlist.played_at, + freestyle=playlist.freestyle, + room_id=room_id, + ) + + +class PlaylistResp(PlaylistBase): + beatmap: BeatmapResp | None = None + + @classmethod + async def from_db(cls, playlist: Playlist) -> "PlaylistResp": + resp = cls.model_validate(playlist) + resp.beatmap = await BeatmapResp.from_db(playlist.beatmap) + return resp diff --git a/app/database/room.py b/app/database/room.py index 0b79ee6..8eb882d 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -1,6 +1,135 @@ -from sqlmodel import Field, SQLModel +from datetime import UTC, datetime + +from app.models.multiplayer_hub import ServerMultiplayerRoom +from app.models.room import ( + MatchType, + QueueMode, + RoomCategory, + RoomDifficultyRange, + RoomPlaylistItemStats, + RoomStatus, +) + +from .lazer_user import User, UserResp +from .playlist_attempts import ItemAttemptsCount +from .playlists import Playlist, PlaylistResp + +from sqlmodel import ( + BigInteger, + Column, + DateTime, + Field, + ForeignKey, + Relationship, + SQLModel, +) -class RoomIndex(SQLModel, table=True): - __tablename__ = "mp_room_index" # pyright: ignore[reportAssignmentType] - id: int = Field(default=None, primary_key=True, index=True) # pyright: ignore[reportCallIssue] +class RoomBase(SQLModel): + name: str = Field(index=True) + category: RoomCategory = Field(default=RoomCategory.NORMAL, index=True) + duration: int | None = Field(default=None) # minutes + starts_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), + ), + default=datetime.now(UTC), + ) + ended_at: datetime | None = Field( + sa_column=Column( + DateTime(timezone=True), + ), + default=None, + ) + participant_count: int = Field(default=0) + max_attempts: int | None = Field(default=None) # playlists + type: MatchType + queue_mode: QueueMode + auto_skip: bool + auto_start_duration: int + status: RoomStatus + # TODO: channel_id + # recent_participants: list[User] + + +class Room(RoomBase, table=True): + __tablename__ = "rooms" # pyright: ignore[reportAssignmentType] + id: int = Field(default=None, primary_key=True, index=True) + host_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) + ) + + host: User = Relationship() + playlist: list[Playlist] = Relationship( + sa_relationship_kwargs={ + "lazy": "joined", + "cascade": "all, delete-orphan", + "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): + id: int + password: str | None = None + host: UserResp | None = None + playlist: list[PlaylistResp] = [] + 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": + resp = cls.model_validate(room.model_dump()) + + stats = RoomPlaylistItemStats(count_active=0, count_total=0) + difficulty_range = RoomDifficultyRange( + min=0, + max=0, + ) + rulesets = set() + for playlist in room.playlist: + stats.count_total += 1 + if not playlist.expired: + stats.count_active += 1 + rulesets.add(playlist.ruleset_id) + difficulty_range.min = min( + difficulty_range.min, playlist.beatmap.difficulty_rating + ) + difficulty_range.max = max( + difficulty_range.max, playlist.beatmap.difficulty_rating + ) + resp.playlist.append(await PlaylistResp.from_db(playlist)) + stats.ruleset_ids = list(rulesets) + 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 + + @classmethod + async def from_hub(cls, server_room: ServerMultiplayerRoom) -> "RoomResp": + room = server_room.room + resp = cls( + id=room.room_id, + name=room.settings.name, + type=room.settings.match_type, + queue_mode=room.settings.queue_mode, + auto_skip=room.settings.auto_skip, + auto_start_duration=room.settings.auto_start_duration, + status=server_room.status, + category=server_room.category, + # duration = room.settings.duration, + starts_at=server_room.start_at, + participant_count=len(room.users), + ) + return resp diff --git a/app/models/mods.py b/app/models/mods.py index abcd2cd..4b20138 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -5,10 +5,12 @@ from typing import Literal, NotRequired, TypedDict from app.path import STATIC_DIR +from msgpack_lazer_api import APIMod as MsgpackAPIMod + class APIMod(TypedDict): acronym: str - settings: NotRequired[dict[str, bool | float | str]] + settings: NotRequired[dict[str, bool | float | str | int]] # https://github.com/ppy/osu-api/wiki#mods @@ -167,3 +169,13 @@ def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool: if expected_value != NO_CHECK and value != expected_value: return False return True + + +def msgpack_to_apimod(mod: MsgpackAPIMod) -> APIMod: + """ + Convert a MsgpackAPIMod to an APIMod. + """ + return APIMod( + acronym=mod.acronym, + settings=mod.settings, + ) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py new file mode 100644 index 0000000..fa5e935 --- /dev/null +++ b/app/models/multiplayer_hub.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import datetime +from typing import Annotated, Any, Literal + +from .room import ( + DownloadState, + MatchType, + MultiplayerRoomState, + MultiplayerUserState, + QueueMode, + RoomCategory, + RoomStatus, +) +from .signalr import ( + EnumByIndex, + MessagePackArrayModel, + UserState, + msgpack_union, + msgpack_union_dump, +) + +from msgpack_lazer_api import APIMod +from pydantic import BaseModel, Field, field_serializer, field_validator + + +class MultiplayerClientState(UserState): + room_id: int = 0 + + +class MultiplayerRoomSettings(MessagePackArrayModel): + name: str = "Unnamed Room" + playlist_item_id: int = 0 + password: str = "" + match_type: Annotated[MatchType, EnumByIndex(MatchType)] = MatchType.HEAD_TO_HEAD + queue_mode: Annotated[QueueMode, EnumByIndex(QueueMode)] = QueueMode.HOST_ONLY + auto_start_duration: int = 0 + auto_skip: bool = False + + +class BeatmapAvailability(MessagePackArrayModel): + state: Annotated[DownloadState, EnumByIndex(DownloadState)] = DownloadState.UNKNOWN + progress: float | None = None + + +class _MatchUserState(MessagePackArrayModel): ... + + +class TeamVersusUserState(_MatchUserState): + team_id: int + + type: Literal[0] = Field(0, exclude=True) + + +MatchUserState = TeamVersusUserState + + +class _MatchRoomState(MessagePackArrayModel): ... + + +class MultiplayerTeam(MessagePackArrayModel): + id: int + name: str + + +class TeamVersusRoomState(_MatchRoomState): + teams: list[MultiplayerTeam] = Field( + default_factory=lambda: [ + MultiplayerTeam(id=0, name="Team Red"), + MultiplayerTeam(id=1, name="Team Blue"), + ] + ) + + type: Literal[0] = Field(0, exclude=True) + + +MatchRoomState = TeamVersusRoomState + + +class PlaylistItem(MessagePackArrayModel): + id: int + owner_id: int + beatmap_id: int + checksum: str + ruleset_id: int + required_mods: list[APIMod] = Field(default_factory=list) + allowed_mods: list[APIMod] = Field(default_factory=list) + expired: bool + order: int + played_at: datetime.datetime | None = None + star: float + freestyle: bool + + +class _MultiplayerCountdown(MessagePackArrayModel): + id: int + remaining: int + is_exclusive: bool + + +class MatchStartCountdown(_MultiplayerCountdown): + type: Literal[0] = Field(0, exclude=True) + + +class ForceGameplayStartCountdown(_MultiplayerCountdown): + type: Literal[1] = Field(1, exclude=True) + + +class ServerShuttingDownCountdown(_MultiplayerCountdown): + type: Literal[2] = Field(2, exclude=True) + + +MultiplayerCountdown = ( + MatchStartCountdown | ForceGameplayStartCountdown | ServerShuttingDownCountdown +) + + +class MultiplayerRoomUser(MessagePackArrayModel): + user_id: int + state: Annotated[MultiplayerUserState, EnumByIndex(MultiplayerUserState)] = ( + MultiplayerUserState.IDLE + ) + availability: BeatmapAvailability = BeatmapAvailability( + state=DownloadState.UNKNOWN, progress=None + ) + mods: list[APIMod] = Field(default_factory=list) + match_state: MatchUserState | None = None + ruleset_id: int | None = None # freestyle + beatmap_id: int | None = None # freestyle + + @field_validator("match_state", mode="before") + def union_validate(v: Any): + if isinstance(v, list): + return msgpack_union(v) + return v + + @field_serializer("match_state") + def union_serialize(v: Any): + return msgpack_union_dump(v) + + +class MultiplayerRoom(MessagePackArrayModel): + room_id: int + state: Annotated[MultiplayerRoomState, EnumByIndex(MultiplayerRoomState)] + settings: MultiplayerRoomSettings + users: list[MultiplayerRoomUser] = Field(default_factory=list) + host: MultiplayerRoomUser | None = None + match_state: MatchRoomState | None = None + playlist: list[PlaylistItem] = Field(default_factory=list) + active_cooldowns: list[MultiplayerCountdown] = Field(default_factory=list) + channel_id: int + + @field_validator("match_state", mode="before") + def union_validate(v: Any): + if isinstance(v, list): + return msgpack_union(v) + return v + + @field_serializer("match_state") + def union_serialize(v: Any): + return msgpack_union_dump(v) + + +class ServerMultiplayerRoom(BaseModel): + room: MultiplayerRoom + category: RoomCategory + status: RoomStatus + start_at: datetime.datetime diff --git a/app/models/room.py b/app/models/room.py index 2d01a26..42f897c 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -1,17 +1,8 @@ from __future__ import annotations -from datetime import datetime, timedelta from enum import Enum -from app.database.beatmap import Beatmap, BeatmapResp -from app.database.user import User as DBUser -from app.fetcher import Fetcher -from app.models.mods import APIMod -from app.models.user import User -from app.utils import convert_db_user_to_api_user - -from pydantic import BaseModel, Field -from sqlmodel.ext.asyncio.session import AsyncSession +from pydantic import BaseModel class RoomCategory(str, Enum): @@ -64,51 +55,13 @@ class MultiplayerUserState(str, Enum): class DownloadState(str, Enum): - UNKOWN = "unkown" + UNKNOWN = "unknown" NOT_DOWNLOADED = "not_downloaded" DOWNLOADING = "downloading" IMPORTING = "importing" LOCALLY_AVAILABLE = "locally_available" -class PlaylistItem(BaseModel): - id: int - owner_id: int - ruleset_id: int - expired: bool - playlist_order: int | None - played_at: datetime | None - allowed_mods: list[APIMod] = [] - required_mods: list[APIMod] = [] - beatmap_id: int - beatmap: BeatmapResp | None - freestyle: bool - - class Config: - exclude_none = True - - @classmethod - async def from_mpListItem( - cls, item: MultiPlayerListItem, db: AsyncSession, fetcher: Fetcher - ): - s = cls.model_validate(item.model_dump()) - s.id = item.id - s.owner_id = item.OwnerID - s.ruleset_id = item.RulesetID - s.expired = item.Expired - s.playlist_order = item.PlaylistOrder - s.played_at = item.PlayedAt - s.required_mods = item.RequierdMods - s.allowed_mods = item.AllowedMods - s.freestyle = item.Freestyle - cur_beatmap = await Beatmap.get_or_fetch( - db, fetcher=fetcher, bid=item.BeatmapID - ) - s.beatmap = BeatmapResp.from_db(cur_beatmap) - s.beatmap_id = item.BeatmapID - return s - - class RoomPlaylistItemStats(BaseModel): count_active: int count_total: int @@ -120,269 +73,7 @@ class RoomDifficultyRange(BaseModel): max: float -class ItemAttemptsCount(BaseModel): - id: int - attempts: int - passed: bool - - -class PlaylistAggregateScore(BaseModel): - playlist_item_attempts: list[ItemAttemptsCount] - - -class MultiplayerRoomSettings(BaseModel): - Name: str = "Unnamed Room" - PlaylistItemId: int - Password: str = "" - MatchType: MatchType - QueueMode: QueueMode - AutoStartDuration: timedelta - AutoSkip: bool - - @classmethod - def from_apiRoom(cls, room: Room): - s = cls.model_validate(room.model_dump()) - s.Name = room.name - s.Password = room.password if room.password is not None else "" - s.MatchType = room.type - s.QueueMode = room.queue_mode - s.AutoStartDuration = timedelta(seconds=room.auto_start_duration) - s.AutoSkip = room.auto_skip - return s - - -class BeatmapAvailability(BaseModel): - State: DownloadState - DownloadProgress: float | None - - -class MatchUserState(BaseModel): - class Config: - extra = "allow" - - -class TeamVersusState(MatchUserState): - TeamId: int - - -MatchUserStateType = TeamVersusState | MatchUserState - - -class MultiplayerRoomUser(BaseModel): - UserID: int - State: MultiplayerUserState = MultiplayerUserState.IDLE - BeatmapAvailability: BeatmapAvailability - Mods: list[APIMod] = [] - MatchUserState: MatchUserStateType | None - RulesetId: int | None - BeatmapId: int | None - User: User | None - - @classmethod - async def from_id(cls, id: int, db: AsyncSession): - actualUser = ( - await db.exec( - DBUser.all_select_clause().where( - DBUser.id == id, - ) - ) - ).first() - user = ( - await convert_db_user_to_api_user(actualUser) - if actualUser is not None - else None - ) - return MultiplayerRoomUser( - UserID=id, - MatchUserState=None, - BeatmapAvailability=BeatmapAvailability( - State=DownloadState.UNKOWN, DownloadProgress=None - ), - RulesetId=None, - BeatmapId=None, - User=user, - ) - - -class MatchRoomState(BaseModel): - class Config: - extra = "allow" - - -class MultiPlayerTeam(BaseModel): - id: int = 0 - name: str = "" - - -class TeamVersusRoomState(BaseModel): - teams: list[MultiPlayerTeam] = [] - - class Config: - pass - - @classmethod - def create_default(cls): - return cls( - teams=[ - MultiPlayerTeam(id=0, name="Team Red"), - MultiPlayerTeam(id=1, name="Team Blue"), - ] - ) - - -MatchRoomStateType = TeamVersusRoomState | MatchRoomState - - -class MultiPlayerListItem(BaseModel): - id: int - OwnerID: int - BeatmapID: int - BeatmapChecksum: str = "" - RulesetID: int - RequierdMods: list[APIMod] - AllowedMods: list[APIMod] - Expired: bool - PlaylistOrder: int | None - PlayedAt: datetime | None - StarRating: float - Freestyle: bool - - @classmethod - async def from_apiItem(cls, item: PlaylistItem, db: AsyncSession, fetcher: Fetcher): - s = cls.model_validate(item.model_dump()) - s.id = item.id - s.OwnerID = item.owner_id - if item.beatmap is None: # 从客户端接受的一定没有这字段 - cur_beatmap = await Beatmap.get_or_fetch( - db, fetcher=fetcher, bid=item.beatmap_id - ) - s.BeatmapID = cur_beatmap.id if cur_beatmap.id is not None else 0 - s.BeatmapChecksum = cur_beatmap.checksum - s.StarRating = cur_beatmap.difficulty_rating - s.RulesetID = item.ruleset_id - s.RequierdMods = item.required_mods - s.AllowedMods = item.allowed_mods - s.Expired = item.expired - s.PlaylistOrder = item.playlist_order if item.playlist_order is not None else 0 - s.PlayedAt = item.played_at - s.Freestyle = item.freestyle - return s - - -class MultiplayerCountdown(BaseModel): - id: int = 0 - time_remaining: timedelta = timedelta(seconds=0) - is_exclusive: bool = True - - class Config: - extra = "allow" - - -class MatchStartCountdown(MultiplayerCountdown): - pass - - -class ForceGameplayStartCountdown(MultiplayerCountdown): - pass - - -class ServerShuttingCountdown(MultiplayerCountdown): - pass - - -MultiplayerCountdownType = ( - MatchStartCountdown - | ForceGameplayStartCountdown - | ServerShuttingCountdown - | MultiplayerCountdown -) - - class PlaylistStatus(BaseModel): count_active: int count_total: int ruleset_ids: list[int] - - -class MultiplayerRoom(BaseModel): - RoomId: int - State: MultiplayerRoomState - Settings: MultiplayerRoomSettings = MultiplayerRoomSettings( - PlaylistItemId=0, - MatchType=MatchType.HEAD_TO_HEAD, - QueueMode=QueueMode.HOST_ONLY, - AutoStartDuration=timedelta(0), - AutoSkip=False, - ) - Users: list[MultiplayerRoomUser] - Host: MultiplayerRoomUser - MatchState: MatchRoomState | None - Playlist: list[MultiPlayerListItem] - ActivecCountDowns: list[MultiplayerCountdownType] - ChannelID: int - - @classmethod - def CanAddPlayistItem(cls, user: MultiplayerRoomUser) -> bool: - return user == cls.Host or cls.Settings.QueueMode != QueueMode.HOST_ONLY - - @classmethod - async def from_apiRoom(cls, room: Room, db: AsyncSession, fetcher: Fetcher): - s = cls.model_validate(room.model_dump()) - s.RoomId = room.room_id if room.room_id is not None else 0 - s.ChannelID = room.channel_id - s.Settings = MultiplayerRoomSettings.from_apiRoom(room) - s.Host = await MultiplayerRoomUser.from_id(room.host.id if room.host else 0, db) - s.Playlist = [ - await MultiPlayerListItem.from_apiItem(item, db, fetcher) - for item in room.playlist - ] - return s - - -class Room(BaseModel): - room_id: int - name: str - password: str | None - has_password: bool = Field(exclude=True) - host: User | None - category: RoomCategory - duration: int | None - starts_at: datetime | None - ends_at: datetime | None - max_particapants: int | None = Field(exclude=True) - particapant_count: int - recent_particapants: list[User] - type: MatchType - max_attempts: int | None - playlist: list[PlaylistItem] - playlist_item_status: list[RoomPlaylistItemStats] - difficulity_range: RoomDifficultyRange - queue_mode: QueueMode - auto_skip: bool - auto_start_duration: int - current_user_score: PlaylistAggregateScore | None - current_playlist_item: PlaylistItem | None - channel_id: int - status: RoomStatus - availability: RoomAvailability = Field(exclude=True) - - class Config: - exclude_none = True - - @classmethod - async def from_mpRoom( - cls, room: MultiplayerRoom, db: AsyncSession, fetcher: Fetcher - ): - s = cls.model_validate(room.model_dump()) - s.room_id = room.RoomId - s.name = room.Settings.Name - s.password = room.Settings.Password - s.type = room.Settings.MatchType - s.queue_mode = room.Settings.QueueMode - s.auto_skip = room.Settings.AutoSkip - s.host = room.Host.User - s.playlist = [ - await PlaylistItem.from_mpListItem(item, db, fetcher) - for item in room.Playlist - ] - return s diff --git a/app/router/__init__.py b/app/router/__init__.py index 1e87343..22f6c70 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -7,6 +7,7 @@ from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401 beatmapset, me, relationship, + room, score, user, ) @@ -14,4 +15,9 @@ from .api_router import router as api_router from .auth import router as auth_router from .fetcher import fetcher_router as fetcher_router -__all__ = ["api_router", "auth_router", "fetcher_router", "signalr_router"] +__all__ = [ + "api_router", + "auth_router", + "fetcher_router", + "signalr_router", +] diff --git a/app/router/room.py b/app/router/room.py index a2347ec..ba909c6 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -1,109 +1,86 @@ from __future__ import annotations -from app.database.room import RoomIndex +from typing import Literal + +from app.database.room import RoomResp from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.fetcher import Fetcher -from app.models.room import MultiplayerRoom, MultiplayerRoomState, Room +from app.models.room import RoomStatus +from app.signalr.hub import MultiplayerHubs from .api_router import router -from fastapi import Depends, HTTPException, Query +from fastapi import Depends, Query from redis.asyncio import Redis -from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession -@router.get("/rooms", tags=["rooms"], response_model=list[Room]) +@router.get("/rooms", tags=["rooms"], response_model=list[RoomResp]) async def get_all_rooms( - mode: str | None = Query(None), # TODO: 对房间根据状态进行筛选 - status: str | None = Query(None), - category: str | None = Query( + mode: Literal["open", "ended", "participated", "owned", None] = Query( None - ), # TODO: 对房间根据分类进行筛选(真的有人用这功能吗) + ), # TODO: 对房间根据状态进行筛选 + category: str = Query(default="realtime"), # TODO + status: RoomStatus | None = Query(None), db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), redis: Redis = Depends(get_redis), ): - all_roomID = (await db.exec(select(RoomIndex))).all() - redis = get_redis() - if redis is not None: - resp: list[Room] = [] - for id in all_roomID: - dumped_room = redis.get(str(id)) - validated_room = MultiplayerRoom.model_validate_json(str(dumped_room)) - flag: bool = False - if status is not None: - if ( - validated_room.State == MultiplayerRoomState.OPEN - and status == "idle" - ): - flag = True - elif validated_room != MultiplayerRoomState.CLOSED: - flag = True - if flag: - resp.append( - await Room.from_mpRoom( - MultiplayerRoom.model_validate_json(str(dumped_room)), - db, - fetcher, - ) - ) - return resp - else: - raise HTTPException(status_code=500, detail="Redis Error") + rooms = MultiplayerHubs.rooms.values() + return [await RoomResp.from_hub(room) for room in rooms] -@router.get("/rooms/{room}", tags=["room"], response_model=Room) -async def get_room( - room: int, - db: AsyncSession = Depends(get_db), - fetcher: Fetcher = Depends(get_fetcher), -): - redis = get_redis() - if redis: - dumped_room = str(redis.get(str(room))) - if dumped_room is not None: - resp = await Room.from_mpRoom( - MultiplayerRoom.model_validate_json(str(dumped_room)), db, fetcher - ) - return resp - else: - raise HTTPException(status_code=404, detail="Room Not Found") - else: - raise HTTPException(status_code=500, detail="Redis error") +# @router.get("/rooms/{room}", tags=["room"], response_model=Room) +# async def get_room( +# room: int, +# db: AsyncSession = Depends(get_db), +# fetcher: Fetcher = Depends(get_fetcher), +# ): +# redis = get_redis() +# if redis: +# dumped_room = str(redis.get(str(room))) +# if dumped_room is not None: +# resp = await Room.from_mpRoom( +# MultiplayerRoom.model_validate_json(str(dumped_room)), db, fetcher +# ) +# return resp +# else: +# raise HTTPException(status_code=404, detail="Room Not Found") +# else: +# raise HTTPException(status_code=500, detail="Redis error") -class APICreatedRoom(Room): - error: str | None +# class APICreatedRoom(Room): +# error: str | None -@router.post("/rooms", tags=["beatmap"], response_model=APICreatedRoom) -async def create_room( - room: Room, - db: AsyncSession = Depends(get_db), - fetcher: Fetcher = Depends(get_fetcher), -): - redis = get_redis() - if redis: - room_index = RoomIndex() - db.add(room_index) - await db.commit() - await db.refresh(room_index) - server_room = await MultiplayerRoom.from_apiRoom(room, db, fetcher) - redis.set(str(room_index.id), server_room.model_dump_json()) - room.room_id = room_index.id - return APICreatedRoom(**room.model_dump(), error=None) - else: - raise HTTPException(status_code=500, detail="redis error") +# @router.post("/rooms", tags=["beatmap"], response_model=APICreatedRoom) +# async def create_room( +# room: Room, +# db: AsyncSession = Depends(get_db), +# fetcher: Fetcher = Depends(get_fetcher), +# ): +# redis = get_redis() +# if redis: +# room_index = RoomIndex() +# db.add(room_index) +# await db.commit() +# await db.refresh(room_index) +# server_room = await MultiplayerRoom.from_apiRoom(room, db, fetcher) +# redis.set(str(room_index.id), server_room.model_dump_json()) +# room.room_id = room_index.id +# return APICreatedRoom(**room.model_dump(), error=None) +# else: +# raise HTTPException(status_code=500, detail="redis error") -@router.delete("/rooms/{room}", tags=["room"]) -async def remove_room(room: int, db: AsyncSession = Depends(get_db)): - redis = get_redis() - if redis: - redis.delete(str(room)) - room_index = await db.get(RoomIndex, room) - if room_index: - await db.delete(room_index) - await db.commit() +# @router.delete("/rooms/{room}", tags=["room"]) +# async def remove_room(room: int, db: AsyncSession = Depends(get_db)): +# redis = get_redis() +# if redis: +# redis.delete(str(room)) +# room_index = await db.get(RoomIndex, room) +# if room_index: +# await db.delete(room_index) +# await db.commit() diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 72b4a52..23ca69b 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -1,6 +1,103 @@ from __future__ import annotations -from .hub import Hub +from typing import override + +from app.database import Room +from app.database.playlists import Playlist +from app.dependencies.database import engine +from app.log import logger +from app.models.multiplayer_hub import ( + MultiplayerClientState, + MultiplayerRoom, + MultiplayerRoomUser, + ServerMultiplayerRoom, +) +from app.models.room import RoomCategory, RoomStatus +from app.models.signalr import serialize_to_list +from app.signalr.exception import InvokeException + +from .hub import Client, Hub + +from sqlmodel.ext.asyncio.session import AsyncSession -class MultiplayerHub(Hub): ... +class MultiplayerHub(Hub[MultiplayerClientState]): + @override + def __init__(self): + super().__init__() + self.rooms: dict[int, ServerMultiplayerRoom] = {} + + @staticmethod + def group_id(room: int) -> str: + return f"room:{room}" + + @override + def create_state(self, client: Client) -> MultiplayerClientState: + return MultiplayerClientState( + connection_id=client.connection_id, + connection_token=client.connection_token, + ) + + async def CreateRoom(self, client: Client, room: MultiplayerRoom): + logger.info(f"[MultiplayerHub] {client.user_id} creating room") + async with AsyncSession(engine) as session: + async with session: + db_room = Room( + name=room.settings.name, + category=RoomCategory.NORMAL, + type=room.settings.match_type, + queue_mode=room.settings.queue_mode, + auto_skip=room.settings.auto_skip, + auto_start_duration=room.settings.auto_start_duration, + host_id=client.user_id, + status=RoomStatus.IDLE, + ) + session.add(db_room) + await session.commit() + await session.refresh(db_room) + playitem = room.playlist[0] + playitem.owner_id = client.user_id + playitem.order = 1 + db_playlist = await Playlist.from_hub(playitem, db_room.id) + session.add(db_playlist) + room.room_id = db_room.id + starts_at = db_room.starts_at + await session.commit() + await session.refresh(db_playlist) + # room.playlist.append() + server_room = ServerMultiplayerRoom( + room=room, + category=RoomCategory.NORMAL, + status=RoomStatus.IDLE, + start_at=starts_at, + ) + self.rooms[room.room_id] = server_room + return await self.JoinRoomWithPassword( + client, room.room_id, room.settings.password + ) + + async def JoinRoomWithPassword(self, client: Client, room_id: int, password: str): + logger.info(f"[MultiplayerHub] {client.user_id} joining room {room_id}") + store = self.get_or_create_state(client) + if store.room_id != 0: + raise InvokeException("You are already in a room") + user = MultiplayerRoomUser(user_id=client.user_id) + if room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[room_id] + room = server_room.room + for u in room.users: + if u.user_id == client.user_id: + raise InvokeException("You are already in this room") + if room.settings.password != password: + raise InvokeException("Incorrect password") + if room.host is None: + # from CreateRoom + room.host = user + store.room_id = room_id + await self.broadcast_group_call( + self.group_id(room_id), "UserJoined", serialize_to_list(user) + ) + room.users.append(user) + self.add_to_group(client, self.group_id(room_id)) + return serialize_to_list(room) diff --git a/main.py b/main.py index 72444ef..b12f543 100644 --- a/main.py +++ b/main.py @@ -6,7 +6,12 @@ from datetime import datetime from app.config import settings from app.dependencies.database import create_tables, engine, redis_client from app.dependencies.fetcher import get_fetcher -from app.router import api_router, auth_router, fetcher_router, signalr_router +from app.router import ( + api_router, + auth_router, + fetcher_router, + signalr_router, +) from fastapi import FastAPI From 0b68bdc0c11fc75a6a76e97a4f874c98a784c639 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 01:55:30 +0000 Subject: [PATCH 09/65] fix(beatmap,beatmapset): fix lookup --- app/router/beatmap.py | 2 +- app/router/beatmapset.py | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/router/beatmap.py b/app/router/beatmap.py index 9574bdb..7dfd0f9 100644 --- a/app/router/beatmap.py +++ b/app/router/beatmap.py @@ -74,7 +74,7 @@ class BatchGetResp(BaseModel): @router.get("/beatmaps", tags=["beatmap"], response_model=BatchGetResp) @router.get("/beatmaps/", tags=["beatmap"], response_model=BatchGetResp) async def batch_get_beatmaps( - b_ids: list[int] = Query(alias="id", default_factory=list), + b_ids: list[int] = Query(alias="ids[]", default_factory=list), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): diff --git a/app/router/beatmapset.py b/app/router/beatmapset.py index b4d2e4c..f77c2ed 100644 --- a/app/router/beatmapset.py +++ b/app/router/beatmapset.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Literal -from app.database import Beatmapset, BeatmapsetResp, FavouriteBeatmapset, User +from app.database import Beatmap, Beatmapset, BeatmapsetResp, FavouriteBeatmapset, User from app.dependencies.database import get_db from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user @@ -17,6 +17,32 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +@router.get("/beatmapsets/lookup", tags=["beatmapset"], response_model=BeatmapsetResp) +async def lookup_beatmapset( + beatmap_id: int = Query(), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + fetcher: Fetcher = Depends(get_fetcher), +): + beatmapset_id = ( + await db.exec(select(Beatmap.beatmapset_id).where(Beatmap.id == beatmap_id)) + ).first() + if not beatmapset_id: + try: + resp = await fetcher.get_beatmap(beatmap_id) + await Beatmap.from_resp(db, resp) + await db.refresh(current_user) + except HTTPStatusError: + raise HTTPException(status_code=404, detail="Beatmapset not found") + beatmapset = ( + await db.exec(select(Beatmapset).where(Beatmapset.id == beatmapset_id)) + ).first() + if not beatmapset: + raise HTTPException(status_code=404, detail="Beatmapset not found") + resp = await BeatmapsetResp.from_db(beatmapset, session=db, user=current_user) + return resp + + @router.get("/beatmapsets/{sid}", tags=["beatmapset"], response_model=BeatmapsetResp) async def get_beatmapset( sid: int, From 884a3f1cc23e298e8396172a1ee63086e6393242 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 01:56:00 +0000 Subject: [PATCH 10/65] fix(leaderboard): missing filter condition for user score --- app/database/score.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/database/score.py b/app/database/score.py index 79cb005..1bd5978 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -328,6 +328,10 @@ async def get_leaderboard( self_query = ( select(BestScore) .where(BestScore.user_id == user.id) + .where( + col(BestScore.beatmap_id) == beatmap, + col(BestScore.gamemode) == mode, + ) .order_by(col(BestScore.total_score).desc()) .limit(1) ) From 86e2313c50d2e5c537eb2ddf29df83ad903c23a1 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 01:56:54 +0000 Subject: [PATCH 11/65] feat(multiplayer): support add/edit/remove playlist item --- app/database/playlists.py | 62 +++++++- app/{signalr => }/exception.py | 0 app/models/multiplayer_hub.py | 256 ++++++++++++++++++++++++++++++++- app/signalr/hub/hub.py | 2 +- app/signalr/hub/multiplayer.py | 136 ++++++++++++++++-- 5 files changed, 441 insertions(+), 15 deletions(-) rename app/{signalr => }/exception.py (100%) diff --git a/app/database/playlists.py b/app/database/playlists.py index 42567b6..10ad86b 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -16,7 +16,10 @@ from sqlmodel import ( ForeignKey, Relationship, SQLModel, + func, + select, ) +from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: from .room import Room @@ -59,9 +62,20 @@ class Playlist(PlaylistBase, table=True): room: "Room" = Relationship() @classmethod - async def from_hub(cls, playlist: PlaylistItem, room_id: int) -> "Playlist": + async def get_next_id_for_room(cls, room_id: int, session: AsyncSession) -> int: + stmt = select(func.coalesce(func.max(cls.id), -1) + 1).where( + cls.room_id == room_id + ) + result = await session.exec(stmt) + return result.one() + + @classmethod + async def from_hub( + cls, playlist: PlaylistItem, room_id: int, session: AsyncSession + ) -> "Playlist": + next_id = await cls.get_next_id_for_room(room_id, session=session) return cls( - id=playlist.id, + id=next_id, owner_id=playlist.owner_id, ruleset_id=playlist.ruleset_id, beatmap_id=playlist.beatmap_id, @@ -74,6 +88,50 @@ class Playlist(PlaylistBase, table=True): room_id=room_id, ) + @classmethod + async def update(cls, playlist: PlaylistItem, room_id: int, session: AsyncSession): + db_playlist = await session.exec( + select(cls).where(cls.id == playlist.id, cls.room_id == room_id) + ) + db_playlist = db_playlist.first() + if db_playlist is None: + raise ValueError("Playlist item not found") + db_playlist.owner_id = playlist.owner_id + db_playlist.ruleset_id = playlist.ruleset_id + db_playlist.beatmap_id = playlist.beatmap_id + db_playlist.required_mods = [ + msgpack_to_apimod(mod) for mod in playlist.required_mods + ] + db_playlist.allowed_mods = [ + msgpack_to_apimod(mod) for mod in playlist.allowed_mods + ] + db_playlist.expired = playlist.expired + db_playlist.playlist_order = playlist.order + db_playlist.played_at = playlist.played_at + db_playlist.freestyle = playlist.freestyle + await session.commit() + + @classmethod + async def add_to_db( + cls, playlist: PlaylistItem, room_id: int, session: AsyncSession + ): + db_playlist = await cls.from_hub(playlist, room_id, session) + session.add(db_playlist) + await session.commit() + await session.refresh(db_playlist) + playlist.id = db_playlist.id + + @classmethod + async def delete_item(cls, item_id: int, room_id: int, session: AsyncSession): + db_playlist = await session.exec( + select(cls).where(cls.id == item_id, cls.room_id == room_id) + ) + db_playlist = db_playlist.first() + if db_playlist is None: + raise ValueError("Playlist item not found") + await session.delete(db_playlist) + await session.commit() + class PlaylistResp(PlaylistBase): beatmap: BeatmapResp | None = None diff --git a/app/signalr/exception.py b/app/exception.py similarity index 100% rename from app/signalr/exception.py rename to app/exception.py diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index fa5e935..39ced12 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -1,7 +1,12 @@ from __future__ import annotations +from dataclasses import dataclass import datetime -from typing import Annotated, Any, Literal +from typing import TYPE_CHECKING, Annotated, Any, Literal + +from app.database.beatmap import Beatmap +from app.dependencies.database import engine +from app.exception import InvokeException from .room import ( DownloadState, @@ -21,7 +26,14 @@ from .signalr import ( ) from msgpack_lazer_api import APIMod -from pydantic import BaseModel, Field, field_serializer, field_validator +from pydantic import Field, field_serializer, field_validator +from sqlmodel.ext.asyncio.session import AsyncSession + +if TYPE_CHECKING: + from app.signalr.hub import MultiplayerHub + +HOST_LIMIT = 50 +PER_USER_LIMIT = 3 class MultiplayerClientState(UserState): @@ -161,8 +173,246 @@ class MultiplayerRoom(MessagePackArrayModel): return msgpack_union_dump(v) -class ServerMultiplayerRoom(BaseModel): +class MultiplayerQueue: + def __init__(self, room: "ServerMultiplayerRoom", hub: "MultiplayerHub"): + self.server_room = room + self.hub = hub + self.current_index = 0 + + @property + def upcoming_items(self): + return sorted( + (item for item in self.room.playlist if not item.expired), + key=lambda i: i.order, + ) + + @property + def room(self): + return self.server_room.room + + async def update_order(self): + from app.database import Playlist + + match self.room.settings.queue_mode: + case QueueMode.ALL_PLAYERS_ROUND_ROBIN: + ordered_active_items = [] + + is_first_set = True + first_set_order_by_user_id = {} + + active_items = [item for item in self.room.playlist if not item.expired] + active_items.sort(key=lambda x: x.id) + + user_item_groups = {} + for item in active_items: + if item.owner_id not in user_item_groups: + user_item_groups[item.owner_id] = [] + user_item_groups[item.owner_id].append(item) + + max_items = max( + (len(items) for items in user_item_groups.values()), default=0 + ) + + for i in range(max_items): + current_set = [] + for user_id, items in user_item_groups.items(): + if i < len(items): + current_set.append(items[i]) + + if is_first_set: + current_set.sort(key=lambda item: (item.order, item.id)) + ordered_active_items.extend(current_set) + first_set_order_by_user_id = { + item.owner_id: idx + for idx, item in enumerate(ordered_active_items) + } + else: + current_set.sort( + key=lambda item: first_set_order_by_user_id.get( + item.owner_id, 0 + ) + ) + ordered_active_items.extend(current_set) + + is_first_set = False + + for idx, item in enumerate(ordered_active_items): + item.order = idx + case _: + ordered_active_items = sorted( + (item for item in self.room.playlist if not item.expired), + key=lambda x: x.id, + ) + async with AsyncSession(engine) as session: + for idx, item in enumerate(ordered_active_items): + if item.order == idx: + continue + item.order = idx + await Playlist.update(item, self.room.room_id, session) + await self.hub.playlist_changed( + self.server_room, item, beatmap_changed=False + ) + + async def update_current_item(self): + upcoming_items = self.upcoming_items + next_item = ( + upcoming_items[0] + if upcoming_items + else max( + self.room.playlist, + key=lambda i: i.played_at or datetime.datetime.min, + ) + ) + self.current_index = self.room.playlist.index(next_item) + last_id = self.room.settings.playlist_item_id + self.room.settings.playlist_item_id = next_item.id + if last_id != next_item.id: + await self.hub.setting_changed(self.server_room, True) + + async def add_item(self, item: PlaylistItem, user: MultiplayerRoomUser): + from app.database import Playlist + + is_host = self.room.host and self.room.host.user_id == user.user_id + if self.room.settings.queue_mode == QueueMode.HOST_ONLY and not is_host: + raise InvokeException("You are not the host") + + limit = HOST_LIMIT if is_host else PER_USER_LIMIT + if ( + len( + list( + filter( + lambda x: x.owner_id == user.user_id, + self.room.playlist, + ) + ) + ) + >= limit + ): + raise InvokeException(f"You can only have {limit} items in the queue") + + if item.freestyle and len(item.allowed_mods) > 0: + raise InvokeException("Freestyle items cannot have allowed mods") + + async with AsyncSession(engine) as session: + async with session: + beatmap = await session.get(Beatmap, item.beatmap_id) + if beatmap is None: + raise InvokeException("Beatmap not found") + if item.checksum != beatmap.checksum: + raise InvokeException("Checksum mismatch") + # TODO: mods validation + item.owner_id = user.user_id + item.star = float( + beatmap.difficulty_rating + ) # FIXME: beatmap use decimal + await Playlist.add_to_db(item, self.room.room_id, session) + self.room.playlist.append(item) + await self.hub.playlist_added(self.server_room, item) + await self.update_order() + await self.update_current_item() + + async def edit_item(self, item: PlaylistItem, user: MultiplayerRoomUser): + from app.database import Playlist + + if item.freestyle and len(item.allowed_mods) > 0: + raise InvokeException("Freestyle items cannot have allowed mods") + + async with AsyncSession(engine) as session: + async with session: + beatmap = await session.get(Beatmap, item.beatmap_id) + if beatmap is None: + raise InvokeException("Beatmap not found") + if item.checksum != beatmap.checksum: + raise InvokeException("Checksum mismatch") + + existing_item = next( + (i for i in self.room.playlist if i.id == item.id), None + ) + if existing_item is None: + raise InvokeException( + "Attempted to change an item that doesn't exist" + ) + + if existing_item.owner_id != user.user_id and self.room.host != user: + raise InvokeException( + "Attempted to change an item which is not owned by the user" + ) + + if existing_item.expired: + raise InvokeException( + "Attempted to change an item which has already been played" + ) + + # TODO: mods validation + item.owner_id = user.user_id + item.star = float(beatmap.difficulty_rating) + item.order = existing_item.order + + await Playlist.update(item, self.room.room_id, session) + + # Update item in playlist + for idx, playlist_item in enumerate(self.room.playlist): + if playlist_item.id == item.id: + self.room.playlist[idx] = item + break + + await self.hub.playlist_changed( + self.server_room, + item, + beatmap_changed=item.checksum != existing_item.checksum, + ) + + async def remove_item(self, playlist_item_id: int, user: MultiplayerRoomUser): + from app.database import Playlist + + item = next( + (i for i in self.room.playlist if i.id == playlist_item_id), + None, + ) + + if item is None: + raise InvokeException("Item does not exist in the room") + + # Check if it's the only item and current item + if item == self.current_item: + upcoming_items = [i for i in self.room.playlist if not i.expired] + if len(upcoming_items) == 1: + raise InvokeException("The only item in the room cannot be removed") + + if item.owner_id != user.user_id and self.room.host != user: + raise InvokeException( + "Attempted to remove an item which is not owned by the user" + ) + + if item.expired: + raise InvokeException( + "Attempted to remove an item which has already been played" + ) + + async with AsyncSession(engine) as session: + await Playlist.delete_item(item.id, self.room.room_id, session) + + self.room.playlist.remove(item) + self.current_index = self.room.playlist.index(self.upcoming_items[0]) + + await self.update_order() + await self.update_current_item() + await self.hub.playlist_removed(self.server_room, item.id) + + @property + def current_item(self): + """Get the current playlist item""" + current_id = self.room.settings.playlist_item_id + return next( + (item for item in self.room.playlist if item.id == current_id), + None, + ) + + +@dataclass +class ServerMultiplayerRoom: room: MultiplayerRoom category: RoomCategory status: RoomStatus start_at: datetime.datetime + queue: MultiplayerQueue | None = None diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index 276140f..4e2c9d6 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -6,9 +6,9 @@ import time from typing import Any from app.config import settings +from app.exception import InvokeException from app.log import logger from app.models.signalr import UserState -from app.signalr.exception import InvokeException from app.signalr.packet import ( ClosePacket, CompletionPacket, diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 23ca69b..477396b 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -5,16 +5,19 @@ from typing import override from app.database import Room from app.database.playlists import Playlist from app.dependencies.database import engine +from app.exception import InvokeException from app.log import logger from app.models.multiplayer_hub import ( + BeatmapAvailability, MultiplayerClientState, + MultiplayerQueue, MultiplayerRoom, MultiplayerRoomUser, + PlaylistItem, ServerMultiplayerRoom, ) from app.models.room import RoomCategory, RoomStatus from app.models.signalr import serialize_to_list -from app.signalr.exception import InvokeException from .hub import Client, Hub @@ -40,6 +43,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): async def CreateRoom(self, client: Client, room: MultiplayerRoom): logger.info(f"[MultiplayerHub] {client.user_id} creating room") + store = self.get_or_create_state(client) + if store.room_id != 0: + raise InvokeException("You are already in a room") async with AsyncSession(engine) as session: async with session: db_room = Room( @@ -55,22 +61,22 @@ class MultiplayerHub(Hub[MultiplayerClientState]): session.add(db_room) await session.commit() await session.refresh(db_room) - playitem = room.playlist[0] - playitem.owner_id = client.user_id - playitem.order = 1 - db_playlist = await Playlist.from_hub(playitem, db_room.id) - session.add(db_playlist) + item = room.playlist[0] + item.owner_id = client.user_id room.room_id = db_room.id starts_at = db_room.starts_at - await session.commit() - await session.refresh(db_playlist) - # room.playlist.append() + await Playlist.add_to_db(item, db_room.id, session) server_room = ServerMultiplayerRoom( room=room, category=RoomCategory.NORMAL, status=RoomStatus.IDLE, start_at=starts_at, ) + queue = MultiplayerQueue( + room=server_room, + hub=self, + ) + server_room.queue = queue self.rooms[room.room_id] = server_room return await self.JoinRoomWithPassword( client, room.room_id, room.settings.password @@ -101,3 +107,115 @@ class MultiplayerHub(Hub[MultiplayerClientState]): room.users.append(user) self.add_to_group(client, self.group_id(room_id)) return serialize_to_list(room) + + async def ChangeBeatmapAvailability( + self, client: Client, beatmap_availability: BeatmapAvailability + ): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + availability = user.availability + if ( + availability.state == beatmap_availability.state + and availability.progress == beatmap_availability.progress + ): + return + user.availability = availability + await self.broadcast_group_call( + self.group_id(store.room_id), + "UserBeatmapAvailabilityChanged", + user.user_id, + serialize_to_list(beatmap_availability), + ) + + async def AddPlaylistItem(self, client: Client, item: PlaylistItem): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await server_room.queue.add_item( + item, + user, + ) + + async def EditPlaylistItem(self, client: Client, item: PlaylistItem): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await server_room.queue.edit_item( + item, + user, + ) + + async def RemovePlaylistItem(self, client: Client, item_id: int): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await server_room.queue.remove_item( + item_id, + user, + ) + + async def setting_changed(self, room: ServerMultiplayerRoom, beatmap_changed: bool): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "SettingsChanged", + serialize_to_list(room.room.settings), + ) + + async def playlist_added(self, room: ServerMultiplayerRoom, item: PlaylistItem): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "PlaylistItemAdded", + serialize_to_list(item), + ) + + async def playlist_removed(self, room: ServerMultiplayerRoom, item_id: int): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "PlaylistItemRemoved", + item_id, + ) + + async def playlist_changed( + self, room: ServerMultiplayerRoom, item: PlaylistItem, beatmap_changed: bool + ): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "PlaylistItemChanged", + serialize_to_list(item), + ) From 693c18ba6e2f561b3fdcff9231e6537884dff7a7 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 04:24:13 +0000 Subject: [PATCH 12/65] feat(multiplayer): support change mods/playstyles(freestyle) --- app/models/multiplayer_hub.py | 79 ++++++++++++++- app/signalr/hub/multiplayer.py | 176 +++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 1 deletion(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 39ced12..9bccb71 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -103,6 +103,84 @@ class PlaylistItem(MessagePackArrayModel): star: float freestyle: bool + def validate_user_mods( + self, + user: "MultiplayerRoomUser", + proposed_mods: list[APIMod], + ) -> tuple[bool, list[APIMod]]: + """ + Validates user mods against playlist item rules and returns valid mods. + Returns (is_valid, valid_mods). + """ + from typing import Literal, cast + + from app.models.mods import API_MODS, init_mods + + if not API_MODS: + init_mods() + + ruleset_id = user.ruleset_id if user.ruleset_id is not None else self.ruleset_id + ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_id) + + valid_mods = [] + all_proposed_valid = True + + # Check if mods are valid for the ruleset + for mod in proposed_mods: + if ruleset_key not in API_MODS or mod.acronym not in API_MODS[ruleset_key]: + all_proposed_valid = False + continue + valid_mods.append(mod) + + # Check mod compatibility within user mods + incompatible_mods = set() + final_valid_mods = [] + for mod in valid_mods: + if mod.acronym in incompatible_mods: + all_proposed_valid = False + continue + setting_mods = API_MODS[ruleset_key].get(mod.acronym) + if setting_mods: + incompatible_mods.update(setting_mods["IncompatibleMods"]) + final_valid_mods.append(mod) + + # If not freestyle, check against allowed mods + if not self.freestyle: + allowed_acronyms = {mod.acronym for mod in self.allowed_mods} + filtered_valid_mods = [] + for mod in final_valid_mods: + if mod.acronym not in allowed_acronyms: + all_proposed_valid = False + else: + filtered_valid_mods.append(mod) + final_valid_mods = filtered_valid_mods + + # Check compatibility with required mods + required_mod_acronyms = {mod.acronym for mod in self.required_mods} + all_mod_acronyms = { + mod.acronym for mod in final_valid_mods + } | required_mod_acronyms + + # Check for incompatibility between required and user mods + filtered_valid_mods = [] + for mod in final_valid_mods: + mod_acronym = mod.acronym + is_compatible = True + + for other_acronym in all_mod_acronyms: + if other_acronym == mod_acronym: + continue + setting_mods = API_MODS[ruleset_key].get(mod_acronym) + if setting_mods and other_acronym in setting_mods["IncompatibleMods"]: + is_compatible = False + all_proposed_valid = False + break + + if is_compatible: + filtered_valid_mods.append(mod) + + return all_proposed_valid, filtered_valid_mods + class _MultiplayerCountdown(MessagePackArrayModel): id: int @@ -405,7 +483,6 @@ class MultiplayerQueue: current_id = self.room.settings.playlist_item_id return next( (item for item in self.room.playlist if item.id == current_id), - None, ) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 477396b..bd34be0 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import override from app.database import Room +from app.database.beatmap import Beatmap from app.database.playlists import Playlist from app.dependencies.database import engine from app.exception import InvokeException @@ -17,10 +18,13 @@ from app.models.multiplayer_hub import ( ServerMultiplayerRoom, ) from app.models.room import RoomCategory, RoomStatus +from app.models.score import GameMode from app.models.signalr import serialize_to_list from .hub import Client, Hub +from msgpack_lazer_api import APIMod +from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -219,3 +223,175 @@ class MultiplayerHub(Hub[MultiplayerClientState]): "PlaylistItemChanged", serialize_to_list(item), ) + + async def ChangeUserStyle( + self, client: Client, beatmap_id: int | None, ruleset_id: int | None + ): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await self.change_user_style( + beatmap_id, + ruleset_id, + server_room, + user, + ) + + async def validate_styles(self, room: ServerMultiplayerRoom): + assert room.queue + if not room.queue.current_item.freestyle: + for user in room.room.users: + await self.change_user_style( + None, + None, + room, + user, + ) + async with AsyncSession(engine) as session: + beatmap = await session.get(Beatmap, room.queue.current_item.beatmap_id) + if beatmap is None: + raise InvokeException("Beatmap not found") + beatmap_ids = ( + await session.exec( + select(Beatmap.id, Beatmap.mode).where( + Beatmap.beatmapset_id == beatmap.beatmapset_id, + ) + ) + ).all() + for user in room.room.users: + beatmap_id = user.beatmap_id + ruleset_id = user.ruleset_id + user_beatmap = next( + (b for b in beatmap_ids if b[0] == beatmap_id), + None, + ) + if beatmap_id is not None and user_beatmap is None: + beatmap_id = None + beatmap_ruleset = user_beatmap[1] if user_beatmap else beatmap.mode + if ( + ruleset_id is not None + and beatmap_ruleset != GameMode.OSU + and ruleset_id != beatmap_ruleset + ): + ruleset_id = None + await self.change_user_style( + beatmap_id, + ruleset_id, + room, + user, + ) + + for user in room.room.users: + is_valid, valid_mods = room.queue.current_item.validate_user_mods( + user, user.mods + ) + if not is_valid: + await self.change_user_mods(valid_mods, room, user) + + async def change_user_style( + self, + beatmap_id: int | None, + ruleset_id: int | None, + room: ServerMultiplayerRoom, + user: MultiplayerRoomUser, + ): + if user.beatmap_id == beatmap_id and user.ruleset_id == ruleset_id: + return + + if beatmap_id is not None or ruleset_id is not None: + assert room.queue + if not room.queue.current_item.freestyle: + raise InvokeException("Current item does not allow free user styles.") + + async with AsyncSession(engine) as session: + item_beatmap = await session.get( + Beatmap, room.queue.current_item.beatmap_id + ) + if item_beatmap is None: + raise InvokeException("Item beatmap not found") + + user_beatmap = ( + item_beatmap + if beatmap_id is None + else await session.get(Beatmap, beatmap_id) + ) + + if user_beatmap is None: + raise InvokeException("Invalid beatmap selected.") + + if user_beatmap.beatmapset_id != item_beatmap.beatmapset_id: + raise InvokeException( + "Selected beatmap is not from the same beatmap set." + ) + + if ( + ruleset_id is not None + and user_beatmap.mode != GameMode.OSU + and ruleset_id != user_beatmap.mode + ): + raise InvokeException( + "Selected ruleset is not supported for the given beatmap." + ) + + user.beatmap_id = beatmap_id + user.ruleset_id = ruleset_id + + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "UserStyleChanged", + user.user_id, + beatmap_id, + ruleset_id, + ) + + async def ChangeUserMods(self, client: Client, new_mods: list[APIMod]): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await self.change_user_mods(new_mods, server_room, user) + + async def change_user_mods( + self, + new_mods: list[APIMod], + room: ServerMultiplayerRoom, + user: MultiplayerRoomUser, + ): + assert room.queue + is_valid, valid_mods = room.queue.current_item.validate_user_mods( + user, new_mods + ) + if not is_valid: + incompatible_mods = [ + mod.acronym for mod in new_mods if mod not in valid_mods + ] + raise InvokeException( + f"Incompatible mods were selected: {','.join(incompatible_mods)}" + ) + + if user.mods == valid_mods: + return + + user.mods = valid_mods + + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "UserModsChanged", + user.user_id, + valid_mods, + ) From c83f950d132c6c1860b0f382f6de53578972d737 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 14:59:12 +0000 Subject: [PATCH 13/65] fix(signalr): encode enum by index --- app/models/signalr.py | 34 +++++++++++++++++----------------- app/signalr/hub/hub.py | 8 +++++++- app/signalr/packet.py | 4 +++- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/app/models/signalr.py b/app/models/signalr.py index 202da4f..9e189e9 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -15,26 +15,26 @@ from pydantic import ( ) +def serialize_msgpack(v: Any) -> Any: + typ = v.__class__ + if issubclass(typ, BaseModel): + return serialize_to_list(v) + elif issubclass(typ, list): + return TypeAdapter( + typ, config=ConfigDict(arbitrary_types_allowed=True) + ).dump_python(v) + elif issubclass(typ, datetime.datetime): + return [v, 0] + elif issubclass(typ, Enum): + list_ = list(typ) + return list_.index(v) if v in list_ else v.value + return v + + def serialize_to_list(value: BaseModel) -> list[Any]: data = [] for field, info in value.__class__.model_fields.items(): - v = getattr(value, field) - typ = v.__class__ - if issubclass(typ, BaseModel): - data.append(serialize_to_list(v)) - elif issubclass(typ, list): - data.append( - TypeAdapter( - info.annotation, config=ConfigDict(arbitrary_types_allowed=True) - ).dump_python(v) - ) - elif issubclass(typ, datetime.datetime): - data.append([v, 0]) - elif issubclass(typ, Enum): - list_ = list(typ) - data.append(list_.index(v) if v in list_ else v.value) - else: - data.append(v) + data.append(serialize_msgpack(v=getattr(value, field))) return data diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index 4e2c9d6..85292df 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -2,13 +2,15 @@ from __future__ import annotations from abc import abstractmethod import asyncio +from enum import Enum +import inspect import time from typing import Any from app.config import settings from app.exception import InvokeException from app.log import logger -from app.models.signalr import UserState +from app.models.signalr import UserState, _by_index from app.signalr.packet import ( ClosePacket, CompletionPacket, @@ -265,6 +267,10 @@ class Hub[TState: UserState]: continue if issubclass(param.annotation, BaseModel): call_params.append(param.annotation.model_validate(args.pop(0))) + elif inspect.isclass(param.annotation) and issubclass( + param.annotation, Enum + ): + call_params.append(_by_index(args.pop(0), param.annotation)) else: call_params.append(args.pop(0)) return await method_(client, *call_params) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index 387231c..de5ce8a 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -8,6 +8,8 @@ from typing import ( Protocol as TypingProtocol, ) +from app.models.signalr import serialize_msgpack + import msgpack_lazer_api as m SEP = b"\x1e" @@ -151,7 +153,7 @@ class MsgpackProtocol: ] ) if packet.arguments is not None: - payload.append(packet.arguments) + payload.append([serialize_msgpack(arg) for arg in packet.arguments]) if packet.stream_ids is not None: payload.append(packet.stream_ids) elif isinstance(packet, CompletionPacket): From 41631b839f35befc1962793d5d1c828174aaa5b5 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 15:02:12 +0000 Subject: [PATCH 14/65] fix(user): last_visit is nullable --- app/database/lazer_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/database/lazer_user.py b/app/database/lazer_user.py index 3bd751b..2717c3a 100644 --- a/app/database/lazer_user.py +++ b/app/database/lazer_user.py @@ -66,7 +66,7 @@ class UserBase(UTCBaseModel, SQLModel): is_active: bool = True is_bot: bool = False is_supporter: bool = False - last_visit: datetime = Field( + last_visit: datetime | None = Field( default=datetime.now(UTC), sa_column=Column(DateTime(timezone=True)) ) pm_friends_only: bool = False From b7bc87b8b63543e75db317f9ac52b73fe8cbae9c Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 3 Aug 2025 11:01:25 +0000 Subject: [PATCH 15/65] fix(signalr): fix SignalRMeta cannot be read --- app/models/metadata_hub.py | 14 +++++++------- app/models/spectator_hub.py | 8 ++++---- app/signalr/packet.py | 14 +++++++++----- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index 3206d03..a678d7f 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import IntEnum -from typing import ClassVar, Literal +from typing import Annotated, ClassVar, Literal from app.models.signalr import SignalRMeta, SignalRUnionMessage, UserState @@ -100,12 +100,12 @@ UserActivity = ( class UserPresence(BaseModel): - activity: UserActivity | None = Field( - default=None, metadata=SignalRMeta(use_upper_case=True) - ) - status: OnlineStatus | None = Field( - default=None, metadata=SignalRMeta(use_upper_case=True) - ) + activity: Annotated[ + UserActivity | None, Field(default=None), SignalRMeta(use_upper_case=True) + ] + status: Annotated[ + OnlineStatus | None, Field(default=None), SignalRMeta(use_upper_case=True) + ] @property def pushable(self) -> bool: diff --git a/app/models/spectator_hub.py b/app/models/spectator_hub.py index a9e9042..9f35932 100644 --- a/app/models/spectator_hub.py +++ b/app/models/spectator_hub.py @@ -2,7 +2,7 @@ from __future__ import annotations import datetime from enum import IntEnum -from typing import Any +from typing import Annotated, Any from app.models.beatmap import BeatmapRankStatus from app.models.mods import APIMod @@ -89,9 +89,9 @@ class LegacyReplayFrame(BaseModel): mouse_y: float | None = None button_state: int - header: FrameHeader | None = Field( - default=None, metadata=[SignalRMeta(member_ignore=True)] - ) + header: Annotated[ + FrameHeader | None, Field(default=None), SignalRMeta(member_ignore=True) + ] class FrameDataBundle(BaseModel): diff --git a/app/signalr/packet.py b/app/signalr/packet.py index be98c39..70c2276 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -126,15 +126,19 @@ class MsgpackProtocol: def process_object(v: Any, typ: type[BaseModel]) -> Any: if isinstance(v, list): d = {} - for i, f in enumerate(typ.model_fields.items()): - field, info = f - if info.exclude: + i = 0 + for field, info in typ.model_fields.items(): + metadata = next( + (m for m in info.metadata if isinstance(m, SignalRMeta)), None + ) + if metadata and metadata.member_ignore: continue anno = info.annotation if anno is None: d[camel_to_snake(field)] = v[i] - continue - d[field] = MsgpackProtocol.validate_object(v[i], anno) + else: + d[field] = MsgpackProtocol.validate_object(v[i], anno) + i += 1 return d return v From 2600fa499f05d266072fb447cd708af4587fc1c9 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 3 Aug 2025 12:53:22 +0000 Subject: [PATCH 16/65] feat(multiplayer): support play WIP --- app/database/playlists.py | 18 +-- app/database/room.py | 2 +- app/database/score.py | 7 +- app/models/mods.py | 12 -- app/models/multiplayer_hub.py | 282 ++++++++++++++++++++++----------- app/models/room.py | 9 ++ app/models/signalr.py | 1 + app/router/score.py | 219 ++++++++++++++++++------- app/signalr/hub/multiplayer.py | 262 ++++++++++++++++++++++++++++-- app/signalr/packet.py | 46 +++++- app/utils.py | 4 +- 11 files changed, 666 insertions(+), 196 deletions(-) diff --git a/app/database/playlists.py b/app/database/playlists.py index 10ad86b..328f17d 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import TYPE_CHECKING from app.models.model import UTCBaseModel -from app.models.mods import APIMod, msgpack_to_apimod +from app.models.mods import APIMod from app.models.multiplayer_hub import PlaylistItem from .beatmap import Beatmap, BeatmapResp @@ -79,10 +79,10 @@ class Playlist(PlaylistBase, table=True): owner_id=playlist.owner_id, ruleset_id=playlist.ruleset_id, beatmap_id=playlist.beatmap_id, - required_mods=[msgpack_to_apimod(mod) for mod in playlist.required_mods], - allowed_mods=[msgpack_to_apimod(mod) for mod in playlist.allowed_mods], + required_mods=playlist.required_mods, + allowed_mods=playlist.allowed_mods, expired=playlist.expired, - playlist_order=playlist.order, + playlist_order=playlist.playlist_order, played_at=playlist.played_at, freestyle=playlist.freestyle, room_id=room_id, @@ -99,14 +99,10 @@ class Playlist(PlaylistBase, table=True): db_playlist.owner_id = playlist.owner_id db_playlist.ruleset_id = playlist.ruleset_id db_playlist.beatmap_id = playlist.beatmap_id - db_playlist.required_mods = [ - msgpack_to_apimod(mod) for mod in playlist.required_mods - ] - db_playlist.allowed_mods = [ - msgpack_to_apimod(mod) for mod in playlist.allowed_mods - ] + db_playlist.required_mods = playlist.required_mods + db_playlist.allowed_mods = playlist.allowed_mods db_playlist.expired = playlist.expired - db_playlist.playlist_order = playlist.order + db_playlist.playlist_order = playlist.playlist_order db_playlist.played_at = playlist.played_at db_playlist.freestyle = playlist.freestyle await session.commit() diff --git a/app/database/room.py b/app/database/room.py index 8eb882d..80457b6 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -125,7 +125,7 @@ class RoomResp(RoomBase): type=room.settings.match_type, queue_mode=room.settings.queue_mode, auto_skip=room.settings.auto_skip, - auto_start_duration=room.settings.auto_start_duration, + auto_start_duration=int(room.settings.auto_start_duration.total_seconds()), status=server_room.status, category=server_room.category, # duration = room.settings.duration, diff --git a/app/database/score.py b/app/database/score.py index 1bd5978..abc3d75 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -91,7 +91,7 @@ class ScoreBase(AsyncAttrs, SQLModel, UTCBaseModel): # optional # TODO: current_user_attributes - position: int | None = Field(default=None) # multiplayer + # position: int | None = Field(default=None) # multiplayer class Score(ScoreBase, table=True): @@ -162,6 +162,7 @@ class ScoreResp(ScoreBase): maximum_statistics: ScoreStatistics | None = None rank_global: int | None = None rank_country: int | None = None + position: int = 1 # TODO @classmethod async def from_db(cls, session: AsyncSession, score: Score) -> "ScoreResp": @@ -618,6 +619,8 @@ async def process_score( fetcher: "Fetcher", session: AsyncSession, redis: Redis, + item_id: int | None = None, + room_id: int | None = None, ) -> Score: assert user.id can_get_pp = info.passed and ranked and mods_can_get_pp(info.ruleset_id, info.mods) @@ -649,6 +652,8 @@ async def process_score( nsmall_tick_hit=info.statistics.get(HitResult.SMALL_TICK_HIT, 0), nlarge_tick_hit=info.statistics.get(HitResult.LARGE_TICK_HIT, 0), nslider_tail_hit=info.statistics.get(HitResult.SLIDER_TAIL_HIT, 0), + playlist_item_id=item_id, + room_id=room_id, ) if can_get_pp: beatmap_raw = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id) diff --git a/app/models/mods.py b/app/models/mods.py index 4b20138..299a05f 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -5,8 +5,6 @@ from typing import Literal, NotRequired, TypedDict from app.path import STATIC_DIR -from msgpack_lazer_api import APIMod as MsgpackAPIMod - class APIMod(TypedDict): acronym: str @@ -169,13 +167,3 @@ def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool: if expected_value != NO_CHECK and value != expected_value: return False return True - - -def msgpack_to_apimod(mod: MsgpackAPIMod) -> APIMod: - """ - Convert a MsgpackAPIMod to an APIMod. - """ - return APIMod( - acronym=mod.acronym, - settings=mod.settings, - ) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 9bccb71..ba8a050 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -1,13 +1,16 @@ from __future__ import annotations -from dataclasses import dataclass -import datetime -from typing import TYPE_CHECKING, Annotated, Any, Literal +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal from app.database.beatmap import Beatmap from app.dependencies.database import engine from app.exception import InvokeException +from .mods import APIMod from .room import ( DownloadState, MatchType, @@ -18,15 +21,14 @@ from .room import ( RoomStatus, ) from .signalr import ( - EnumByIndex, - MessagePackArrayModel, + SignalRMeta, + SignalRUnionMessage, UserState, - msgpack_union, - msgpack_union_dump, ) -from msgpack_lazer_api import APIMod -from pydantic import Field, field_serializer, field_validator +from pydantic import BaseModel, Field +from sqlalchemy import update +from sqlmodel import col from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: @@ -40,37 +42,37 @@ class MultiplayerClientState(UserState): room_id: int = 0 -class MultiplayerRoomSettings(MessagePackArrayModel): +class MultiplayerRoomSettings(BaseModel): name: str = "Unnamed Room" - playlist_item_id: int = 0 + playlist_item_id: Annotated[int, Field(default=0), SignalRMeta(use_abbr=False)] password: str = "" - match_type: Annotated[MatchType, EnumByIndex(MatchType)] = MatchType.HEAD_TO_HEAD - queue_mode: Annotated[QueueMode, EnumByIndex(QueueMode)] = QueueMode.HOST_ONLY - auto_start_duration: int = 0 + match_type: MatchType = MatchType.HEAD_TO_HEAD + queue_mode: QueueMode = QueueMode.HOST_ONLY + auto_start_duration: timedelta = timedelta(seconds=0) auto_skip: bool = False -class BeatmapAvailability(MessagePackArrayModel): - state: Annotated[DownloadState, EnumByIndex(DownloadState)] = DownloadState.UNKNOWN +class BeatmapAvailability(BaseModel): + state: DownloadState = DownloadState.UNKNOWN progress: float | None = None -class _MatchUserState(MessagePackArrayModel): ... +class _MatchUserState(SignalRUnionMessage): ... class TeamVersusUserState(_MatchUserState): team_id: int - type: Literal[0] = Field(0, exclude=True) + union_type: ClassVar[Literal[0]] = 0 MatchUserState = TeamVersusUserState -class _MatchRoomState(MessagePackArrayModel): ... +class _MatchRoomState(SignalRUnionMessage): ... -class MultiplayerTeam(MessagePackArrayModel): +class MultiplayerTeam(BaseModel): id: int name: str @@ -83,24 +85,24 @@ class TeamVersusRoomState(_MatchRoomState): ] ) - type: Literal[0] = Field(0, exclude=True) + union_type: ClassVar[Literal[0]] = 0 MatchRoomState = TeamVersusRoomState -class PlaylistItem(MessagePackArrayModel): - id: int +class PlaylistItem(BaseModel): + id: Annotated[int, Field(default=0), SignalRMeta(use_abbr=False)] owner_id: int beatmap_id: int - checksum: str + beatmap_checksum: str ruleset_id: int required_mods: list[APIMod] = Field(default_factory=list) allowed_mods: list[APIMod] = Field(default_factory=list) expired: bool - order: int - played_at: datetime.datetime | None = None - star: float + playlist_order: int + played_at: datetime | None = None + star_rating: float freestyle: bool def validate_user_mods( @@ -127,7 +129,10 @@ class PlaylistItem(MessagePackArrayModel): # Check if mods are valid for the ruleset for mod in proposed_mods: - if ruleset_key not in API_MODS or mod.acronym not in API_MODS[ruleset_key]: + if ( + ruleset_key not in API_MODS + or mod["acronym"] not in API_MODS[ruleset_key] + ): all_proposed_valid = False continue valid_mods.append(mod) @@ -136,35 +141,35 @@ class PlaylistItem(MessagePackArrayModel): incompatible_mods = set() final_valid_mods = [] for mod in valid_mods: - if mod.acronym in incompatible_mods: + if mod["acronym"] in incompatible_mods: all_proposed_valid = False continue - setting_mods = API_MODS[ruleset_key].get(mod.acronym) + setting_mods = API_MODS[ruleset_key].get(mod["acronym"]) if setting_mods: incompatible_mods.update(setting_mods["IncompatibleMods"]) final_valid_mods.append(mod) # If not freestyle, check against allowed mods if not self.freestyle: - allowed_acronyms = {mod.acronym for mod in self.allowed_mods} + allowed_acronyms = {mod["acronym"] for mod in self.allowed_mods} filtered_valid_mods = [] for mod in final_valid_mods: - if mod.acronym not in allowed_acronyms: + if mod["acronym"] not in allowed_acronyms: all_proposed_valid = False else: filtered_valid_mods.append(mod) final_valid_mods = filtered_valid_mods # Check compatibility with required mods - required_mod_acronyms = {mod.acronym for mod in self.required_mods} + required_mod_acronyms = {mod["acronym"] for mod in self.required_mods} all_mod_acronyms = { - mod.acronym for mod in final_valid_mods + mod["acronym"] for mod in final_valid_mods } | required_mod_acronyms # Check for incompatibility between required and user mods filtered_valid_mods = [] for mod in final_valid_mods: - mod_acronym = mod.acronym + mod_acronym = mod["acronym"] is_compatible = True for other_acronym in all_mod_acronyms: @@ -181,23 +186,29 @@ class PlaylistItem(MessagePackArrayModel): return all_proposed_valid, filtered_valid_mods + def clone(self) -> "PlaylistItem": + copy = self.model_copy() + copy.required_mods = list(self.required_mods) + copy.allowed_mods = list(self.allowed_mods) + return copy -class _MultiplayerCountdown(MessagePackArrayModel): - id: int - remaining: int - is_exclusive: bool + +class _MultiplayerCountdown(BaseModel): + id: int = 0 + remaining: timedelta + is_exclusive: bool = False class MatchStartCountdown(_MultiplayerCountdown): - type: Literal[0] = Field(0, exclude=True) + union_type: ClassVar[Literal[0]] = 0 class ForceGameplayStartCountdown(_MultiplayerCountdown): - type: Literal[1] = Field(1, exclude=True) + union_type: ClassVar[Literal[1]] = 1 class ServerShuttingDownCountdown(_MultiplayerCountdown): - type: Literal[2] = Field(2, exclude=True) + union_type: ClassVar[Literal[2]] = 2 MultiplayerCountdown = ( @@ -205,11 +216,9 @@ MultiplayerCountdown = ( ) -class MultiplayerRoomUser(MessagePackArrayModel): +class MultiplayerRoomUser(BaseModel): user_id: int - state: Annotated[MultiplayerUserState, EnumByIndex(MultiplayerUserState)] = ( - MultiplayerUserState.IDLE - ) + state: MultiplayerUserState = MultiplayerUserState.IDLE availability: BeatmapAvailability = BeatmapAvailability( state=DownloadState.UNKNOWN, progress=None ) @@ -218,50 +227,33 @@ class MultiplayerRoomUser(MessagePackArrayModel): ruleset_id: int | None = None # freestyle beatmap_id: int | None = None # freestyle - @field_validator("match_state", mode="before") - def union_validate(v: Any): - if isinstance(v, list): - return msgpack_union(v) - return v - @field_serializer("match_state") - def union_serialize(v: Any): - return msgpack_union_dump(v) - - -class MultiplayerRoom(MessagePackArrayModel): +class MultiplayerRoom(BaseModel): room_id: int - state: Annotated[MultiplayerRoomState, EnumByIndex(MultiplayerRoomState)] + state: MultiplayerRoomState settings: MultiplayerRoomSettings users: list[MultiplayerRoomUser] = Field(default_factory=list) host: MultiplayerRoomUser | None = None match_state: MatchRoomState | None = None playlist: list[PlaylistItem] = Field(default_factory=list) - active_cooldowns: list[MultiplayerCountdown] = Field(default_factory=list) + active_countdowns: list[MultiplayerCountdown] = Field(default_factory=list) channel_id: int - @field_validator("match_state", mode="before") - def union_validate(v: Any): - if isinstance(v, list): - return msgpack_union(v) - return v - - @field_serializer("match_state") - def union_serialize(v: Any): - return msgpack_union_dump(v) - class MultiplayerQueue: - def __init__(self, room: "ServerMultiplayerRoom", hub: "MultiplayerHub"): + def __init__(self, room: "ServerMultiplayerRoom"): self.server_room = room - self.hub = hub self.current_index = 0 + @property + def hub(self) -> "MultiplayerHub": + return self.server_room.hub + @property def upcoming_items(self): return sorted( (item for item in self.room.playlist if not item.expired), - key=lambda i: i.order, + key=lambda i: i.playlist_order, ) @property @@ -323,9 +315,9 @@ class MultiplayerQueue: ) async with AsyncSession(engine) as session: for idx, item in enumerate(ordered_active_items): - if item.order == idx: + if item.playlist_order == idx: continue - item.order = idx + item.playlist_order = idx await Playlist.update(item, self.room.room_id, session) await self.hub.playlist_changed( self.server_room, item, beatmap_changed=False @@ -338,7 +330,7 @@ class MultiplayerQueue: if upcoming_items else max( self.room.playlist, - key=lambda i: i.played_at or datetime.datetime.min, + key=lambda i: i.played_at or datetime.min, ) ) self.current_index = self.room.playlist.index(next_item) @@ -356,14 +348,7 @@ class MultiplayerQueue: limit = HOST_LIMIT if is_host else PER_USER_LIMIT if ( - len( - list( - filter( - lambda x: x.owner_id == user.user_id, - self.room.playlist, - ) - ) - ) + len([True for u in self.room.playlist if u.owner_id == user.user_id]) >= limit ): raise InvokeException(f"You can only have {limit} items in the queue") @@ -376,11 +361,11 @@ class MultiplayerQueue: beatmap = await session.get(Beatmap, item.beatmap_id) if beatmap is None: raise InvokeException("Beatmap not found") - if item.checksum != beatmap.checksum: + if item.beatmap_checksum != beatmap.checksum: raise InvokeException("Checksum mismatch") # TODO: mods validation item.owner_id = user.user_id - item.star = float( + item.star_rating = float( beatmap.difficulty_rating ) # FIXME: beatmap use decimal await Playlist.add_to_db(item, self.room.room_id, session) @@ -400,7 +385,7 @@ class MultiplayerQueue: beatmap = await session.get(Beatmap, item.beatmap_id) if beatmap is None: raise InvokeException("Beatmap not found") - if item.checksum != beatmap.checksum: + if item.beatmap_checksum != beatmap.checksum: raise InvokeException("Checksum mismatch") existing_item = next( @@ -423,8 +408,8 @@ class MultiplayerQueue: # TODO: mods validation item.owner_id = user.user_id - item.star = float(beatmap.difficulty_rating) - item.order = existing_item.order + item.star_rating = float(beatmap.difficulty_rating) + item.playlist_order = existing_item.playlist_order await Playlist.update(item, self.room.room_id, session) @@ -437,7 +422,8 @@ class MultiplayerQueue: await self.hub.playlist_changed( self.server_room, item, - beatmap_changed=item.checksum != existing_item.checksum, + beatmap_changed=item.beatmap_checksum + != existing_item.beatmap_checksum, ) async def remove_item(self, playlist_item_id: int, user: MultiplayerRoomUser): @@ -477,12 +463,46 @@ class MultiplayerQueue: await self.update_current_item() await self.hub.playlist_removed(self.server_room, item.id) + async def finish_current_item(self): + from app.database import Playlist + + async with AsyncSession(engine) as session: + played_at = datetime.now(UTC) + await session.execute( + update(Playlist) + .where( + col(Playlist.id) == self.current_item.id, + col(Playlist.room_id) == self.room.room_id, + ) + .values(expired=True, played_at=played_at) + ) + self.room.playlist[self.current_index].expired = True + self.room.playlist[self.current_index].played_at = played_at + await self.hub.playlist_changed(self.server_room, self.current_item, True) + await self.update_order() + if self.room.settings.queue_mode == QueueMode.HOST_ONLY and all( + playitem.expired for playitem in self.room.playlist + ): + assert self.room.host + await self.add_item(self.current_item.clone(), self.room.host) + @property def current_item(self): - """Get the current playlist item""" - current_id = self.room.settings.playlist_item_id - return next( - (item for item in self.room.playlist if item.id == current_id), + return self.room.playlist[self.current_index] + + +@dataclass +class CountdownInfo: + countdown: MultiplayerCountdown + duration: timedelta + task: asyncio.Task | None = None + + def __init__(self, countdown: MultiplayerCountdown): + self.countdown = countdown + self.duration = ( + countdown.remaining + if countdown.remaining > timedelta(seconds=0) + else timedelta(seconds=0) ) @@ -491,5 +511,79 @@ class ServerMultiplayerRoom: room: MultiplayerRoom category: RoomCategory status: RoomStatus - start_at: datetime.datetime + start_at: datetime + hub: "MultiplayerHub" queue: MultiplayerQueue | None = None + _next_countdown_id: int = 0 + _countdown_id_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + _tracked_countdown: dict[int, CountdownInfo] = field(default_factory=dict) + + async def get_next_countdown_id(self) -> int: + async with self._countdown_id_lock: + self._next_countdown_id += 1 + return self._next_countdown_id + + async def start_countdown( + self, + countdown: MultiplayerCountdown, + on_complete: Callable[["ServerMultiplayerRoom"], Awaitable[Any]] | None = None, + ): + async def _countdown_task(self: "ServerMultiplayerRoom"): + await asyncio.sleep(info.duration.total_seconds()) + await self.stop_countdown(countdown) + if on_complete is not None: + await on_complete(self) + + if countdown.is_exclusive: + await self.stop_all_countdowns() + + countdown.id = await self.get_next_countdown_id() + info = CountdownInfo(countdown) + self.room.active_countdowns.append(info.countdown) + self._tracked_countdown[countdown.id] = info + await self.hub.send_match_event( + self, CountdownStartedEvent(countdown=info.countdown) + ) + info.task = asyncio.create_task(_countdown_task(self)) + + async def stop_countdown(self, countdown: MultiplayerCountdown): + info = next( + ( + info + for info in self._tracked_countdown.values() + if info.countdown.id == countdown.id + ), + None, + ) + if info is None: + return + if info.task is not None and not info.task.done(): + info.task.cancel() + del self._tracked_countdown[countdown.id] + self.room.active_countdowns.remove(countdown) + await self.hub.send_match_event(self, CountdownStoppedEvent(id=countdown.id)) + + async def stop_all_countdowns(self): + for countdown in list(self._tracked_countdown.values()): + await self.stop_countdown(countdown.countdown) + + self._tracked_countdown.clear() + self.room.active_countdowns.clear() + + +class _MatchServerEvent(BaseModel): ... + + +class CountdownStartedEvent(_MatchServerEvent): + countdown: MultiplayerCountdown + + type: Literal[0] = Field(default=0, exclude=True) + + +class CountdownStoppedEvent(_MatchServerEvent): + id: int + + type: Literal[1] = Field(default=1, exclude=True) + + +MatchServerEvent = CountdownStartedEvent | CountdownStoppedEvent diff --git a/app/models/room.py b/app/models/room.py index 42f897c..392562a 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -53,6 +53,15 @@ class MultiplayerUserState(str, Enum): RESULTS = "results" SPECTATING = "spectating" + @property + def is_playing(self) -> bool: + return self in { + self.WAITING_FOR_LOAD, + self.PLAYING, + self.READY_FOR_GAMEPLAY, + self.LOADED, + } + class DownloadState(str, Enum): UNKNOWN = "unknown" diff --git a/app/models/signalr.py b/app/models/signalr.py index de66e30..7116ea0 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -14,6 +14,7 @@ class SignalRMeta: member_ignore: bool = False # implement of IgnoreMember (msgpack) attribute json_ignore: bool = False # implement of JsonIgnore (json) attribute use_upper_case: bool = False # use upper CamelCase for field names + use_abbr: bool = True class SignalRUnionMessage(BaseModel): diff --git a/app/router/score.py b/app/router/score.py index 2f1303e..b50911d 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -1,10 +1,19 @@ from __future__ import annotations -from app.database import Beatmap, Score, ScoreResp, ScoreToken, ScoreTokenResp, User +from app.database import ( + Beatmap, + Playlist, + Score, + ScoreResp, + ScoreToken, + ScoreTokenResp, + User, +) from app.database.score import get_leaderboard, process_score, process_user from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user +from app.fetcher import Fetcher from app.models.beatmap import BeatmapRankStatus from app.models.score import ( INT_TO_MODE, @@ -13,6 +22,7 @@ from app.models.score import ( Rank, SoloScoreSubmissionInfo, ) +from app.signalr.hub import MultiplayerHubs from .api_router import router @@ -24,6 +34,68 @@ from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession +async def submit_score( + info: SoloScoreSubmissionInfo, + beatmap: int, + token: int, + current_user: User, + db: AsyncSession, + redis: Redis, + fetcher: Fetcher, + item_id: int | None = None, + room_id: int | None = None, +): + if not info.passed: + info.rank = Rank.F + score_token = ( + await db.exec( + select(ScoreToken) + .options(joinedload(ScoreToken.beatmap)) # pyright: ignore[reportArgumentType] + .where(ScoreToken.id == token) + ) + ).first() + if not score_token or score_token.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Score token not found") + if score_token.score_id: + score = ( + await db.exec( + select(Score).where( + Score.id == score_token.score_id, + Score.user_id == current_user.id, + ) + ) + ).first() + if not score: + raise HTTPException(status_code=404, detail="Score not found") + else: + beatmap_status = ( + await db.exec(select(Beatmap.beatmap_status).where(Beatmap.id == beatmap)) + ).first() + if beatmap_status is None: + raise HTTPException(status_code=404, detail="Beatmap not found") + ranked = beatmap_status in { + BeatmapRankStatus.RANKED, + BeatmapRankStatus.APPROVED, + } + score = await process_score( + current_user, + beatmap, + ranked, + score_token, + info, + fetcher, + db, + redis, + ) + await db.refresh(current_user) + score_id = score.id + score_token.score_id = score_id + await process_user(db, current_user, score, ranked) + score = (await db.exec(select(Score).where(Score.id == score_id))).first() + assert score is not None + return await ScoreResp.from_db(db, score) + + class BeatmapScores(BaseModel): scores: list[ScoreResp] userScore: ScoreResp | None = None @@ -97,9 +169,10 @@ async def get_user_beatmap_score( status_code=404, detail=f"Cannot find user {user}'s score on this beatmap" ) else: + resp = await ScoreResp.from_db(db, user_score) return BeatmapUserScore( - position=user_score.position if user_score.position is not None else 0, - score=await ScoreResp.from_db(db, user_score), + position=resp.rank_global or 0, + score=resp, ) @@ -173,55 +246,95 @@ async def submit_solo_score( redis: Redis = Depends(get_redis), fetcher=Depends(get_fetcher), ): - if not info.passed: - info.rank = Rank.F - async with db: - score_token = ( - await db.exec( - select(ScoreToken) - .options(joinedload(ScoreToken.beatmap)) # pyright: ignore[reportArgumentType] - .where(ScoreToken.id == token, ScoreToken.user_id == current_user.id) + return await submit_score(info, beatmap, token, current_user, db, redis, fetcher) + + +@router.post( + "/rooms/{room_id}/playlist/{playlist_id}/scores", response_model=ScoreTokenResp +) +async def create_playlist_score( + room_id: int, + playlist_id: int, + beatmap_id: int = Form(), + beatmap_hash: str = Form(), + ruleset_id: int = Form(..., ge=0, le=3), + version_hash: str = Form(""), + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +): + room = MultiplayerHubs.rooms[room_id] + if not room: + raise HTTPException(status_code=404, detail="Room not found") + item = ( + await session.exec( + select(Playlist).where( + Playlist.id == playlist_id, Playlist.room_id == room_id ) - ).first() - if not score_token or score_token.user_id != current_user.id: - raise HTTPException(status_code=404, detail="Score token not found") - if score_token.score_id: - score = ( - await db.exec( - select(Score).where( - Score.id == score_token.score_id, - Score.user_id == current_user.id, - ) - ) - ).first() - if not score: - raise HTTPException(status_code=404, detail="Score not found") - else: - beatmap_status = ( - await db.exec( - select(Beatmap.beatmap_status).where(Beatmap.id == beatmap) - ) - ).first() - if beatmap_status is None: - raise HTTPException(status_code=404, detail="Beatmap not found") - ranked = beatmap_status in { - BeatmapRankStatus.RANKED, - BeatmapRankStatus.APPROVED, - } - score = await process_score( - current_user, - beatmap, - ranked, - score_token, - info, - fetcher, - db, - redis, + ) + ).first() + if not item: + raise HTTPException(status_code=404, detail="Playlist not found") + + # validate + if not item.freestyle: + if item.ruleset_id != ruleset_id: + raise HTTPException( + status_code=400, detail="Ruleset mismatch in playlist item" ) - await db.refresh(current_user) - score_id = score.id - score_token.score_id = score_id - await process_user(db, current_user, score, ranked) - score = (await db.exec(select(Score).where(Score.id == score_id))).first() - assert score is not None - return await ScoreResp.from_db(db, score) + if item.beatmap_id != beatmap_id: + raise HTTPException( + status_code=400, detail="Beatmap ID mismatch in playlist item" + ) + # TODO: max attempts + if item.expired: + raise HTTPException(status_code=400, detail="Playlist item has expired") + if item.played_at: + raise HTTPException( + status_code=400, detail="Playlist item has already been played" + ) + # 这里应该不用验证mod了吧。。。 + + score_token = ScoreToken( + user_id=current_user.id, + beatmap_id=beatmap_id, + ruleset_id=INT_TO_MODE[ruleset_id], + playlist_item_id=playlist_id, + ) + session.add(score_token) + await session.commit() + await session.refresh(score_token) + return ScoreTokenResp.from_db(score_token) + + +@router.put("/rooms/{room_id}/playlist/{playlist_id}/scores/{token}") +async def submit_playlist_score( + room_id: int, + playlist_id: int, + token: int, + info: SoloScoreSubmissionInfo, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), + fetcher: Fetcher = Depends(get_fetcher), +): + item = ( + await session.exec( + select(Playlist).where( + Playlist.id == playlist_id, Playlist.room_id == room_id + ) + ) + ).first() + if not item: + raise HTTPException(status_code=404, detail="Playlist item not found") + score_resp = await submit_score( + info, + item.beatmap_id, + token, + current_user, + session, + redis, + fetcher, + item.id, + room_id, + ) + return score_resp diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index bd34be0..21f192c 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +from datetime import timedelta from typing import override from app.database import Room @@ -8,8 +10,11 @@ from app.database.playlists import Playlist from app.dependencies.database import engine from app.exception import InvokeException from app.log import logger +from app.models.mods import APIMod from app.models.multiplayer_hub import ( BeatmapAvailability, + ForceGameplayStartCountdown, + MatchServerEvent, MultiplayerClientState, MultiplayerQueue, MultiplayerRoom, @@ -17,16 +22,22 @@ from app.models.multiplayer_hub import ( PlaylistItem, ServerMultiplayerRoom, ) -from app.models.room import RoomCategory, RoomStatus +from app.models.room import ( + DownloadState, + MultiplayerRoomState, + MultiplayerUserState, + RoomCategory, + RoomStatus, +) from app.models.score import GameMode -from app.models.signalr import serialize_to_list from .hub import Client, Hub -from msgpack_lazer_api import APIMod from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +GAMEPLAY_LOAD_TIMEOUT = 30 + class MultiplayerHub(Hub[MultiplayerClientState]): @override @@ -58,7 +69,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): type=room.settings.match_type, queue_mode=room.settings.queue_mode, auto_skip=room.settings.auto_skip, - auto_start_duration=room.settings.auto_start_duration, + auto_start_duration=int( + room.settings.auto_start_duration.total_seconds() + ), host_id=client.user_id, status=RoomStatus.IDLE, ) @@ -75,10 +88,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): category=RoomCategory.NORMAL, status=RoomStatus.IDLE, start_at=starts_at, + hub=self, ) queue = MultiplayerQueue( room=server_room, - hub=self, ) server_room.queue = queue self.rooms[room.room_id] = server_room @@ -86,6 +99,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): client, room.room_id, room.settings.password ) + async def JoinRoom(self, client: Client, room_id: int): + return self.JoinRoomWithPassword(client, room_id, "") + async def JoinRoomWithPassword(self, client: Client, room_id: int, password: str): logger.info(f"[MultiplayerHub] {client.user_id} joining room {room_id}") store = self.get_or_create_state(client) @@ -105,12 +121,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): # from CreateRoom room.host = user store.room_id = room_id - await self.broadcast_group_call( - self.group_id(room_id), "UserJoined", serialize_to_list(user) - ) + await self.broadcast_group_call(self.group_id(room_id), "UserJoined", user) room.users.append(user) self.add_to_group(client, self.group_id(room_id)) - return serialize_to_list(room) + return room async def ChangeBeatmapAvailability( self, client: Client, beatmap_availability: BeatmapAvailability @@ -132,12 +146,12 @@ class MultiplayerHub(Hub[MultiplayerClientState]): and availability.progress == beatmap_availability.progress ): return - user.availability = availability + user.availability = beatmap_availability await self.broadcast_group_call( self.group_id(store.room_id), "UserBeatmapAvailabilityChanged", user.user_id, - serialize_to_list(beatmap_availability), + (beatmap_availability), ) async def AddPlaylistItem(self, client: Client, item: PlaylistItem): @@ -198,14 +212,14 @@ class MultiplayerHub(Hub[MultiplayerClientState]): await self.broadcast_group_call( self.group_id(room.room.room_id), "SettingsChanged", - serialize_to_list(room.room.settings), + (room.room.settings), ) async def playlist_added(self, room: ServerMultiplayerRoom, item: PlaylistItem): await self.broadcast_group_call( self.group_id(room.room.room_id), "PlaylistItemAdded", - serialize_to_list(item), + (item), ) async def playlist_removed(self, room: ServerMultiplayerRoom, item_id: int): @@ -221,7 +235,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): await self.broadcast_group_call( self.group_id(room.room.room_id), "PlaylistItemChanged", - serialize_to_list(item), + (item), ) async def ChangeUserStyle( @@ -378,7 +392,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) if not is_valid: incompatible_mods = [ - mod.acronym for mod in new_mods if mod not in valid_mods + mod["acronym"] for mod in new_mods if mod not in valid_mods ] raise InvokeException( f"Incompatible mods were selected: {','.join(incompatible_mods)}" @@ -395,3 +409,221 @@ class MultiplayerHub(Hub[MultiplayerClientState]): user.user_id, valid_mods, ) + + async def validate_user_stare( + self, + room: ServerMultiplayerRoom, + old: MultiplayerUserState, + new: MultiplayerUserState, + ): + assert room.queue + match new: + case MultiplayerUserState.IDLE: + if old.is_playing: + raise InvokeException( + "Cannot return to idle without aborting gameplay." + ) + case MultiplayerUserState.READY: + if old != MultiplayerUserState.IDLE: + raise InvokeException(f"Cannot change state from {old} to {new}") + if room.queue.current_item.expired: + raise InvokeException( + "Cannot ready up while all items have been played." + ) + case MultiplayerUserState.WAITING_FOR_LOAD: + raise InvokeException("Cannot change state from {old} to {new}") + case MultiplayerUserState.LOADED: + if old != MultiplayerUserState.WAITING_FOR_LOAD: + raise InvokeException(f"Cannot change state from {old} to {new}") + case MultiplayerUserState.READY_FOR_GAMEPLAY: + if old != MultiplayerUserState.LOADED: + raise InvokeException(f"Cannot change state from {old} to {new}") + case MultiplayerUserState.PLAYING: + raise InvokeException("State is managed by the server.") + case MultiplayerUserState.FINISHED_PLAY: + if old != MultiplayerUserState.PLAYING: + raise InvokeException(f"Cannot change state from {old} to {new}") + case MultiplayerUserState.RESULTS: + raise InvokeException("Cannot change state from {old} to {new}") + case MultiplayerUserState.SPECTATING: + if old not in (MultiplayerUserState.IDLE, MultiplayerUserState.READY): + raise InvokeException(f"Cannot change state from {old} to {new}") + + async def ChangeState(self, client: Client, state: MultiplayerUserState): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + if user.state == state: + return + match state: + case MultiplayerUserState.IDLE: + if user.state.is_playing: + return + case MultiplayerUserState.LOADED | MultiplayerUserState.READY_FOR_GAMEPLAY: + if not user.state.is_playing: + return + await self.validate_user_stare( + server_room, + user.state, + state, + ) + await self.change_user_state(server_room, user, state) + if state == MultiplayerUserState.SPECTATING and ( + room.state == MultiplayerRoomState.PLAYING + or room.state == MultiplayerRoomState.WAITING_FOR_LOAD + ): + await self.call_noblock(client, "LoadRequested") + await self.update_room_state(server_room) + + async def change_user_state( + self, + room: ServerMultiplayerRoom, + user: MultiplayerRoomUser, + state: MultiplayerUserState, + ): + user.state = state + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "UserStateChanged", + user.user_id, + user.state, + ) + + async def update_room_state(self, room: ServerMultiplayerRoom): + match room.room.state: + case MultiplayerRoomState.WAITING_FOR_LOAD: + played_count = len( + [True for user in room.room.users if user.state.is_playing] + ) + ready_count = len( + [ + True + for user in room.room.users + if user.state == MultiplayerUserState.READY_FOR_GAMEPLAY + ] + ) + if played_count == ready_count: + await self.start_gameplay(room) + case MultiplayerRoomState.PLAYING: + assert room.queue + if all( + u.state != MultiplayerUserState.PLAYING for u in room.room.users + ): + for u in filter( + lambda u: u.state == MultiplayerUserState.FINISHED_PLAY, + room.room.users, + ): + await self.change_user_state( + room, u, MultiplayerUserState.RESULTS + ) + await self.change_room_state(room, MultiplayerRoomState.OPEN) + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "ResultsReady", + ) + await room.queue.finish_current_item() + + async def change_room_state( + self, room: ServerMultiplayerRoom, state: MultiplayerRoomState + ): + room.room.state = state + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "RoomStateChanged", + state, + ) + + async def StartMatch(self, client: Client): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + if room.host is None or room.host.user_id != client.user_id: + raise InvokeException("You are not the host of this room") + if any(u.state != MultiplayerUserState.READY for u in room.users): + raise InvokeException("Not all users are ready") + + await self.start_match(server_room) + + async def start_match(self, room: ServerMultiplayerRoom): + assert room.queue + if room.room.state != MultiplayerRoomState.OPEN: + raise InvokeException("Can't start match when already in a running state.") + if room.queue.current_item.expired: + raise InvokeException("Current playlist item is expired") + ready_users = [ + u + for u in room.room.users + if u.availability.state == DownloadState.LOCALLY_AVAILABLE + and ( + u.state == MultiplayerUserState.READY + or u.state == MultiplayerUserState.IDLE + ) + ] + await asyncio.gather( + *[ + self.change_user_state(room, u, MultiplayerUserState.WAITING_FOR_LOAD) + for u in ready_users + ] + ) + await self.change_room_state( + room, + MultiplayerRoomState.WAITING_FOR_LOAD, + ) + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "LoadRequested", + ) + await room.start_countdown( + ForceGameplayStartCountdown( + remaining=timedelta(seconds=GAMEPLAY_LOAD_TIMEOUT) + ), + self.start_gameplay, + ) + + async def start_gameplay(self, room: ServerMultiplayerRoom): + assert room.queue + if room.room.state != MultiplayerRoomState.WAITING_FOR_LOAD: + raise InvokeException("Room is not ready for gameplay") + if room.queue.current_item.expired: + raise InvokeException("Current playlist item is expired") + playing = False + for user in room.room.users: + client = self.get_client_by_id(str(user.user_id)) + if client is None: + continue + + if user.state in ( + MultiplayerUserState.READY_FOR_GAMEPLAY, + MultiplayerUserState.LOADED, + ): + playing = True + await self.change_user_state(room, user, MultiplayerUserState.PLAYING) + await self.call_noblock(client, "GameplayStarted") + await self.change_room_state( + room, + (MultiplayerRoomState.PLAYING if playing else MultiplayerRoomState.OPEN), + ) + + async def send_match_event( + self, room: ServerMultiplayerRoom, event: MatchServerEvent + ): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "MatchEvent", + event, + ) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index 70c2276..9afb78d 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -97,6 +97,8 @@ class MsgpackProtocol: return [cls.serialize_msgpack(item) for item in v] elif issubclass(typ, datetime.datetime): return [v, 0] + elif issubclass(typ, datetime.timedelta): + return int(v.total_seconds()) elif isinstance(v, dict): return { cls.serialize_msgpack(k): cls.serialize_msgpack(value) @@ -213,6 +215,8 @@ class MsgpackProtocol: return typ.model_validate(obj=cls.process_object(v, typ)) elif inspect.isclass(typ) and issubclass(typ, datetime.datetime): return v[0] + elif inspect.isclass(typ) and issubclass(typ, datetime.timedelta): + return datetime.timedelta(seconds=int(v)) elif isinstance(v, list): return [cls.validate_object(item, get_args(typ)[0]) for item in v] elif inspect.isclass(typ) and issubclass(typ, Enum): @@ -296,21 +300,30 @@ class MsgpackProtocol: class JSONProtocol: @classmethod - def serialize_to_json(cls, v: Any): + def serialize_to_json(cls, v: Any, dict_key: bool = False): typ = v.__class__ if issubclass(typ, BaseModel): return cls.serialize_model(v) elif isinstance(v, dict): return { - cls.serialize_to_json(k): cls.serialize_to_json(value) + cls.serialize_to_json(k, True): cls.serialize_to_json(value) for k, value in v.items() } elif isinstance(v, list): return [cls.serialize_to_json(item) for item in v] elif isinstance(v, datetime.datetime): return v.isoformat() - elif isinstance(v, Enum): + elif isinstance(v, datetime.timedelta): + # d.hh:mm:ss + total_seconds = int(v.total_seconds()) + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return f"{hours:02}:{minutes:02}:{seconds:02}" + elif isinstance(v, Enum) and dict_key: return v.value + elif isinstance(v, Enum): + list_ = list(typ) + return list_.index(v) return v @classmethod @@ -322,9 +335,13 @@ class JSONProtocol: ) if metadata and metadata.json_ignore: continue - d[snake_to_camel(field, metadata.use_upper_case if metadata else False)] = ( - cls.serialize_to_json(getattr(v, field)) - ) + d[ + snake_to_camel( + field, + metadata.use_upper_case if metadata else False, + metadata.use_abbr if metadata else True, + ) + ] = cls.serialize_to_json(getattr(v, field)) if issubclass(v.__class__, SignalRUnionMessage): return { "$dtype": v.__class__.__name__, @@ -343,7 +360,11 @@ class JSONProtocol: ) if metadata and metadata.json_ignore: continue - value = v.get(snake_to_camel(field, not from_union)) + value = v.get( + snake_to_camel( + field, not from_union, metadata.use_abbr if metadata else True + ) + ) anno = typ.model_fields[field].annotation if anno is None: d[field] = value @@ -401,6 +422,17 @@ class JSONProtocol: return typ.model_validate(JSONProtocol.process_object(v, typ, from_union)) elif inspect.isclass(typ) and issubclass(typ, datetime.datetime): return datetime.datetime.fromisoformat(v) + elif inspect.isclass(typ) and issubclass(typ, datetime.timedelta): + # d.hh:mm:ss + parts = v.split(":") + if len(parts) == 3: + return datetime.timedelta( + hours=int(parts[0]), minutes=int(parts[1]), seconds=int(parts[2]) + ) + elif len(parts) == 2: + return datetime.timedelta(minutes=int(parts[0]), seconds=int(parts[1])) + elif len(parts) == 1: + return datetime.timedelta(seconds=int(parts[0])) elif isinstance(v, list): return [cls.validate_object(item, get_args(typ)[0]) for item in v] elif inspect.isclass(typ) and issubclass(typ, Enum): diff --git a/app/utils.py b/app/utils.py index 0d759a1..ac51b90 100644 --- a/app/utils.py +++ b/app/utils.py @@ -21,7 +21,7 @@ def camel_to_snake(name: str) -> str: return "".join(result) -def snake_to_camel(name: str, lower_case: bool = True) -> str: +def snake_to_camel(name: str, lower_case: bool = True, use_abbr: bool = True) -> str: """Convert a snake_case string to camelCase.""" if not name: return name @@ -47,7 +47,7 @@ def snake_to_camel(name: str, lower_case: bool = True) -> str: result = [] for part in parts: - if part.lower() in abbreviations: + if part.lower() in abbreviations and use_abbr: result.append(part.upper()) else: if result or not lower_case: From c2579e86ebc5595a2a8d8b65a646411953e568e8 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 3 Aug 2025 13:50:59 +0000 Subject: [PATCH 17/65] feat(multiplayer): supoort manage user (kick, transfer host, leave) --- app/signalr/hub/multiplayer.py | 116 ++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 21f192c..9350c34 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import UTC, datetime, timedelta from typing import override from app.database import Room @@ -33,7 +33,8 @@ from app.models.score import GameMode from .hub import Client, Hub -from sqlmodel import select +from sqlalchemy import update +from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession GAMEPLAY_LOAD_TIMEOUT = 30 @@ -627,3 +628,114 @@ class MultiplayerHub(Hub[MultiplayerClientState]): "MatchEvent", event, ) + + async def make_user_leave( + self, + client: Client, + room: ServerMultiplayerRoom, + user: MultiplayerRoomUser, + kicked: bool = False, + ): + self.remove_from_group(client, self.group_id(room.room.room_id)) + room.room.users.remove(user) + + if len(room.room.users) == 0: + await self.end_room(room) + await self.update_room_state(room) + if room.room.host and room.room.host.user_id == user.user_id: + next_host = room.room.users[0] + await self.set_host(room, next_host) + + if kicked: + await self.call_noblock(client, "UserKicked", user) + await self.broadcast_group_call( + self.group_id(room.room.room_id), "UserKicked", user + ) + else: + await self.broadcast_group_call( + self.group_id(room.room.room_id), "UserLeft", user + ) + + target_store = self.state.get(user.user_id) + if target_store: + target_store.room_id = 0 + + async def end_room(self, room: ServerMultiplayerRoom): + assert room.room.host + async with AsyncSession(engine) as session: + await session.execute( + update(Room) + .where(col(Room.id) == room.room.room_id) + .values( + name=room.room.settings.name, + ended_at=datetime.now(UTC), + type=room.room.settings.match_type, + queue_mode=room.room.settings.queue_mode, + auto_skip=room.room.settings.auto_skip, + auto_start_duration=int( + room.room.settings.auto_start_duration.total_seconds() + ), + host_id=room.room.host.user_id, + ) + ) + del self.rooms[room.room.room_id] + + async def LeaveRoom(self, client: Client): + store = self.get_or_create_state(client) + if store.room_id == 0: + return + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await self.make_user_leave(client, server_room, user) + + async def KickUser(self, client: Client, user_id: int): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + + if room.host is None or room.host.user_id != client.user_id: + raise InvokeException("You are not the host of this room") + + user = next((u for u in room.users if u.user_id == user_id), None) + if user is None: + raise InvokeException("User not found in this room") + + target_client = self.get_client_by_id(str(user.user_id)) + if target_client is None: + return + await self.make_user_leave(target_client, server_room, user, kicked=True) + + async def set_host(self, room: ServerMultiplayerRoom, user: MultiplayerRoomUser): + room.room.host = user + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "HostChanged", + user.user_id, + ) + + async def TransferHost(self, client: Client, user_id: int): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + + if room.host is None or room.host.user_id != client.user_id: + raise InvokeException("You are not the host of this room") + + new_host = next((u for u in room.users if u.user_id == user_id), None) + if new_host is None: + raise InvokeException("User not found in this room") + await self.set_host(server_room, new_host) From 1e304542bd4d61da3082a9d58aa029a47cca3fb2 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 3 Aug 2025 14:00:49 +0000 Subject: [PATCH 18/65] feat(multiplayer): supoort abort match --- app/models/multiplayer_hub.py | 6 ++++ app/signalr/hub/multiplayer.py | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index ba8a050..9c30523 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta +from enum import IntEnum from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal from app.database.beatmap import Beatmap @@ -587,3 +588,8 @@ class CountdownStoppedEvent(_MatchServerEvent): MatchServerEvent = CountdownStartedEvent | CountdownStoppedEvent + + +class GameplayAbortReason(IntEnum): + LOAD_TOOK_TOO_LONG = 0 + HOST_ABORTED = 1 diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 9350c34..1a2b332 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -14,6 +14,7 @@ from app.models.mods import APIMod from app.models.multiplayer_hub import ( BeatmapAvailability, ForceGameplayStartCountdown, + GameplayAbortReason, MatchServerEvent, MultiplayerClientState, MultiplayerQueue, @@ -615,6 +616,13 @@ class MultiplayerHub(Hub[MultiplayerClientState]): playing = True await self.change_user_state(room, user, MultiplayerUserState.PLAYING) await self.call_noblock(client, "GameplayStarted") + elif user.state == MultiplayerUserState.WAITING_FOR_LOAD: + await self.change_user_state(room, user, MultiplayerUserState.IDLE) + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "GameplayAborted", + GameplayAbortReason.LOAD_TOOK_TOO_LONG, + ) await self.change_room_state( room, (MultiplayerRoomState.PLAYING if playing else MultiplayerRoomState.OPEN), @@ -739,3 +747,57 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if new_host is None: raise InvokeException("User not found in this room") await self.set_host(server_room, new_host) + + async def AbortGameplay(self, client: Client): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + if not user.state.is_playing: + raise InvokeException("Cannot abort gameplay while not in a gameplay state") + + await self.change_user_state( + server_room, + user, + MultiplayerUserState.IDLE, + ) + await self.update_room_state(server_room) + + async def AbortMatch(self, client: Client): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + + if room.host is None or room.host.user_id != client.user_id: + raise InvokeException("You are not the host of this room") + + if ( + room.state != MultiplayerRoomState.PLAYING + or room.state == MultiplayerRoomState.WAITING_FOR_LOAD + ): + raise InvokeException("Room is not in a playable state") + + await asyncio.gather( + *[ + self.change_user_state(server_room, u, MultiplayerUserState.IDLE) + for u in room.users + if u.state.is_playing + ] + ) + await self.broadcast_group_call( + self.group_id(room.room_id), + "GameplayAborted", + GameplayAbortReason.HOST_ABORTED, + ) + await self.update_room_state(server_room) From 34bf2c6b324c3f16da4eb0bbd511d2e0fffa268e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 3 Aug 2025 15:14:30 +0000 Subject: [PATCH 19/65] feat(multiplayer): support change settings --- app/models/multiplayer_hub.py | 172 +++++++++++++++++++++++++++++++-- app/signalr/hub/multiplayer.py | 92 +++++++++++++++--- 2 files changed, 240 insertions(+), 24 deletions(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 9c30523..97148db 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -1,11 +1,12 @@ from __future__ import annotations +from abc import ABC, abstractmethod import asyncio from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from enum import IntEnum -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, override from app.database.beatmap import Beatmap from app.dependencies.database import engine @@ -291,7 +292,9 @@ class MultiplayerQueue: current_set.append(items[i]) if is_first_set: - current_set.sort(key=lambda item: (item.order, item.id)) + current_set.sort( + key=lambda item: (item.playlist_order, item.id) + ) ordered_active_items.extend(current_set) first_set_order_by_user_id = { item.owner_id: idx @@ -308,7 +311,7 @@ class MultiplayerQueue: is_first_set = False for idx, item in enumerate(ordered_active_items): - item.order = idx + item.playlist_order = idx case _: ordered_active_items = sorted( (item for item in self.room.playlist if not item.expired), @@ -487,6 +490,15 @@ class MultiplayerQueue: assert self.room.host await self.add_item(self.current_item.clone(), self.room.host) + async def update_queue_mode(self): + if self.room.settings.queue_mode == QueueMode.HOST_ONLY and all( + playitem.expired for playitem in self.room.playlist + ): + assert self.room.host + await self.add_item(self.current_item.clone(), self.room.host) + await self.update_order() + await self.update_current_item() + @property def current_item(self): return self.room.playlist[self.current_index] @@ -507,6 +519,125 @@ class CountdownInfo: ) +class _MatchRequest(SignalRUnionMessage): ... + + +class ChangeTeamRequest(_MatchRequest): + union_type: ClassVar[Literal[0]] = 0 + team_id: int + + +class StartMatchCountdownRequest(_MatchRequest): + union_type: ClassVar[Literal[1]] = 1 + duration: timedelta + + +class StopCountdownRequest(_MatchRequest): + union_type: ClassVar[Literal[2]] = 2 + id: int + + +MatchRequest = ChangeTeamRequest | StartMatchCountdownRequest | StopCountdownRequest + + +class MatchTypeHandler(ABC): + def __init__(self, room: "ServerMultiplayerRoom"): + self.room = room + self.hub = room.hub + + @abstractmethod + async def handle_join(self, user: MultiplayerRoomUser): ... + + @abstractmethod + async def handle_request( + self, user: MultiplayerRoomUser, request: MatchRequest + ): ... + + @abstractmethod + async def handle_leave(self, user: MultiplayerRoomUser): ... + + +class HeadToHeadHandler(MatchTypeHandler): + @override + async def handle_join(self, user: MultiplayerRoomUser): + if user.match_state is not None: + user.match_state = None + await self.hub.change_user_match_state(self.room, user) + + @override + async def handle_request( + self, user: MultiplayerRoomUser, request: MatchRequest + ): ... + + @override + async def handle_leave(self, user: MultiplayerRoomUser): ... + + +class TeamVersusHandler(MatchTypeHandler): + @override + def __init__(self, room: "ServerMultiplayerRoom"): + super().__init__(room) + self.state = TeamVersusRoomState() + room.room.match_state = self.state + task = asyncio.create_task(self.hub.change_room_match_state(self.room)) + self.hub.tasks.add(task) + task.add_done_callback(self.hub.tasks.discard) + + def _get_best_available_team(self) -> int: + for team in self.state.teams: + if all( + ( + user.match_state is None + or not isinstance(user.match_state, TeamVersusUserState) + or user.match_state.team_id != team.id + ) + for user in self.room.room.users + ): + return team.id + + from collections import defaultdict + + team_counts = defaultdict(int) + for user in self.room.room.users: + if user.match_state is not None and isinstance( + user.match_state, TeamVersusUserState + ): + team_counts[user.match_state.team_id] += 1 + + if team_counts: + min_count = min(team_counts.values()) + for team_id, count in team_counts.items(): + if count == min_count: + return team_id + return self.state.teams[0].id if self.state.teams else 0 + + @override + async def handle_join(self, user: MultiplayerRoomUser): + best_team_id = self._get_best_available_team() + user.match_state = TeamVersusUserState(team_id=best_team_id) + await self.hub.change_user_match_state(self.room, user) + + @override + async def handle_request(self, user: MultiplayerRoomUser, request: MatchRequest): + if not isinstance(request, ChangeTeamRequest): + return + + if request.team_id not in [team.id for team in self.state.teams]: + raise InvokeException("Invalid team ID") + + user.match_state = TeamVersusUserState(team_id=request.team_id) + await self.hub.change_user_match_state(self.room, user) + + @override + async def handle_leave(self, user: MultiplayerRoomUser): ... + + +MATCH_TYPE_HANDLERS = { + MatchType.HEAD_TO_HEAD: HeadToHeadHandler, + MatchType.TEAM_VERSUS: TeamVersusHandler, +} + + @dataclass class ServerMultiplayerRoom: room: MultiplayerRoom @@ -514,10 +645,35 @@ class ServerMultiplayerRoom: status: RoomStatus start_at: datetime hub: "MultiplayerHub" - queue: MultiplayerQueue | None = None - _next_countdown_id: int = 0 - _countdown_id_lock: asyncio.Lock = field(default_factory=asyncio.Lock) - _tracked_countdown: dict[int, CountdownInfo] = field(default_factory=dict) + match_type_handler: MatchTypeHandler + queue: MultiplayerQueue + _next_countdown_id: int + _countdown_id_lock: asyncio.Lock + _tracked_countdown: dict[int, CountdownInfo] + + def __init__( + self, + room: MultiplayerRoom, + category: RoomCategory, + start_at: datetime, + hub: "MultiplayerHub", + ): + self.room = room + self.category = category + self.status = RoomStatus.IDLE + self.start_at = start_at + self.hub = hub + self.queue = MultiplayerQueue(self) + self._next_countdown_id = 0 + self._countdown_id_lock = asyncio.Lock() + self._tracked_countdown = {} + + async def set_handler(self): + self.match_type_handler = MATCH_TYPE_HANDLERS[self.room.settings.match_type]( + self + ) + for i in self.room.users: + await self.match_type_handler.handle_join(i) async def get_next_countdown_id(self) -> int: async with self._countdown_id_lock: diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 1a2b332..1a27497 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -15,16 +15,20 @@ from app.models.multiplayer_hub import ( BeatmapAvailability, ForceGameplayStartCountdown, GameplayAbortReason, + MatchRequest, MatchServerEvent, MultiplayerClientState, - MultiplayerQueue, MultiplayerRoom, + MultiplayerRoomSettings, MultiplayerRoomUser, PlaylistItem, ServerMultiplayerRoom, + StartMatchCountdownRequest, + StopCountdownRequest, ) from app.models.room import ( DownloadState, + MatchType, MultiplayerRoomState, MultiplayerUserState, RoomCategory, @@ -88,15 +92,11 @@ class MultiplayerHub(Hub[MultiplayerClientState]): server_room = ServerMultiplayerRoom( room=room, category=RoomCategory.NORMAL, - status=RoomStatus.IDLE, start_at=starts_at, hub=self, ) - queue = MultiplayerQueue( - room=server_room, - ) - server_room.queue = queue self.rooms[room.room_id] = server_room + await server_room.set_handler() return await self.JoinRoomWithPassword( client, room.room_id, room.settings.password ) @@ -126,6 +126,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): await self.broadcast_group_call(self.group_id(room_id), "UserJoined", user) room.users.append(user) self.add_to_group(client, self.group_id(room_id)) + await server_room.match_type_handler.handle_join(user) return room async def ChangeBeatmapAvailability( @@ -164,7 +165,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException("Room does not exist") server_room = self.rooms[store.room_id] room = server_room.room - assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) if user is None: raise InvokeException("You are not in this room") @@ -182,7 +183,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException("Room does not exist") server_room = self.rooms[store.room_id] room = server_room.room - assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) if user is None: raise InvokeException("You are not in this room") @@ -200,7 +201,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException("Room does not exist") server_room = self.rooms[store.room_id] room = server_room.room - assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) if user is None: raise InvokeException("You are not in this room") @@ -262,7 +263,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def validate_styles(self, room: ServerMultiplayerRoom): - assert room.queue if not room.queue.current_item.freestyle: for user in room.room.users: await self.change_user_style( @@ -323,7 +323,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): return if beatmap_id is not None or ruleset_id is not None: - assert room.queue if not room.queue.current_item.freestyle: raise InvokeException("Current item does not allow free user styles.") @@ -388,7 +387,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): room: ServerMultiplayerRoom, user: MultiplayerRoomUser, ): - assert room.queue is_valid, valid_mods = room.queue.current_item.validate_user_mods( user, new_mods ) @@ -418,7 +416,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): old: MultiplayerUserState, new: MultiplayerUserState, ): - assert room.queue match new: case MultiplayerUserState.IDLE: if old.is_playing: @@ -515,7 +512,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if played_count == ready_count: await self.start_gameplay(room) case MultiplayerRoomState.PLAYING: - assert room.queue if all( u.state != MultiplayerUserState.PLAYING for u in room.room.users ): @@ -562,7 +558,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): await self.start_match(server_room) async def start_match(self, room: ServerMultiplayerRoom): - assert room.queue if room.room.state != MultiplayerRoomState.OPEN: raise InvokeException("Can't start match when already in a running state.") if room.queue.current_item.expired: @@ -598,7 +593,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def start_gameplay(self, room: ServerMultiplayerRoom): - assert room.queue if room.room.state != MultiplayerRoomState.WAITING_FOR_LOAD: raise InvokeException("Room is not ready for gameplay") if room.queue.current_item.expired: @@ -801,3 +795,69 @@ class MultiplayerHub(Hub[MultiplayerClientState]): GameplayAbortReason.HOST_ABORTED, ) await self.update_room_state(server_room) + + async def change_user_match_state( + self, room: ServerMultiplayerRoom, user: MultiplayerRoomUser + ): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "MatchUserStateChanged", + user.user_id, + user.match_state, + ) + + async def change_room_match_state(self, room: ServerMultiplayerRoom): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "MatchRoomStateChanged", + room.room.match_state, + ) + + async def ChangeSettings(self, client: Client, settings: MultiplayerRoomSettings): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + + if room.host is None or room.host.user_id != client.user_id: + raise InvokeException("You are not the host of this room") + + if room.state != MultiplayerRoomState.OPEN: + raise InvokeException("Cannot change settings while playing") + + if settings.match_type == MatchType.PLAYLISTS: + raise InvokeException("Invalid match type selected") + + previous_settings = room.settings + room.settings = settings + + if previous_settings.match_type != settings.match_type: + await server_room.set_handler() + if previous_settings.queue_mode != settings.queue_mode: + await server_room.queue.update_queue_mode() + + await self.setting_changed(server_room, beatmap_changed=False) + await self.update_room_state(server_room) + + async def SendMatchRequest(self, client: Client, request: MatchRequest): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + if isinstance(request, StartMatchCountdownRequest): + # TODO: countdown + ... + elif isinstance(request, StopCountdownRequest): + ... + else: + await server_room.match_type_handler.handle_request(user, request) From f82a1bb3c0be2c8c8f33ec51e74e93c66546725e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 4 Aug 2025 01:31:24 +0000 Subject: [PATCH 20/65] feat(multiplayer): support invite player --- app/signalr/hub/multiplayer.py | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 1a27497..eb602fe 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -6,7 +6,9 @@ from typing import override from app.database import Room from app.database.beatmap import Beatmap +from app.database.lazer_user import User from app.database.playlists import Playlist +from app.database.relationship import Relationship, RelationshipType from app.dependencies.database import engine from app.exception import InvokeException from app.log import logger @@ -861,3 +863,73 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ... else: await server_room.match_type_handler.handle_request(user, request) + + async def InvitePlayer(self, client: Client, user_id: int): + print(f"Inviting player... {client.user_id} {user_id}") + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + async with AsyncSession(engine) as session: + db_user = await session.get(User, user_id) + target_relationship = ( + await session.exec( + select(Relationship).where( + Relationship.user_id == user_id, + Relationship.target_id == client.user_id, + ) + ) + ).first() + inviter_relationship = ( + await session.exec( + select(Relationship).where( + Relationship.user_id == client.user_id, + Relationship.target_id == user_id, + ) + ) + ).first() + if db_user is None: + raise InvokeException("User not found") + if db_user.id == client.user_id: + raise InvokeException("You cannot invite yourself") + if db_user.id in [u.user_id for u in room.users]: + raise InvokeException("User already invited") + if db_user.is_restricted: + raise InvokeException("User is restricted") + if ( + inviter_relationship + and inviter_relationship.type == RelationshipType.BLOCK + ): + raise InvokeException("Cannot perform action due to user being blocked") + if ( + target_relationship + and target_relationship.type == RelationshipType.BLOCK + ): + raise InvokeException("Cannot perform action due to user being blocked") + if ( + db_user.pm_friends_only + and target_relationship is not None + and target_relationship.type != RelationshipType.FOLLOW + ): + raise InvokeException( + "Cannot perform action " + "because user has disabled non-friend communications" + ) + + target_client = self.get_client_by_id(str(user_id)) + if target_client is None: + raise InvokeException("User is not online") + await self.call_noblock( + target_client, + "Invited", + client.user_id, + room.room_id, + room.settings.password, + ) From 9da9f27febcccabbded8dd736c8376976fe66c4e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 4 Aug 2025 02:20:14 +0000 Subject: [PATCH 21/65] feat(multiplayer): complete validation --- app/models/multiplayer_hub.py | 103 ++++++++++++++++++++++++++++++--- app/signalr/hub/multiplayer.py | 38 ++++++++++-- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 97148db..e2f4edf 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import UTC, datetime, timedelta from enum import IntEnum -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, override +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, cast, override from app.database.beatmap import Beatmap from app.dependencies.database import engine @@ -107,6 +107,97 @@ class PlaylistItem(BaseModel): star_rating: float freestyle: bool + def _get_api_mods(self): + from app.models.mods import API_MODS, init_mods + + if not API_MODS: + init_mods() + return API_MODS + + def _validate_mod_for_ruleset( + self, mod: APIMod, ruleset_key: int, context: str = "mod" + ) -> None: + from typing import Literal, cast + + API_MODS = self._get_api_mods() + typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key) + + # Check if mod is valid for ruleset + if ( + typed_ruleset_key not in API_MODS + or mod["acronym"] not in API_MODS[typed_ruleset_key] + ): + raise InvokeException( + f"{context} {mod['acronym']} is invalid for this ruleset" + ) + + mod_settings = API_MODS[typed_ruleset_key][mod["acronym"]] + + # Check if mod is unplayable in multiplayer + if mod_settings.get("UserPlayable", True) is False: + raise InvokeException( + f"{context} {mod['acronym']} is not playable by users" + ) + + if mod_settings.get("ValidForMultiplayer", True) is False: + raise InvokeException( + f"{context} {mod['acronym']} is not valid for multiplayer" + ) + + def _check_mod_compatibility(self, mods: list[APIMod], ruleset_key: int) -> None: + from typing import Literal, cast + + API_MODS = self._get_api_mods() + typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key) + + for i, mod1 in enumerate(mods): + mod1_settings = API_MODS[typed_ruleset_key].get(mod1["acronym"]) + if mod1_settings: + incompatible = set(mod1_settings.get("IncompatibleMods", [])) + for mod2 in mods[i + 1 :]: + if mod2["acronym"] in incompatible: + raise InvokeException( + f"Mods {mod1['acronym']} and " + f"{mod2['acronym']} are incompatible" + ) + + def _check_required_allowed_compatibility(self, ruleset_key: int) -> None: + from typing import Literal, cast + + API_MODS = self._get_api_mods() + typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key) + allowed_acronyms = {mod["acronym"] for mod in self.allowed_mods} + + for req_mod in self.required_mods: + req_acronym = req_mod["acronym"] + req_settings = API_MODS[typed_ruleset_key].get(req_acronym) + if req_settings: + incompatible = set(req_settings.get("IncompatibleMods", [])) + conflicting_allowed = allowed_acronyms & incompatible + if conflicting_allowed: + conflict_list = ", ".join(conflicting_allowed) + raise InvokeException( + f"Required mod {req_acronym} conflicts with " + f"allowed mods: {conflict_list}" + ) + + def validate_playlist_item_mods(self) -> None: + ruleset_key = cast(Literal[0, 1, 2, 3], self.ruleset_id) + + # Validate required mods + for mod in self.required_mods: + self._validate_mod_for_ruleset(mod, ruleset_key, "Required mod") + + # Validate allowed mods + for mod in self.allowed_mods: + self._validate_mod_for_ruleset(mod, ruleset_key, "Allowed mod") + + # Check internal compatibility of required mods + self._check_mod_compatibility(self.required_mods, ruleset_key) + + # Check compatibility between required and allowed mods + self._check_required_allowed_compatibility(ruleset_key) + def validate_user_mods( self, user: "MultiplayerRoomUser", @@ -118,10 +209,7 @@ class PlaylistItem(BaseModel): """ from typing import Literal, cast - from app.models.mods import API_MODS, init_mods - - if not API_MODS: - init_mods() + API_MODS = self._get_api_mods() ruleset_id = user.ruleset_id if user.ruleset_id is not None else self.ruleset_id ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_id) @@ -367,7 +455,8 @@ class MultiplayerQueue: raise InvokeException("Beatmap not found") if item.beatmap_checksum != beatmap.checksum: raise InvokeException("Checksum mismatch") - # TODO: mods validation + + item.validate_playlist_item_mods() item.owner_id = user.user_id item.star_rating = float( beatmap.difficulty_rating @@ -410,7 +499,7 @@ class MultiplayerQueue: "Attempted to change an item which has already been played" ) - # TODO: mods validation + item.validate_playlist_item_mods() item.owner_id = user.user_id item.star_rating = float(beatmap.difficulty_rating) item.playlist_order = existing_item.playlist_order diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index eb602fe..3be8024 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -64,6 +64,18 @@ class MultiplayerHub(Hub[MultiplayerClientState]): connection_token=client.connection_token, ) + @override + async def _clean_state(self, state: MultiplayerClientState): + user_id = int(state.connection_id) + if state.room_id != 0 and state.room_id in self.rooms: + server_room = self.rooms[state.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == user_id), None) + if user is not None: + await self.make_user_leave( + self.get_client_by_id(str(user_id)), server_room, user + ) + async def CreateRoom(self, client: Client, room: MultiplayerRoom): logger.info(f"[MultiplayerHub] {client.user_id} creating room") store = self.get_or_create_state(client) @@ -554,8 +566,17 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException("You are not in this room") if room.host is None or room.host.user_id != client.user_id: raise InvokeException("You are not the host of this room") - if any(u.state != MultiplayerUserState.READY for u in room.users): - raise InvokeException("Not all users are ready") + + # Check host state - host must be ready or spectating + if room.host.state not in ( + MultiplayerUserState.SPECTATING, + MultiplayerUserState.READY, + ): + raise InvokeException("Can't start match when the host is not ready.") + + # Check if any users are ready + if all(u.state != MultiplayerUserState.READY for u in room.users): + raise InvokeException("Can't start match when no users are ready.") await self.start_match(server_room) @@ -646,7 +667,11 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if len(room.room.users) == 0: await self.end_room(room) await self.update_room_state(room) - if room.room.host and room.room.host.user_id == user.user_id: + if ( + len(room.room.users) != 0 + and room.room.host + and room.room.host.user_id == user.user_id + ): next_host = room.room.users[0] await self.set_host(room, next_host) @@ -710,6 +735,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if room.host is None or room.host.user_id != client.user_id: raise InvokeException("You are not the host of this room") + if user_id == client.user_id: + raise InvokeException("Can't kick self") + user = next((u for u in room.users if u.user_id == user_id), None) if user is None: raise InvokeException("User not found in this room") @@ -780,9 +808,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if ( room.state != MultiplayerRoomState.PLAYING - or room.state == MultiplayerRoomState.WAITING_FOR_LOAD + and room.state != MultiplayerRoomState.WAITING_FOR_LOAD ): - raise InvokeException("Room is not in a playable state") + raise InvokeException("Cannot abort a match that hasn't started.") await asyncio.gather( *[ From cfcf9ad03457da3cd3e7c8a92bcc8e37f5462812 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 4 Aug 2025 02:21:40 +0000 Subject: [PATCH 22/65] chore(mods): update mod definitions catch: add MF --- static/README.md | 2 +- static/mods.json | 27 ++++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/static/README.md b/static/README.md index 16ece63..77b54fe 100644 --- a/static/README.md +++ b/static/README.md @@ -2,4 +2,4 @@ - `mods.json`: 包含了游戏中的所有可用mod的详细信息。 - Origin: https://github.com/ppy/osu-web/blob/master/database/mods.json - - Version: 2025/6/10 `b68c920b1db3d443b9302fdc3f86010c875fe380` + - Version: 2025/7/30 `ff49b66b27a2850aea4b6b3ba563cfe936cb6082` diff --git a/static/mods.json b/static/mods.json index defb57f..0a8449b 100644 --- a/static/mods.json +++ b/static/mods.json @@ -2438,7 +2438,8 @@ "Settings": [], "IncompatibleMods": [ "CN", - "RX" + "RX", + "MF" ], "RequiresConfiguration": false, "UserPlayable": false, @@ -2460,7 +2461,8 @@ "AC", "AT", "CN", - "RX" + "RX", + "MF" ], "RequiresConfiguration": false, "UserPlayable": false, @@ -2477,7 +2479,8 @@ "Settings": [], "IncompatibleMods": [ "AT", - "CN" + "CN", + "MF" ], "RequiresConfiguration": false, "UserPlayable": true, @@ -2638,6 +2641,24 @@ "ValidForMultiplayerAsFreeMod": true, "AlwaysValidForSubmission": false }, + { + "Acronym": "MF", + "Name": "Moving Fast", + "Description": "Dashing by default, slow down!", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [ + "AT", + "CN", + "RX" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, { "Acronym": "SV2", "Name": "Score V2", From 082883599e3a743c18231976bc8078fc08b84803 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Tue, 5 Aug 2025 07:29:41 +0000 Subject: [PATCH 23/65] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0gitignore,?= =?UTF-8?q?=E6=96=B9=E4=BE=BF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 369e759..05622b7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports +test-cert/ htmlcov/ .tox/ .nox/ From 0988f1fc0c40350030ec746ab9216fe77c10e7cc Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Tue, 5 Aug 2025 16:17:33 +0000 Subject: [PATCH 24/65] feat(multiplayer): partital support for multiplayer rooms' filtering --- app/router/room.py | 79 +++++++++++++--------------------------------- 1 file changed, 22 insertions(+), 57 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index ba909c6..cfaaf56 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -2,9 +2,11 @@ from __future__ import annotations from typing import Literal +from app.database.lazer_user import User from app.database.room import RoomResp from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher +from app.dependencies.user import get_current_user from app.fetcher import Fetcher from app.models.room import RoomStatus from app.signalr.hub import MultiplayerHubs @@ -19,68 +21,31 @@ from sqlmodel.ext.asyncio.session import AsyncSession @router.get("/rooms", tags=["rooms"], response_model=list[RoomResp]) async def get_all_rooms( mode: Literal["open", "ended", "participated", "owned", None] = Query( - None + default="open" ), # TODO: 对房间根据状态进行筛选 category: str = Query(default="realtime"), # TODO status: RoomStatus | None = Query(None), db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), redis: Redis = Depends(get_redis), + current_user: User = Depends(get_current_user), ): rooms = MultiplayerHubs.rooms.values() - return [await RoomResp.from_hub(room) for room in rooms] - - -# @router.get("/rooms/{room}", tags=["room"], response_model=Room) -# async def get_room( -# room: int, -# db: AsyncSession = Depends(get_db), -# fetcher: Fetcher = Depends(get_fetcher), -# ): -# redis = get_redis() -# if redis: -# dumped_room = str(redis.get(str(room))) -# if dumped_room is not None: -# resp = await Room.from_mpRoom( -# MultiplayerRoom.model_validate_json(str(dumped_room)), db, fetcher -# ) -# return resp -# else: -# raise HTTPException(status_code=404, detail="Room Not Found") -# else: -# raise HTTPException(status_code=500, detail="Redis error") - - -# class APICreatedRoom(Room): -# error: str | None - - -# @router.post("/rooms", tags=["beatmap"], response_model=APICreatedRoom) -# async def create_room( -# room: Room, -# db: AsyncSession = Depends(get_db), -# fetcher: Fetcher = Depends(get_fetcher), -# ): -# redis = get_redis() -# if redis: -# room_index = RoomIndex() -# db.add(room_index) -# await db.commit() -# await db.refresh(room_index) -# server_room = await MultiplayerRoom.from_apiRoom(room, db, fetcher) -# redis.set(str(room_index.id), server_room.model_dump_json()) -# room.room_id = room_index.id -# return APICreatedRoom(**room.model_dump(), error=None) -# else: -# raise HTTPException(status_code=500, detail="redis error") - - -# @router.delete("/rooms/{room}", tags=["room"]) -# async def remove_room(room: int, db: AsyncSession = Depends(get_db)): -# redis = get_redis() -# if redis: -# redis.delete(str(room)) -# room_index = await db.get(RoomIndex, room) -# if room_index: -# await db.delete(room_index) -# await db.commit() + resp_list: list[RoomResp] = [] + for room in rooms: + if category != "realtime": # 歌单模式的处理逻辑 + if room.category == category: + if mode == "owned": + if ( + room.room.host.user_id if room.room.host is not None else 0 + ) != current_user.id: + continue + else: + if ( + room.room.host.user_id if room.room.host is not None else 0 + ) != current_user.id: + continue + if room.status != status: + continue + resp_list.append(await RoomResp.from_hub(room)) + return resp_list From 0a80c5051cb7e5c1651322c174466474ebd8c7ea Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Tue, 5 Aug 2025 17:21:45 +0000 Subject: [PATCH 25/65] feat(multiplayer): support countdown --- app/models/metadata_hub.py | 15 ++++------ app/models/multiplayer_hub.py | 42 +++++++++++++-------------- app/models/signalr.py | 1 - app/signalr/hub/multiplayer.py | 52 ++++++++++++++++++++++++++++++---- app/signalr/packet.py | 39 ++++++++++++++----------- app/utils.py | 38 +++++++++++++++++++++++-- 6 files changed, 131 insertions(+), 56 deletions(-) diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index a678d7f..684ab54 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -1,11 +1,11 @@ from __future__ import annotations from enum import IntEnum -from typing import Annotated, ClassVar, Literal +from typing import ClassVar, Literal -from app.models.signalr import SignalRMeta, SignalRUnionMessage, UserState +from app.models.signalr import SignalRUnionMessage, UserState -from pydantic import BaseModel, Field +from pydantic import BaseModel class _UserActivity(SignalRUnionMessage): ... @@ -100,12 +100,9 @@ UserActivity = ( class UserPresence(BaseModel): - activity: Annotated[ - UserActivity | None, Field(default=None), SignalRMeta(use_upper_case=True) - ] - status: Annotated[ - OnlineStatus | None, Field(default=None), SignalRMeta(use_upper_case=True) - ] + activity: UserActivity | None = None + + status: OnlineStatus | None = None @property def pushable(self) -> bool: diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index e2f4edf..9d78282 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -53,10 +53,14 @@ class MultiplayerRoomSettings(BaseModel): auto_start_duration: timedelta = timedelta(seconds=0) auto_skip: bool = False + @property + def auto_start_enabled(self) -> bool: + return self.auto_start_duration != timedelta(seconds=0) + class BeatmapAvailability(BaseModel): state: DownloadState = DownloadState.UNKNOWN - progress: float | None = None + download_progress: float | None = None class _MatchUserState(SignalRUnionMessage): ... @@ -283,10 +287,12 @@ class PlaylistItem(BaseModel): return copy -class _MultiplayerCountdown(BaseModel): +class _MultiplayerCountdown(SignalRUnionMessage): id: int = 0 - remaining: timedelta - is_exclusive: bool = False + time_remaining: timedelta + is_exclusive: Annotated[ + bool, Field(default=True), SignalRMeta(member_ignore=True) + ] = True class MatchStartCountdown(_MultiplayerCountdown): @@ -310,7 +316,7 @@ class MultiplayerRoomUser(BaseModel): user_id: int state: MultiplayerUserState = MultiplayerUserState.IDLE availability: BeatmapAvailability = BeatmapAvailability( - state=DownloadState.UNKNOWN, progress=None + state=DownloadState.UNKNOWN, download_progress=None ) mods: list[APIMod] = Field(default_factory=list) match_state: MatchUserState | None = None @@ -602,8 +608,8 @@ class CountdownInfo: def __init__(self, countdown: MultiplayerCountdown): self.countdown = countdown self.duration = ( - countdown.remaining - if countdown.remaining > timedelta(seconds=0) + countdown.time_remaining + if countdown.time_remaining > timedelta(seconds=0) else timedelta(seconds=0) ) @@ -776,13 +782,12 @@ class ServerMultiplayerRoom: ): async def _countdown_task(self: "ServerMultiplayerRoom"): await asyncio.sleep(info.duration.total_seconds()) - await self.stop_countdown(countdown) if on_complete is not None: await on_complete(self) + await self.stop_countdown(countdown) if countdown.is_exclusive: await self.stop_all_countdowns() - countdown.id = await self.get_next_countdown_id() info = CountdownInfo(countdown) self.room.active_countdowns.append(info.countdown) @@ -793,21 +798,14 @@ class ServerMultiplayerRoom: info.task = asyncio.create_task(_countdown_task(self)) async def stop_countdown(self, countdown: MultiplayerCountdown): - info = next( - ( - info - for info in self._tracked_countdown.values() - if info.countdown.id == countdown.id - ), - None, - ) + info = self._tracked_countdown.get(countdown.id) if info is None: return - if info.task is not None and not info.task.done(): - info.task.cancel() del self._tracked_countdown[countdown.id] self.room.active_countdowns.remove(countdown) await self.hub.send_match_event(self, CountdownStoppedEvent(id=countdown.id)) + if info.task is not None and not info.task.done(): + info.task.cancel() async def stop_all_countdowns(self): for countdown in list(self._tracked_countdown.values()): @@ -817,19 +815,19 @@ class ServerMultiplayerRoom: self.room.active_countdowns.clear() -class _MatchServerEvent(BaseModel): ... +class _MatchServerEvent(SignalRUnionMessage): ... class CountdownStartedEvent(_MatchServerEvent): countdown: MultiplayerCountdown - type: Literal[0] = Field(default=0, exclude=True) + union_type: ClassVar[Literal[0]] = 0 class CountdownStoppedEvent(_MatchServerEvent): id: int - type: Literal[1] = Field(default=1, exclude=True) + union_type: ClassVar[Literal[1]] = 1 MatchServerEvent = CountdownStartedEvent | CountdownStoppedEvent diff --git a/app/models/signalr.py b/app/models/signalr.py index 7116ea0..ffbaf6b 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -13,7 +13,6 @@ from pydantic import ( class SignalRMeta: member_ignore: bool = False # implement of IgnoreMember (msgpack) attribute json_ignore: bool = False # implement of JsonIgnore (json) attribute - use_upper_case: bool = False # use upper CamelCase for field names use_abbr: bool = True diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 3be8024..ef3dfcd 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -19,12 +19,14 @@ from app.models.multiplayer_hub import ( GameplayAbortReason, MatchRequest, MatchServerEvent, + MatchStartCountdown, MultiplayerClientState, MultiplayerRoom, MultiplayerRoomSettings, MultiplayerRoomUser, PlaylistItem, ServerMultiplayerRoom, + ServerShuttingDownCountdown, StartMatchCountdownRequest, StopCountdownRequest, ) @@ -160,7 +162,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): availability = user.availability if ( availability.state == beatmap_availability.state - and availability.progress == beatmap_availability.progress + and availability.download_progress == beatmap_availability.download_progress ): return user.availability = beatmap_availability @@ -512,6 +514,25 @@ class MultiplayerHub(Hub[MultiplayerClientState]): async def update_room_state(self, room: ServerMultiplayerRoom): match room.room.state: + case MultiplayerRoomState.OPEN: + if room.room.settings.auto_start_enabled: + if ( + not room.queue.current_item.expired + and any( + u.state == MultiplayerUserState.READY + for u in room.room.users + ) + and not any( + isinstance(countdown, MatchStartCountdown) + for countdown in room.room.active_countdowns + ) + ): + await room.start_countdown( + MatchStartCountdown( + time_remaining=room.room.settings.auto_start_duration + ), + self.start_match, + ) case MultiplayerRoomState.WAITING_FOR_LOAD: played_count = len( [True for user in room.room.users if user.state.is_playing] @@ -610,7 +631,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) await room.start_countdown( ForceGameplayStartCountdown( - remaining=timedelta(seconds=GAMEPLAY_LOAD_TIMEOUT) + time_remaining=timedelta(seconds=GAMEPLAY_LOAD_TIMEOUT) ), self.start_gameplay, ) @@ -885,15 +906,34 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException("You are not in this room") if isinstance(request, StartMatchCountdownRequest): - # TODO: countdown - ... + if room.host and room.host.user_id != user.user_id: + raise InvokeException("You are not the host of this room") + if room.state != MultiplayerRoomState.OPEN: + raise InvokeException("Cannot start a countdown during ongoing play") + await server_room.start_countdown( + MatchStartCountdown(time_remaining=request.duration), + self.start_match, + ) elif isinstance(request, StopCountdownRequest): - ... + countdown = next( + (c for c in room.active_countdowns if c.id == request.id), + None, + ) + if countdown is None: + return + if ( + isinstance(countdown, MatchStartCountdown) + and room.settings.auto_start_enabled + ) or isinstance( + countdown, (ForceGameplayStartCountdown | ServerShuttingDownCountdown) + ): + raise InvokeException("Cannot stop the requested countdown") + + await server_room.stop_countdown(countdown) else: await server_room.match_type_handler.handle_request(user, request) async def InvitePlayer(self, client: Client, user_id: int): - print(f"Inviting player... {client.user_id} {user_id}") store = self.get_or_create_state(client) if store.room_id == 0: raise InvokeException("You are not in a room") diff --git a/app/signalr/packet.py b/app/signalr/packet.py index 9afb78d..09a36bd 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -15,7 +15,7 @@ from typing import ( ) from app.models.signalr import SignalRMeta, SignalRUnionMessage -from app.utils import camel_to_snake, snake_to_camel +from app.utils import camel_to_snake, snake_to_camel, snake_to_pascal import msgpack_lazer_api as m from pydantic import BaseModel @@ -98,7 +98,7 @@ class MsgpackProtocol: elif issubclass(typ, datetime.datetime): return [v, 0] elif issubclass(typ, datetime.timedelta): - return int(v.total_seconds()) + return int(v.total_seconds() * 10_000_000) elif isinstance(v, dict): return { cls.serialize_msgpack(k): cls.serialize_msgpack(value) @@ -216,8 +216,8 @@ class MsgpackProtocol: elif inspect.isclass(typ) and issubclass(typ, datetime.datetime): return v[0] elif inspect.isclass(typ) and issubclass(typ, datetime.timedelta): - return datetime.timedelta(seconds=int(v)) - elif isinstance(v, list): + return datetime.timedelta(seconds=int(v / 10_000_000)) + elif get_origin(typ) is list: return [cls.validate_object(item, get_args(typ)[0]) for item in v] elif inspect.isclass(typ) and issubclass(typ, Enum): list_ = list(typ) @@ -300,10 +300,10 @@ class MsgpackProtocol: class JSONProtocol: @classmethod - def serialize_to_json(cls, v: Any, dict_key: bool = False): + def serialize_to_json(cls, v: Any, dict_key: bool = False, in_union: bool = False): typ = v.__class__ if issubclass(typ, BaseModel): - return cls.serialize_model(v) + return cls.serialize_model(v, in_union) elif isinstance(v, dict): return { cls.serialize_to_json(k, True): cls.serialize_to_json(value) @@ -327,22 +327,28 @@ class JSONProtocol: return v @classmethod - def serialize_model(cls, v: BaseModel) -> dict[str, Any]: + def serialize_model(cls, v: BaseModel, in_union: bool = False) -> dict[str, Any]: d = {} + is_union = issubclass(v.__class__, SignalRUnionMessage) for field, info in v.__class__.model_fields.items(): metadata = next( (m for m in info.metadata if isinstance(m, SignalRMeta)), None ) if metadata and metadata.json_ignore: continue - d[ + name = ( snake_to_camel( field, - metadata.use_upper_case if metadata else False, metadata.use_abbr if metadata else True, ) - ] = cls.serialize_to_json(getattr(v, field)) - if issubclass(v.__class__, SignalRUnionMessage): + if not is_union + else snake_to_pascal( + field, + metadata.use_abbr if metadata else True, + ) + ) + d[name] = cls.serialize_to_json(getattr(v, field), in_union=is_union) + if is_union and not in_union: return { "$dtype": v.__class__.__name__, "$value": d, @@ -360,11 +366,12 @@ class JSONProtocol: ) if metadata and metadata.json_ignore: continue - value = v.get( - snake_to_camel( - field, not from_union, metadata.use_abbr if metadata else True - ) + name = ( + snake_to_camel(field, metadata.use_abbr if metadata else True) + if not from_union + else snake_to_pascal(field, metadata.use_abbr if metadata else True) ) + value = v.get(name) anno = typ.model_fields[field].annotation if anno is None: d[field] = value @@ -433,7 +440,7 @@ class JSONProtocol: return datetime.timedelta(minutes=int(parts[0]), seconds=int(parts[1])) elif len(parts) == 1: return datetime.timedelta(seconds=int(parts[0])) - elif isinstance(v, list): + elif get_origin(typ) is list: return [cls.validate_object(item, get_args(typ)[0]) for item in v] elif inspect.isclass(typ) and issubclass(typ, Enum): list_ = list(typ) diff --git a/app/utils.py b/app/utils.py index ac51b90..22f06dd 100644 --- a/app/utils.py +++ b/app/utils.py @@ -21,7 +21,7 @@ def camel_to_snake(name: str) -> str: return "".join(result) -def snake_to_camel(name: str, lower_case: bool = True, use_abbr: bool = True) -> str: +def snake_to_camel(name: str, use_abbr: bool = True) -> str: """Convert a snake_case string to camelCase.""" if not name: return name @@ -50,9 +50,43 @@ def snake_to_camel(name: str, lower_case: bool = True, use_abbr: bool = True) -> if part.lower() in abbreviations and use_abbr: result.append(part.upper()) else: - if result or not lower_case: + if result: result.append(part.capitalize()) else: result.append(part.lower()) return "".join(result) + + +def snake_to_pascal(name: str, use_abbr: bool = True) -> str: + """Convert a snake_case string to PascalCase.""" + if not name: + return name + + parts = name.split("_") + if not parts: + return name + + # 常见缩写词列表 + abbreviations = { + "id", + "url", + "api", + "http", + "https", + "xml", + "json", + "css", + "html", + "sql", + "db", + } + + result = [] + for part in parts: + if part.lower() in abbreviations and use_abbr: + result.append(part.upper()) + else: + result.append(part.capitalize()) + + return "".join(result) From 2b4d366e3e8160f28fcfd9f7029d417f2942b1c6 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Tue, 5 Aug 2025 17:21:53 +0000 Subject: [PATCH 26/65] fix(score): remove foreign key to fix missing index error --- app/database/best_score.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/database/best_score.py b/app/database/best_score.py index 42b0024..8688d5b 100644 --- a/app/database/best_score.py +++ b/app/database/best_score.py @@ -29,9 +29,7 @@ class BestScore(SQLModel, table=True): ) beatmap_id: int = Field(foreign_key="beatmaps.id", index=True) gamemode: GameMode = Field(index=True) - total_score: int = Field( - default=0, sa_column=Column(BigInteger, ForeignKey("scores.total_score")) - ) + total_score: int = Field(default=0, sa_column=Column(BigInteger)) mods: list[str] = Field( default_factory=list, sa_column=Column(JSON), From 84dac34a05ec3270ce3c86549ae7a3ff4746216a Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Wed, 6 Aug 2025 06:55:45 +0000 Subject: [PATCH 27/65] fix(multiplayer): fix fliters --- app/router/room.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index cfaaf56..476eaf2 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -40,11 +40,14 @@ async def get_all_rooms( room.room.host.user_id if room.room.host is not None else 0 ) != current_user.id: continue - else: - if ( - room.room.host.user_id if room.room.host is not None else 0 - ) != current_user.id: + else: continue + else: + if mode == "owned": + if ( + room.room.host.user_id if room.room.host is not None else 0 + ) != current_user.id: + continue if room.status != status: continue resp_list.append(await RoomResp.from_hub(room)) From 87bb74d1caa0affba79b2f32e9fed22bc147ba19 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Wed, 6 Aug 2025 10:51:37 +0000 Subject: [PATCH 28/65] feat(multiplayer): support leaderboard --- app/database/__init__.py | 6 + app/database/playlist_best_score.py | 107 +++++++++++ app/database/playlists.py | 2 +- app/database/score.py | 19 +- app/models/model.py | 7 + app/router/score.py | 177 +++++++++++++++++- app/signalr/hub/multiplayer.py | 11 +- ...d0c1b2cefe91_playlist_index_playlist_id.py | 89 +++++++++ 8 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 app/database/playlist_best_score.py create mode 100644 migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 2c01f7a..b3e65f9 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -16,12 +16,15 @@ from .lazer_user import ( UserResp, ) from .playlist_attempts import ItemAttemptsCount +from .playlist_best_score import PlaylistBestScore from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore from .relationship import Relationship, RelationshipResp, RelationshipType from .room import Room, RoomResp from .score import ( + MultiplayerScores, Score, + ScoreAround, ScoreBase, ScoreResp, ScoreStatistics, @@ -47,9 +50,11 @@ __all__ = [ "DailyChallengeStatsResp", "FavouriteBeatmapset", "ItemAttemptsCount", + "MultiplayerScores", "OAuthToken", "PPBestScore", "Playlist", + "PlaylistBestScore", "PlaylistResp", "Relationship", "RelationshipResp", @@ -57,6 +62,7 @@ __all__ = [ "Room", "RoomResp", "Score", + "ScoreAround", "ScoreBase", "ScoreResp", "ScoreStatistics", diff --git a/app/database/playlist_best_score.py b/app/database/playlist_best_score.py new file mode 100644 index 0000000..49fb459 --- /dev/null +++ b/app/database/playlist_best_score.py @@ -0,0 +1,107 @@ +from typing import TYPE_CHECKING + +from .lazer_user import User + +from redis.asyncio import Redis +from sqlmodel import ( + BigInteger, + Column, + Field, + ForeignKey, + Relationship, + SQLModel, + col, + func, + select, +) +from sqlmodel.ext.asyncio.session import AsyncSession + +if TYPE_CHECKING: + from .score import Score + + +class PlaylistBestScore(SQLModel, table=True): + __tablename__ = "playlist_best_scores" # pyright: ignore[reportAssignmentType] + + user_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) + ) + score_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("scores.id"), primary_key=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)) + + user: User = Relationship() + score: "Score" = Relationship( + sa_relationship_kwargs={ + "foreign_keys": "[PlaylistBestScore.score_id]", + "lazy": "joined", + } + ) + + +async def process_playlist_best_score( + room_id: int, + playlist_id: int, + user_id: int, + score_id: int, + total_score: int, + session: AsyncSession, + redis: Redis, +): + previous = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.room_id == room_id, + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.user_id == user_id, + ) + ) + ).first() + if previous is None: + score = PlaylistBestScore( + user_id=user_id, + score_id=score_id, + room_id=room_id, + playlist_id=playlist_id, + total_score=total_score, + ) + session.add(score) + else: + previous.score_id = score_id + previous.total_score = total_score + await session.commit() + await redis.decr(f"multiplayer:{room_id}:gameplay:players") + + +async def get_position( + room_id: int, + playlist_id: int, + score_id: int, + session: AsyncSession, +) -> int: + rownum = ( + func.row_number() + .over( + partition_by=( + col(PlaylistBestScore.playlist_id), + col(PlaylistBestScore.room_id), + ), + order_by=col(PlaylistBestScore.total_score).desc(), + ) + .label("row_number") + ) + subq = ( + select(PlaylistBestScore, rownum) + .where( + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.room_id == room_id, + ) + .subquery() + ) + stmt = select(subq.c.row_number).where(subq.c.score_id == score_id) + result = await session.exec(stmt) + s = result.one_or_none() + return s if s else 0 diff --git a/app/database/playlists.py b/app/database/playlists.py index 328f17d..432c3b0 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: class PlaylistBase(SQLModel, UTCBaseModel): - id: int = 0 + id: int = Field(index=True) owner_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"))) ruleset_id: int = Field(ge=0, le=3) expired: bool = Field(default=False) diff --git a/app/database/score.py b/app/database/score.py index abc3d75..37b96a3 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from datetime import UTC, date, datetime import json import math -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from app.calculator import ( calculate_pp, @@ -14,7 +14,7 @@ from app.calculator import ( clamp, ) from app.database.team import TeamMember -from app.models.model import UTCBaseModel +from app.models.model import RespWithCursor, UTCBaseModel from app.models.mods import APIMod, mods_can_get_pp from app.models.score import ( INT_TO_MODE, @@ -88,6 +88,7 @@ class ScoreBase(AsyncAttrs, SQLModel, UTCBaseModel): default=0, sa_column=Column(BigInteger), exclude=True ) type: str + beatmap_id: int = Field(index=True, foreign_key="beatmaps.id") # optional # TODO: current_user_attributes @@ -99,7 +100,6 @@ class Score(ScoreBase, table=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") user_id: int = Field( default=None, sa_column=Column( @@ -162,7 +162,8 @@ class ScoreResp(ScoreBase): maximum_statistics: ScoreStatistics | None = None rank_global: int | None = None rank_country: int | None = None - position: int = 1 # TODO + position: int | None = None + scores_around: "ScoreAround | None" = None @classmethod async def from_db(cls, session: AsyncSession, score: Score) -> "ScoreResp": @@ -234,6 +235,16 @@ class ScoreResp(ScoreBase): return s +class MultiplayerScores(RespWithCursor): + scores: list[ScoreResp] = Field(default_factory=list) + params: dict[str, Any] = Field(default_factory=dict) + + +class ScoreAround(SQLModel): + higher: MultiplayerScores | None = None + lower: MultiplayerScores | None = None + + async def get_best_id(session: AsyncSession, score_id: int) -> None: rownum = ( func.row_number() diff --git a/app/models/model.py b/app/models/model.py index bc00585..5ba8093 100644 --- a/app/models/model.py +++ b/app/models/model.py @@ -13,3 +13,10 @@ class UTCBaseModel(BaseModel): v = v.replace(tzinfo=UTC) return v.astimezone(UTC).isoformat() return v + + +Cursor = dict[str, int] + + +class RespWithCursor(BaseModel): + cursor: Cursor | None = None diff --git a/app/router/score.py b/app/router/score.py index b50911d..818155d 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -1,5 +1,8 @@ from __future__ import annotations +import time + +from app.calculator import clamp from app.database import ( Beatmap, Playlist, @@ -9,7 +12,18 @@ from app.database import ( ScoreTokenResp, User, ) -from app.database.score import get_leaderboard, process_score, process_user +from app.database.playlist_best_score import ( + PlaylistBestScore, + get_position, + process_playlist_best_score, +) +from app.database.score import ( + MultiplayerScores, + ScoreAround, + get_leaderboard, + process_score, + process_user, +) from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user @@ -33,6 +47,8 @@ from sqlalchemy.orm import joinedload from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession +READ_SCORE_TIMEOUT = 10 + async def submit_score( info: SoloScoreSubmissionInfo, @@ -337,4 +353,163 @@ async def submit_playlist_score( item.id, room_id, ) + await process_playlist_best_score( + room_id, + playlist_id, + current_user.id, + score_resp.id, + score_resp.total_score, + session, + redis, + ) return score_resp + + +class IndexedScoreResp(MultiplayerScores): + total: int + user_score: ScoreResp | None = None + + +@router.get( + "/rooms/{room_id}/playlist/{playlist_id}/scores", response_model=IndexedScoreResp +) +async def index_playlist_scores( + room_id: int, + playlist_id: int, + limit: int = 50, + cursor: int = Query(2000000, alias="cursor[total_score]"), + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +): + limit = clamp(limit, 1, 50) + + scores = ( + await session.exec( + select(PlaylistBestScore) + .where( + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.room_id == room_id, + PlaylistBestScore.total_score < cursor, + ) + .order_by(col(PlaylistBestScore.total_score).desc()) + .limit(limit + 1) + ) + ).all() + has_more = len(scores) > limit + if has_more: + scores = scores[:-1] + + user_score = None + score_resp = [await ScoreResp.from_db(session, score.score) for score in scores] + for score in score_resp: + score.position = await get_position(room_id, playlist_id, score.id, session) + if score.user_id == current_user.id: + user_score = score + resp = IndexedScoreResp( + scores=score_resp, + user_score=user_score, + total=len(scores), + params={ + "limit": limit, + }, + ) + if has_more: + resp.cursor = { + "total_score": scores[-1].total_score, + } + return resp + + +@router.get( + "/rooms/{room_id}/playlist/{playlist_id}/scores/{score_id}", + response_model=ScoreResp, +) +async def show_playlist_score( + room_id: int, + playlist_id: int, + score_id: int, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), +): + start_time = time.time() + score_record = None + completed = False + while time.time() - start_time < READ_SCORE_TIMEOUT: + if score_record is None: + score_record = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.score_id == score_id, + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.room_id == room_id, + ) + ) + ).first() + if completed_players := await redis.get( + f"multiplayer:{room_id}:gameplay:players" + ): + completed = completed_players == "0" + if score_record and completed: + break + if not score_record: + raise HTTPException(status_code=404, detail="Score not found") + resp = await ScoreResp.from_db(session, score_record.score) + resp.position = await get_position(room_id, playlist_id, score_id, session) + if completed: + scores = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.room_id == room_id, + ) + ) + ).all() + higher_scores = [] + lower_scores = [] + for score in scores: + if score.total_score > resp.total_score: + higher_scores.append(await ScoreResp.from_db(session, score.score)) + elif score.total_score < resp.total_score: + lower_scores.append(await ScoreResp.from_db(session, score.score)) + resp.scores_around = ScoreAround( + higher=MultiplayerScores(scores=higher_scores), + lower=MultiplayerScores(scores=lower_scores), + ) + + return resp + + +@router.get( + "rooms/{room_id}/playlist/{playlist_id}/scores/users/{user_id}", + response_model=ScoreResp, +) +async def get_user_playlist_score( + room_id: int, + playlist_id: int, + user_id: int, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +): + score_record = None + start_time = time.time() + while time.time() - start_time < READ_SCORE_TIMEOUT: + score_record = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.user_id == user_id, + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.room_id == room_id, + ) + ) + ).first() + if score_record: + break + if not score_record: + raise HTTPException(status_code=404, detail="Score not found") + + resp = await ScoreResp.from_db(session, score_record.score) + resp.position = await get_position( + room_id, playlist_id, score_record.score_id, session + ) + return resp diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index ef3dfcd..af28d26 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -9,7 +9,7 @@ from app.database.beatmap import Beatmap from app.database.lazer_user import User from app.database.playlists import Playlist from app.database.relationship import Relationship, RelationshipType -from app.dependencies.database import engine +from app.dependencies.database import engine, get_redis from app.exception import InvokeException from app.log import logger from app.models.mods import APIMod @@ -642,6 +642,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if room.queue.current_item.expired: raise InvokeException("Current playlist item is expired") playing = False + played_user = 0 for user in room.room.users: client = self.get_client_by_id(str(user.user_id)) if client is None: @@ -652,6 +653,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): MultiplayerUserState.LOADED, ): playing = True + played_user += 1 await self.change_user_state(room, user, MultiplayerUserState.PLAYING) await self.call_noblock(client, "GameplayStarted") elif user.state == MultiplayerUserState.WAITING_FOR_LOAD: @@ -665,6 +667,13 @@ class MultiplayerHub(Hub[MultiplayerClientState]): room, (MultiplayerRoomState.PLAYING if playing else MultiplayerRoomState.OPEN), ) + if playing: + redis = get_redis() + await redis.set( + f"multiplayer:{room.room.room_id}:gameplay:players", + played_user, + ex=3600, + ) async def send_match_event( self, room: ServerMultiplayerRoom, event: MatchServerEvent diff --git a/migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py b/migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py new file mode 100644 index 0000000..74f2e56 --- /dev/null +++ b/migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py @@ -0,0 +1,89 @@ +"""playlist: index playlist id + +Revision ID: d0c1b2cefe91 +Revises: 58a11441d302 +Create Date: 2025-08-06 06:02:10.512616 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "d0c1b2cefe91" +down_revision: str | Sequence[str] | None = "58a11441d302" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_index( + op.f("ix_room_playlists_id"), "room_playlists", ["id"], unique=False + ) + op.create_table( + "playlist_best_scores", + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("score_id", sa.BigInteger(), nullable=False), + sa.Column("room_id", sa.Integer(), nullable=False), + sa.Column("playlist_id", sa.Integer(), nullable=False), + sa.Column("total_score", sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint( + ["playlist_id"], + ["room_playlists.id"], + ), + sa.ForeignKeyConstraint( + ["room_id"], + ["rooms.id"], + ), + sa.ForeignKeyConstraint( + ["score_id"], + ["scores.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("score_id"), + ) + op.create_index( + op.f("ix_playlist_best_scores_playlist_id"), + "playlist_best_scores", + ["playlist_id"], + unique=False, + ) + op.create_index( + op.f("ix_playlist_best_scores_room_id"), + "playlist_best_scores", + ["room_id"], + unique=False, + ) + op.create_index( + op.f("ix_playlist_best_scores_user_id"), + "playlist_best_scores", + ["user_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_playlist_best_scores_user_id"), table_name="playlist_best_scores" + ) + op.drop_index( + op.f("ix_playlist_best_scores_room_id"), table_name="playlist_best_scores" + ) + op.drop_index( + op.f("ix_playlist_best_scores_playlist_id"), table_name="playlist_best_scores" + ) + op.drop_table("playlist_best_scores") + op.drop_index(op.f("ix_room_playlists_id"), table_name="room_playlists") + # ### end Alembic commands ### From 47d02e4e9c94d30e4cbe9fe3caba1ea577117023 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Thu, 7 Aug 2025 06:28:07 +0000 Subject: [PATCH 29/65] feat(room): add POST /room API --- app/database/room.py | 2 +- app/models/multiplayer_hub.py | 52 ++++++++++++++++++++ app/router/room.py | 86 +++++++++++++++++++++++++++------- app/signalr/hub/multiplayer.py | 2 +- 4 files changed, 122 insertions(+), 20 deletions(-) diff --git a/app/database/room.py b/app/database/room.py index 80457b6..08f1466 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -29,7 +29,7 @@ class RoomBase(SQLModel): name: str = Field(index=True) category: RoomCategory = Field(default=RoomCategory.NORMAL, index=True) duration: int | None = Field(default=None) # minutes - starts_at: datetime = Field( + starts_at: datetime | None = Field( sa_column=Column( DateTime(timezone=True), ), diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 9d78282..ed37b98 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -335,6 +335,58 @@ class MultiplayerRoom(BaseModel): active_countdowns: list[MultiplayerCountdown] = Field(default_factory=list) channel_id: int + @classmethod + def from_db(cls, room) -> "MultiplayerRoom": + """ + 将 Room (数据库模型) 转换为 MultiplayerRoom (业务模型) + """ + + # 用户列表 + users = [MultiplayerRoomUser(user_id=room.host_id)] + host_user = MultiplayerRoomUser(user_id=room.host_id) + # playlist 转换 + playlist = [] + if hasattr(room, "playlist"): + for item in room.playlist: + playlist.append( + PlaylistItem( + id=item.id, + owner_id=item.owner_id, + beatmap_id=item.beatmap_id, + beatmap_checksum=item.beatmap.checksum if item.beatmap else "", + ruleset_id=item.ruleset_id, + required_mods=item.required_mods, + allowed_mods=item.allowed_mods, + expired=item.expired, + playlist_order=item.playlist_order, + played_at=item.played_at, + star_rating=item.beatmap.difficulty_rating + if item.beatmap is not None + else 0.0, + freestyle=item.freestyle, + ) + ) + + return cls( + room_id=room.id, + state=getattr(room, "state", MultiplayerRoomState.OPEN), + settings=MultiplayerRoomSettings( + name=room.name, + playlist_item_id=playlist[0].id if playlist else 0, + password=getattr(room, "password", ""), + match_type=room.type, + queue_mode=room.queue_mode, + auto_start_duration=timedelta(seconds=room.auto_start_duration), + auto_skip=room.auto_skip, + ), + users=users, + host=host_user, + match_state=None, + playlist=playlist, + active_countdowns=[], + channel_id=getattr(room, "channel_id", 0), + ) + class MultiplayerQueue: def __init__(self, room: "ServerMultiplayerRoom"): diff --git a/app/router/room.py b/app/router/room.py index 476eaf2..800c861 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -1,13 +1,17 @@ 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.room import RoomResp +from app.database.playlists import Playlist +from app.database.room import Room, RoomBase, RoomResp from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher +from app.models.multiplayer_hub import MultiplayerRoom, ServerMultiplayerRoom from app.models.room import RoomStatus from app.signalr.hub import MultiplayerHubs @@ -33,22 +37,68 @@ async def get_all_rooms( rooms = MultiplayerHubs.rooms.values() resp_list: list[RoomResp] = [] for room in rooms: - if category != "realtime": # 歌单模式的处理逻辑 - if room.category == category: - if mode == "owned": - if ( - room.room.host.user_id if room.room.host is not None else 0 - ) != current_user.id: - continue - else: - continue - else: - if mode == "owned": - if ( - room.room.host.user_id if room.room.host is not None else 0 - ) != current_user.id: - continue - if room.status != status: - continue + if category == "realtime" and room.category != "normal": + continue + elif category != room.category: + continue resp_list.append(await RoomResp.from_hub(room)) return resp_list + + +class APICreatedRoom(RoomResp): + error: str = "" + + +class APIUploadedRoom(RoomBase): + def to_room(self) -> Room: + """ + 将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。 + """ + room_dict = self.model_dump() + room_dict.pop("playlist", None) + # host_id 已在字段中 + return Room(**room_dict) + + id: int | None + host_id: int | None = None + playlist: list[Playlist] + + +@router.post("/rooms", tags=["room"], response_model=APICreatedRoom) +async def create_room( + room: APIUploadedRoom, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + # db_room = Room.from_resp(room) + await db.refresh(current_user) + user_id = current_user.id + db_room = room.to_room() + db_room.host_id = current_user.id if current_user.id else 1 + db.add(db_room) + await db.commit() + await db.refresh(db_room) + + playlist: list[Playlist] = [] + # 处理 APIUploadedRoom 里的 playlist 字段 + for item in room.playlist: + # 确保 room_id 正确赋值 + item.id = await Playlist.get_next_id_for_room(db_room.id, db) + item.room_id = db_room.id + item.owner_id = user_id if user_id else 1 + db.add(item) + await db.commit() + await db.refresh(item) + playlist.append(item) + await db.refresh(db_room) + db_room.playlist = playlist + server_room = ServerMultiplayerRoom( + room=MultiplayerRoom.from_db(db_room), + category=db_room.category, + start_at=datetime.now(UTC), + hub=MultiplayerHubs, + ) + MultiplayerHubs.rooms[db_room.id] = server_room + created_room = APICreatedRoom.model_validate(db_room) + created_room.error = "" + return created_room diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index af28d26..fa869b6 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -103,7 +103,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): item = room.playlist[0] item.owner_id = client.user_id room.room_id = db_room.id - starts_at = db_room.starts_at + starts_at = db_room.starts_at or datetime.now(UTC) await Playlist.add_to_db(item, db_room.id, session) server_room = ServerMultiplayerRoom( room=room, From ff25e58696780157a1523151ce7d6493d47a8069 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Thu, 7 Aug 2025 07:37:24 +0000 Subject: [PATCH 30/65] fix(room): solve 500 in API POST /rooms --- app/database/playlists.py | 5 +++-- app/router/room.py | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/database/playlists.py b/app/database/playlists.py index 432c3b0..3ecb75f 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -134,6 +134,7 @@ class PlaylistResp(PlaylistBase): @classmethod async def from_db(cls, playlist: Playlist) -> "PlaylistResp": - resp = cls.model_validate(playlist) - resp.beatmap = await BeatmapResp.from_db(playlist.beatmap) + data = playlist.model_dump() + data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True) + resp = cls.model_validate(data) return resp diff --git a/app/router/room.py b/app/router/room.py index 800c861..5f3a684 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -37,10 +37,10 @@ async def get_all_rooms( rooms = MultiplayerHubs.rooms.values() resp_list: list[RoomResp] = [] for room in rooms: - if category == "realtime" and room.category != "normal": - continue - elif category != room.category: - continue + # if category == "realtime" and room.category != "normal": + # continue + # elif category != room.category and category != "": + # continue resp_list.append(await RoomResp.from_hub(room)) return resp_list @@ -99,6 +99,6 @@ async def create_room( hub=MultiplayerHubs, ) MultiplayerHubs.rooms[db_room.id] = server_room - created_room = APICreatedRoom.model_validate(db_room) + created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room)) created_room.error = "" return created_room From bf04ea02d861da9afc84a8724edc86ddbaed2c65 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Thu, 7 Aug 2025 08:11:26 +0000 Subject: [PATCH 31/65] fix(multiplayer): don't re-add the last item when `HOST_ONLY` --- app/models/multiplayer_hub.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index ed37b98..25e359c 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -284,6 +284,8 @@ class PlaylistItem(BaseModel): copy = self.model_copy() copy.required_mods = list(self.required_mods) copy.allowed_mods = list(self.allowed_mods) + copy.expired = False + copy.played_at = None return copy From d130915b4a4e194cca704784a682701ad61a61cc Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Thu, 7 Aug 2025 11:16:28 +0000 Subject: [PATCH 32/65] feat(rooms): add API GET /rooms/{room} --- app/router/room.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/router/room.py b/app/router/room.py index 5f3a684..b992e5b 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -102,3 +102,12 @@ async def create_room( created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room)) created_room.error = "" return created_room + + +@router.get("/rooms/{room}", tags=["room"], response_model=RoomResp) +async def get_room( + room: int, + db: AsyncSession = Depends(get_db), +): + server_room = MultiplayerHubs.rooms[room] + return await RoomResp.from_hub(server_room) From 18d16e2542997d5eb39e6b8b94ab8bb130be4e40 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Thu, 7 Aug 2025 12:00:19 +0000 Subject: [PATCH 33/65] feat(rooms): add router PUT /rooms/{room}/users/{user} --- app/router/room.py | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index b992e5b..1c51753 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -5,21 +5,27 @@ from time import timezone from typing import Literal from app.database.lazer_user import User -from app.database.playlists import Playlist +from app.database.playlists import Playlist, PlaylistResp from app.database.room import Room, RoomBase, RoomResp from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher -from app.models.multiplayer_hub import MultiplayerRoom, ServerMultiplayerRoom +from app.models.multiplayer_hub import ( + MultiplayerRoom, + MultiplayerRoomUser, + ServerMultiplayerRoom, +) from app.models.room import RoomStatus from app.signalr.hub import MultiplayerHubs from .api_router import router -from fastapi import Depends, Query +from fastapi import Depends, HTTPException, Query from redis.asyncio import Redis +from sqlmodel import 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]) @@ -111,3 +117,30 @@ async def get_room( ): server_room = MultiplayerHubs.rooms[room] return await RoomResp.from_hub(server_room) + + +@router.delete("/rooms/{room}", tags=["room"]) +async def delete_room(room: int, db: AsyncSession = Depends(get_db)): + db_room = (await db.exec(select(Room).where(Room.id == room))).first() + if db_room is None: + raise HTTPException(404, "Room not found") + else: + await db.delete(db_room) + return None + + +@router.put("/rooms/{room}/users/{user}", tags=["room"]) +async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_db)): + server_room = MultiplayerHubs.rooms[room] + server_room.room.users.append(MultiplayerRoomUser(user_id=user)) + db_room = (await db.exec(select(Room).where(Room.id == room))).first() + if db_room is not None: + db_room.participant_count += 1 + await db.commit() + resp = await RoomResp.from_hub(server_room) + await db.refresh(db_room) + for item in db_room.playlist: + resp.playlist.append(await PlaylistResp.from_db(item)) + return resp + else: + raise HTTPException(404, "room not found0") From bc2961de1094a4453b2f66e8ddaa349afb805ca2 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Thu, 7 Aug 2025 14:52:02 +0000 Subject: [PATCH 34/65] feat(playlist): support leaderboard **UNTESTED** --- app/database/__init__.py | 3 +- app/database/playlist_attempts.py | 117 ++++++++++++++++++++++++++-- app/database/playlist_best_score.py | 2 + app/database/room.py | 10 --- app/router/room.py | 40 +++++++++- app/router/score.py | 26 ++++++- 6 files changed, 175 insertions(+), 23 deletions(-) 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 From 7a2c8c1fb4f8cebd065b846ede1331ec69103d76 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Thu, 7 Aug 2025 16:18:54 +0000 Subject: [PATCH 35/65] feat(multiplayer): support multiplayer events --- app/database/__init__.py | 3 + app/database/multiplayer_event.py | 53 +++++++++++++ app/database/playlist_attempts.py | 4 +- app/database/playlists.py | 7 +- app/database/room.py | 2 +- app/models/multiplayer_hub.py | 35 ++++++++- app/router/room.py | 120 +++++++++++++++++++++++++++- app/signalr/hub/multiplayer.py | 126 ++++++++++++++++++++++++++++++ 8 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 app/database/multiplayer_event.py diff --git a/app/database/__init__.py b/app/database/__init__.py index dbfd3b8..0ee253b 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -15,6 +15,7 @@ from .lazer_user import ( User, UserResp, ) +from .multiplayer_event import MultiplayerEvent, MultiplayerEventResp from .playlist_attempts import ItemAttemptsCount, ItemAttemptsResp from .playlist_best_score import PlaylistBestScore from .playlists import Playlist, PlaylistResp @@ -51,6 +52,8 @@ __all__ = [ "FavouriteBeatmapset", "ItemAttemptsCount", "ItemAttemptsResp", + "MultiplayerEvent", + "MultiplayerEventResp", "MultiplayerScores", "OAuthToken", "PPBestScore", diff --git a/app/database/multiplayer_event.py b/app/database/multiplayer_event.py new file mode 100644 index 0000000..b80f957 --- /dev/null +++ b/app/database/multiplayer_event.py @@ -0,0 +1,53 @@ +from datetime import UTC, datetime +from typing import Any + +from app.models.model import UTCBaseModel + +from sqlmodel import ( + JSON, + BigInteger, + Column, + DateTime, + Field, + ForeignKey, + SQLModel, +) + + +class MultiplayerEventBase(SQLModel, UTCBaseModel): + playlist_item_id: int | None = None + user_id: int | None = Field( + default=None, + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True), + ) + created_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), + ), + default=datetime.now(UTC), + ) + event_type: str = Field(index=True) + + +class MultiplayerEvent(MultiplayerEventBase, table=True): + __tablename__ = "multiplayer_events" # pyright: ignore[reportAssignmentType] + id: int | None = Field(default=None, primary_key=True) + room_id: int = Field(foreign_key="rooms.id", index=True) + updated_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), + ), + default=datetime.now(UTC), + ) + event_detail: dict[str, Any] | None = Field( + sa_column=Column(JSON), + default_factory=dict, + ) + + +class MultiplayerEventResp(MultiplayerEventBase): + id: int + + @classmethod + def from_db(cls, event: MultiplayerEvent) -> "MultiplayerEventResp": + return cls.model_validate(event) diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py index da49981..93bc8c5 100644 --- a/app/database/playlist_attempts.py +++ b/app/database/playlist_attempts.py @@ -29,9 +29,7 @@ class ItemAttemptsCountBase(SQLModel): 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 - ) + id: int | None = Field(default=None, primary_key=True) user: User = Relationship() diff --git a/app/database/playlists.py b/app/database/playlists.py index 3ecb75f..3f7ae40 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -133,8 +133,11 @@ class PlaylistResp(PlaylistBase): beatmap: BeatmapResp | None = None @classmethod - async def from_db(cls, playlist: Playlist) -> "PlaylistResp": + async def from_db( + cls, playlist: Playlist, include: list[str] = [] + ) -> "PlaylistResp": data = playlist.model_dump() - data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True) + if "beatmap" in include: + data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True) resp = cls.model_validate(data) return resp diff --git a/app/database/room.py b/app/database/room.py index e01dece..7817805 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -98,7 +98,7 @@ class RoomResp(RoomBase): difficulty_range.max = max( difficulty_range.max, playlist.beatmap.difficulty_rating ) - resp.playlist.append(await PlaylistResp.from_db(playlist)) + resp.playlist.append(await PlaylistResp.from_db(playlist, ["beatmap"])) stats.ruleset_ids = list(rulesets) resp.playlist_item_stats = stats resp.difficulty_range = difficulty_range diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 25e359c..09d8900 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -6,7 +6,16 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import UTC, datetime, timedelta from enum import IntEnum -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, cast, override +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + ClassVar, + Literal, + TypedDict, + cast, + override, +) from app.database.beatmap import Beatmap from app.dependencies.database import engine @@ -705,6 +714,9 @@ class MatchTypeHandler(ABC): @abstractmethod async def handle_leave(self, user: MultiplayerRoomUser): ... + @abstractmethod + def get_details(self) -> MatchStartedEventDetail: ... + class HeadToHeadHandler(MatchTypeHandler): @override @@ -721,6 +733,11 @@ class HeadToHeadHandler(MatchTypeHandler): @override async def handle_leave(self, user: MultiplayerRoomUser): ... + @override + def get_details(self) -> MatchStartedEventDetail: + detail = MatchStartedEventDetail(room_type="head_to_head", team=None) + return detail + class TeamVersusHandler(MatchTypeHandler): @override @@ -780,6 +797,17 @@ class TeamVersusHandler(MatchTypeHandler): @override async def handle_leave(self, user: MultiplayerRoomUser): ... + @override + def get_details(self) -> MatchStartedEventDetail: + teams: dict[int, Literal["blue", "red"]] = {} + for user in self.room.room.users: + if user.match_state is not None and isinstance( + user.match_state, TeamVersusUserState + ): + teams[user.user_id] = "blue" if user.match_state.team_id == 1 else "red" + detail = MatchStartedEventDetail(room_type="team_versus", team=teams) + return detail + MATCH_TYPE_HANDLERS = { MatchType.HEAD_TO_HEAD: HeadToHeadHandler, @@ -890,3 +918,8 @@ MatchServerEvent = CountdownStartedEvent | CountdownStoppedEvent class GameplayAbortReason(IntEnum): LOAD_TOOK_TOO_LONG = 0 HOST_ABORTED = 1 + + +class MatchStartedEventDetail(TypedDict): + room_type: Literal["playlists", "head_to_head", "team_versus"] + team: dict[int, Literal["blue", "red"]] | None diff --git a/app/router/room.py b/app/router/room.py index d5bc713..2677b75 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -3,10 +3,14 @@ from __future__ import annotations from datetime import UTC, datetime from typing import Literal -from app.database.lazer_user import User +from app.database.beatmap import Beatmap, BeatmapResp +from app.database.beatmapset import BeatmapsetResp +from app.database.lazer_user import User, UserResp +from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp 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.database.score import Score from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user @@ -140,7 +144,7 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ resp = await RoomResp.from_hub(server_room) await db.refresh(db_room) for item in db_room.playlist: - resp.playlist.append(await PlaylistResp.from_db(item)) + resp.playlist.append(await PlaylistResp.from_db(item, ["beatmap"])) return resp else: raise HTTPException(404, "room not found0") @@ -178,3 +182,115 @@ async def get_room_leaderboard( leaderboard=aggs_resp, user_score=user_agg, ) + + +class RoomEvents(BaseModel): + beatmaps: list[BeatmapResp] = Field(default_factory=list) + beatmapsets: dict[int, BeatmapsetResp] = Field(default_factory=dict) + current_playlist_item_id: int = 0 + events: list[MultiplayerEventResp] = Field(default_factory=list) + first_event_id: int = 0 + last_event_id: int = 0 + playlist_items: list[PlaylistResp] = Field(default_factory=list) + room: RoomResp + user: list[UserResp] = Field(default_factory=list) + + +@router.get("/rooms/{room_id}/events", response_model=RoomEvents, tags=["room"]) +async def get_room_events( + room_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + limit: int = Query(100, ge=1, le=1000), + after: int | None = Query(None, ge=0), + before: int | None = Query(None, ge=0), +): + events = ( + await db.exec( + select(MultiplayerEvent) + .where( + MultiplayerEvent.room_id == room_id, + col(MultiplayerEvent.id) > after if after is not None else True, + col(MultiplayerEvent.id) < before if before is not None else True, + ) + .order_by(col(MultiplayerEvent.id).desc()) + .limit(limit) + ) + ).all() + + user_ids = set() + playlist_items = {} + beatmap_ids = set() + + event_resps = [] + first_event_id = 0 + last_event_id = 0 + + current_playlist_item_id = 0 + for event in events: + event_resps.append(MultiplayerEventResp.from_db(event)) + + if event.user_id: + user_ids.add(event.user_id) + + if event.playlist_item_id is not None and ( + playitem := ( + await db.exec( + select(Playlist).where( + Playlist.id == event.playlist_item_id, + Playlist.room_id == room_id, + ) + ) + ).first() + ): + current_playlist_item_id = playitem.id + playlist_items[event.playlist_item_id] = playitem + beatmap_ids.add(playitem.beatmap_id) + scores = await db.exec( + select(Score).where( + Score.playlist_item_id == event.playlist_item_id, + Score.room_id == room_id, + ) + ) + for score in scores: + user_ids.add(score.user_id) + beatmap_ids.add(score.beatmap_id) + + assert event.id is not None + first_event_id = min(first_event_id, event.id) + last_event_id = max(last_event_id, event.id) + + if room := MultiplayerHubs.rooms.get(room_id): + current_playlist_item_id = room.queue.current_item.id + room_resp = await RoomResp.from_hub(room) + else: + room = (await db.exec(select(Room).where(Room.id == room_id))).first() + if room is None: + raise HTTPException(404, "Room not found") + room_resp = await RoomResp.from_db(room) + + users = await db.exec(select(User).where(col(User.id).in_(user_ids))) + user_resps = [await UserResp.from_db(user, db) for user in users] + beatmaps = await db.exec(select(Beatmap).where(col(Beatmap.id).in_(beatmap_ids))) + beatmap_resps = [ + await BeatmapResp.from_db(beatmap, session=db) for beatmap in beatmaps + ] + beatmapset_resps = {} + for beatmap_resp in beatmap_resps: + beatmapset_resps[beatmap_resp.beatmapset_id] = beatmap_resp.beatmapset + + playlist_items_resps = [ + await PlaylistResp.from_db(item) for item in playlist_items.values() + ] + + return RoomEvents( + beatmaps=beatmap_resps, + beatmapsets=beatmapset_resps, + current_playlist_item_id=current_playlist_item_id, + events=event_resps, + first_event_id=first_event_id, + last_event_id=last_event_id, + playlist_items=playlist_items_resps, + room=room_resp, + user=user_resps, + ) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index fa869b6..3688efa 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -7,6 +7,7 @@ from typing import override from app.database import Room from app.database.beatmap import Beatmap from app.database.lazer_user import User +from app.database.multiplayer_event import MultiplayerEvent from app.database.playlists import Playlist from app.database.relationship import Relationship, RelationshipType from app.dependencies.database import engine, get_redis @@ -20,6 +21,7 @@ from app.models.multiplayer_hub import ( MatchRequest, MatchServerEvent, MatchStartCountdown, + MatchStartedEventDetail, MultiplayerClientState, MultiplayerRoom, MultiplayerRoomSettings, @@ -49,11 +51,100 @@ from sqlmodel.ext.asyncio.session import AsyncSession GAMEPLAY_LOAD_TIMEOUT = 30 +class MultiplayerEventLogger: + def __init__(self): + pass + + async def log_event(self, event: MultiplayerEvent): + try: + async with AsyncSession(engine) as session: + session.add(event) + await session.commit() + except Exception as e: + logger.warning(f"Failed to log multiplayer room event to database: {e}") + + async def room_created(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="room_created", + ) + await self.log_event(event) + + async def room_disbanded(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="room_disbanded", + ) + await self.log_event(event) + + async def player_joined(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="player_joined", + ) + await self.log_event(event) + + async def player_left(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="player_left", + ) + await self.log_event(event) + + async def player_kicked(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="player_kicked", + ) + await self.log_event(event) + + async def host_changed(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="host_changed", + ) + await self.log_event(event) + + async def game_started( + self, room_id: int, playlist_item_id: int, details: MatchStartedEventDetail + ): + event = MultiplayerEvent( + room_id=room_id, + playlist_item_id=playlist_item_id, + event_type="game_started", + event_detail=details, # pyright: ignore[reportArgumentType] + ) + await self.log_event(event) + + async def game_aborted(self, room_id: int, playlist_item_id: int): + event = MultiplayerEvent( + room_id=room_id, + playlist_item_id=playlist_item_id, + event_type="game_aborted", + ) + await self.log_event(event) + + async def game_completed(self, room_id: int, playlist_item_id: int): + event = MultiplayerEvent( + room_id=room_id, + playlist_item_id=playlist_item_id, + event_type="game_completed", + ) + await self.log_event(event) + + class MultiplayerHub(Hub[MultiplayerClientState]): @override def __init__(self): super().__init__() self.rooms: dict[int, ServerMultiplayerRoom] = {} + self.event_logger = MultiplayerEventLogger() @staticmethod def group_id(room: int) -> str: @@ -113,6 +204,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) self.rooms[room.room_id] = server_room await server_room.set_handler() + await self.event_logger.room_created(room.room_id, client.user_id) return await self.JoinRoomWithPassword( client, room.room_id, room.settings.password ) @@ -143,6 +235,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): room.users.append(user) self.add_to_group(client, self.group_id(room_id)) await server_room.match_type_handler.handle_join(user) + await self.event_logger.player_joined(room_id, user.user_id) return room async def ChangeBeatmapAvailability( @@ -550,10 +643,12 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if all( u.state != MultiplayerUserState.PLAYING for u in room.room.users ): + any_user_finished_playing = False for u in filter( lambda u: u.state == MultiplayerUserState.FINISHED_PLAY, room.room.users, ): + any_user_finished_playing = True await self.change_user_state( room, u, MultiplayerUserState.RESULTS ) @@ -562,6 +657,16 @@ class MultiplayerHub(Hub[MultiplayerClientState]): self.group_id(room.room.room_id), "ResultsReady", ) + if any_user_finished_playing: + await self.event_logger.game_completed( + room.room.room_id, + room.queue.current_item.id, + ) + else: + await self.event_logger.game_aborted( + room.room.room_id, + room.queue.current_item.id, + ) await room.queue.finish_current_item() async def change_room_state( @@ -635,6 +740,11 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ), self.start_gameplay, ) + await self.event_logger.game_started( + room.room.room_id, + room.queue.current_item.id, + details=room.match_type_handler.get_details(), + ) async def start_gameplay(self, room: ServerMultiplayerRoom): if room.room.state != MultiplayerRoomState.WAITING_FOR_LOAD: @@ -737,6 +847,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): host_id=room.room.host.user_id, ) ) + await self.event_logger.room_disbanded( + room.room.room_id, + room.room.host.user_id, + ) del self.rooms[room.room.room_id] async def LeaveRoom(self, client: Client): @@ -751,6 +865,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if user is None: raise InvokeException("You are not in this room") + await self.event_logger.player_left( + room.room_id, + user.user_id, + ) await self.make_user_leave(client, server_room, user) async def KickUser(self, client: Client, user_id: int): @@ -772,6 +890,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if user is None: raise InvokeException("User not found in this room") + await self.event_logger.player_kicked( + room.room_id, + user.user_id, + ) target_client = self.get_client_by_id(str(user.user_id)) if target_client is None: return @@ -800,6 +922,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): new_host = next((u for u in room.users if u.user_id == user_id), None) if new_host is None: raise InvokeException("User not found in this room") + await self.event_logger.host_changed( + room.room_id, + new_host.user_id, + ) await self.set_host(server_room, new_host) async def AbortGameplay(self, client: Client): From 2bb1e4bad2d6806f83cbf59b17a757b4868903ab Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Thu, 7 Aug 2025 16:21:56 +0000 Subject: [PATCH 36/65] fix(multiplayer): use bigint for `event.id` --- app/database/multiplayer_event.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/database/multiplayer_event.py b/app/database/multiplayer_event.py index b80f957..904fbe4 100644 --- a/app/database/multiplayer_event.py +++ b/app/database/multiplayer_event.py @@ -31,7 +31,10 @@ class MultiplayerEventBase(SQLModel, UTCBaseModel): class MultiplayerEvent(MultiplayerEventBase, table=True): __tablename__ = "multiplayer_events" # pyright: ignore[reportAssignmentType] - id: int | None = Field(default=None, primary_key=True) + id: int | None = Field( + default=None, + sa_column=Column(BigInteger, primary_key=True, autoincrement=True, index=True), + ) room_id: int = Field(foreign_key="rooms.id", index=True) updated_at: datetime = Field( sa_column=Column( From fb0bba1a6eeae8b0076cd0557dfeaeac9080503c Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 06:25:31 +0000 Subject: [PATCH 37/65] fix(signalr): fail to parse `MessagePack-CSharp-Union | None` type when protocol is msgpack --- app/signalr/packet.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index 09a36bd..8949f4b 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -242,7 +242,9 @@ class MsgpackProtocol: # except `X (Other Type) | None` if NoneType in args and v is None: return None - if not all(issubclass(arg, SignalRUnionMessage) for arg in args): + if not all( + issubclass(arg, SignalRUnionMessage) or arg is NoneType for arg in args + ): raise ValueError( f"Cannot validate {v} to {typ}, " "only SignalRUnionMessage subclasses are supported" From 0ac4f1f516fc29da6333d6f974017cee195bbbf1 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 11:54:43 +0000 Subject: [PATCH 38/65] refactor(beatmap,beatmapset): use to ensure beatmap exists --- app/database/beatmapset.py | 12 +++++++++++ app/router/beatmapset.py | 37 +++++++++------------------------- app/router/score.py | 10 ++++----- app/signalr/hub/multiplayer.py | 28 +++++++++++++++++++++---- app/signalr/hub/spectator.py | 27 ++++++++++++------------- 5 files changed, 64 insertions(+), 50 deletions(-) diff --git a/app/database/beatmapset.py b/app/database/beatmapset.py index 3bad7e9..12f3c67 100644 --- a/app/database/beatmapset.py +++ b/app/database/beatmapset.py @@ -13,6 +13,8 @@ from sqlmodel import Field, Relationship, SQLModel, col, func, select from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: + from app.fetcher import Fetcher + from .beatmap import Beatmap, BeatmapResp from .favourite_beatmapset import FavouriteBeatmapset @@ -185,6 +187,16 @@ class Beatmapset(AsyncAttrs, BeatmapsetBase, table=True): await Beatmap.from_resp_batch(session, resp.beatmaps, from_=from_) return beatmapset + @classmethod + async def get_or_fetch( + cls, session: AsyncSession, fetcher: "Fetcher", sid: int + ) -> "Beatmapset": + beatmapset = await session.get(Beatmapset, sid) + if not beatmapset: + resp = await fetcher.get_beatmapset(sid) + beatmapset = await cls.from_resp(session, resp) + return beatmapset + class BeatmapsetResp(BeatmapsetBase): id: int diff --git a/app/router/beatmapset.py b/app/router/beatmapset.py index f77c2ed..7280bad 100644 --- a/app/router/beatmapset.py +++ b/app/router/beatmapset.py @@ -12,7 +12,7 @@ from .api_router import router from fastapi import Depends, Form, HTTPException, Query from fastapi.responses import RedirectResponse -from httpx import HTTPStatusError +from httpx import HTTPError from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -24,22 +24,10 @@ async def lookup_beatmapset( db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), ): - beatmapset_id = ( - await db.exec(select(Beatmap.beatmapset_id).where(Beatmap.id == beatmap_id)) - ).first() - if not beatmapset_id: - try: - resp = await fetcher.get_beatmap(beatmap_id) - await Beatmap.from_resp(db, resp) - await db.refresh(current_user) - except HTTPStatusError: - raise HTTPException(status_code=404, detail="Beatmapset not found") - beatmapset = ( - await db.exec(select(Beatmapset).where(Beatmapset.id == beatmapset_id)) - ).first() - if not beatmapset: - raise HTTPException(status_code=404, detail="Beatmapset not found") - resp = await BeatmapsetResp.from_db(beatmapset, session=db, user=current_user) + beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap_id) + resp = await BeatmapsetResp.from_db( + beatmap.beatmapset, session=db, user=current_user + ) return resp @@ -50,18 +38,13 @@ async def get_beatmapset( db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), ): - beatmapset = (await db.exec(select(Beatmapset).where(Beatmapset.id == sid))).first() - if not beatmapset: - try: - resp = await fetcher.get_beatmapset(sid) - await Beatmapset.from_resp(db, resp) - except HTTPStatusError: - raise HTTPException(status_code=404, detail="Beatmapset not found") - else: - resp = await BeatmapsetResp.from_db( + try: + beatmapset = await Beatmapset.get_or_fetch(db, fetcher, sid) + return await BeatmapsetResp.from_db( beatmapset, session=db, include=["recent_favourites"], user=current_user ) - return resp + except HTTPError: + raise HTTPException(status_code=404, detail="Beatmapset not found") @router.get("/beatmapsets/{beatmapset}/download", tags=["beatmapset"]) diff --git a/app/router/score.py b/app/router/score.py index 5db171d..d776b5a 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -43,6 +43,7 @@ from app.models.score import ( from .api_router import router from fastapi import Depends, Form, HTTPException, Query +from httpx import HTTPError from pydantic import BaseModel from redis.asyncio import Redis from sqlalchemy.orm import joinedload @@ -86,12 +87,11 @@ async def submit_score( if not score: raise HTTPException(status_code=404, detail="Score not found") else: - beatmap_status = ( - await db.exec(select(Beatmap.beatmap_status).where(Beatmap.id == beatmap)) - ).first() - if beatmap_status is None: + try: + db_beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap) + except HTTPError: raise HTTPException(status_code=404, detail="Beatmap not found") - ranked = beatmap_status in { + ranked = db_beatmap.beatmap_status in { BeatmapRankStatus.RANKED, BeatmapRankStatus.APPROVED, } diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 3688efa..3f081e3 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -11,6 +11,7 @@ from app.database.multiplayer_event import MultiplayerEvent from app.database.playlists import Playlist from app.database.relationship import Relationship, RelationshipType from app.dependencies.database import engine, get_redis +from app.dependencies.fetcher import get_fetcher from app.exception import InvokeException from app.log import logger from app.models.mods import APIMod @@ -44,8 +45,9 @@ from app.models.score import GameMode from .hub import Client, Hub +from httpx import HTTPError from sqlalchemy import update -from sqlmodel import col, select +from sqlmodel import col, exists, select from sqlmodel.ext.asyncio.session import AsyncSession GAMEPLAY_LOAD_TIMEOUT = 30 @@ -191,11 +193,25 @@ class MultiplayerHub(Hub[MultiplayerClientState]): session.add(db_room) await session.commit() await session.refresh(db_room) + item = room.playlist[0] item.owner_id = client.user_id room.room_id = db_room.id starts_at = db_room.starts_at or datetime.now(UTC) + beatmap_exists = await session.exec( + select(exists().where(col(Beatmap.id) == item.beatmap_id)) + ) + if not beatmap_exists.one(): + fetcher = await get_fetcher() + try: + resp = await fetcher.get_beatmap(item.beatmap_id) + await Beatmap.from_resp(session, resp) + except HTTPError: + raise InvokeException( + "Failed to fetch beatmap, please retry later" + ) await Playlist.add_to_db(item, db_room.id, session) + server_room = ServerMultiplayerRoom( room=room, category=RoomCategory.NORMAL, @@ -372,6 +388,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def validate_styles(self, room: ServerMultiplayerRoom): + fetcher = await get_fetcher() if not room.queue.current_item.freestyle: for user in room.room.users: await self.change_user_style( @@ -381,9 +398,12 @@ class MultiplayerHub(Hub[MultiplayerClientState]): user, ) async with AsyncSession(engine) as session: - beatmap = await session.get(Beatmap, room.queue.current_item.beatmap_id) - if beatmap is None: - raise InvokeException("Beatmap not found") + try: + beatmap = await Beatmap.get_or_fetch( + session, fetcher, bid=room.queue.current_item.beatmap_id + ) + except HTTPError: + raise InvokeException("Current item beatmap not found") beatmap_ids = ( await session.exec( select(Beatmap.id, Beatmap.mode).where( diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index b9a3c99..d5a12ff 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -11,6 +11,7 @@ from app.database import Beatmap, User from app.database.score import Score from app.database.score_token import ScoreToken from app.dependencies.database import engine +from app.dependencies.fetcher import get_fetcher from app.models.beatmap import BeatmapRankStatus from app.models.mods import mods_to_int from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatistics @@ -179,15 +180,13 @@ class SpectatorHub(Hub[StoreClientState]): return if state.beatmap_id is None or state.ruleset_id is None: return + + fetcher = await get_fetcher() async with AsyncSession(engine) as session: async with session.begin(): - beatmap = ( - await session.exec( - select(Beatmap).where(Beatmap.id == state.beatmap_id) - ) - ).first() - if not beatmap: - return + beatmap = await Beatmap.get_or_fetch( + session, fetcher, bid=state.beatmap_id + ) user = ( await session.exec(select(User).where(User.id == user_id)) ).first() @@ -237,16 +236,16 @@ class SpectatorHub(Hub[StoreClientState]): user_id = int(client.connection_id) store = self.get_or_create_state(client) score = store.score - assert store.beatmap_status is not None - assert store.state is not None - assert store.score is not None - if not score or not store.score_token: + if ( + score is None + or store.score_token is None + or store.beatmap_status is None + or store.state is None + ): return if ( BeatmapRankStatus.PENDING < store.beatmap_status <= BeatmapRankStatus.LOVED - ) and any( - k.is_hit() and v > 0 for k, v in store.score.score_info.statistics.items() - ): + ) and any(k.is_hit() and v > 0 for k, v in score.score_info.statistics.items()): await self._process_score(store, client) store.state = None store.beatmap_status = None From 5bf733a94ea72a6f79ea030db4625017f7495bcf Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 12:00:06 +0000 Subject: [PATCH 39/65] fix(multiplayer): fix fetch beatmap when creating room --- app/signalr/hub/multiplayer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 3f081e3..efaabd9 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -204,8 +204,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if not beatmap_exists.one(): fetcher = await get_fetcher() try: - resp = await fetcher.get_beatmap(item.beatmap_id) - await Beatmap.from_resp(session, resp) + await Beatmap.get_or_fetch( + session, fetcher, bid=item.beatmap_id + ) except HTTPError: raise InvokeException( "Failed to fetch beatmap, please retry later" From dd7b8a14cdb9ebe46400a336850e84a94f0e756e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 12:07:48 +0000 Subject: [PATCH 40/65] fix(multiplayer): fail to fetch for multiplayer --- app/models/multiplayer_hub.py | 13 +++++++++---- app/router/beatmap.py | 25 ++++++++++++++++++++++--- app/signalr/hub/multiplayer.py | 2 +- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 09d8900..bf08ff0 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -19,6 +19,7 @@ from typing import ( from app.database.beatmap import Beatmap from app.dependencies.database import engine +from app.dependencies.fetcher import get_fetcher from app.exception import InvokeException from .mods import APIMod @@ -518,8 +519,11 @@ class MultiplayerQueue: raise InvokeException("Freestyle items cannot have allowed mods") async with AsyncSession(engine) as session: + fetcher = await get_fetcher() async with session: - beatmap = await session.get(Beatmap, item.beatmap_id) + beatmap = await Beatmap.get_or_fetch( + session, fetcher, bid=item.beatmap_id + ) if beatmap is None: raise InvokeException("Beatmap not found") if item.beatmap_checksum != beatmap.checksum: @@ -543,10 +547,11 @@ class MultiplayerQueue: raise InvokeException("Freestyle items cannot have allowed mods") async with AsyncSession(engine) as session: + fetcher = await get_fetcher() async with session: - beatmap = await session.get(Beatmap, item.beatmap_id) - if beatmap is None: - raise InvokeException("Beatmap not found") + beatmap = await Beatmap.get_or_fetch( + session, fetcher, bid=item.beatmap_id + ) if item.beatmap_checksum != beatmap.checksum: raise InvokeException("Checksum mismatch") diff --git a/app/router/beatmap.py b/app/router/beatmap.py index 7dfd0f9..6800246 100644 --- a/app/router/beatmap.py +++ b/app/router/beatmap.py @@ -77,6 +77,7 @@ async def batch_get_beatmaps( b_ids: list[int] = Query(alias="ids[]", default_factory=list), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), + fetcher: Fetcher = Depends(get_fetcher), ): if not b_ids: # select 50 beatmaps by last_updated @@ -86,9 +87,27 @@ async def batch_get_beatmaps( ) ).all() else: - beatmaps = ( - await db.exec(select(Beatmap).where(col(Beatmap.id).in_(b_ids)).limit(50)) - ).all() + beatmaps = list( + ( + await db.exec( + select(Beatmap).where(col(Beatmap.id).in_(b_ids)).limit(50) + ) + ).all() + ) + not_found_beatmaps = [ + bid for bid in b_ids if bid not in [bm.id for bm in beatmaps] + ] + beatmaps.extend( + beatmap + for beatmap in await asyncio.gather( + *[ + Beatmap.get_or_fetch(db, fetcher, bid=bid) + for bid in not_found_beatmaps + ], + return_exceptions=True, + ) + if isinstance(beatmap, Beatmap) + ) return BatchGetResp( beatmaps=[ diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index efaabd9..b7aa14c 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -211,7 +211,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException( "Failed to fetch beatmap, please retry later" ) - await Playlist.add_to_db(item, db_room.id, session) + await Playlist.add_to_db(item, room.room_id, session) server_room = ServerMultiplayerRoom( room=room, From 07a23c522550a01ae45a6c3f8ed7423df7eb4963 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 12:15:29 +0000 Subject: [PATCH 41/65] fix(multiplayer): cannot play the next item when the count of items > 1 --- app/models/multiplayer_hub.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index bf08ff0..13c7119 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -652,6 +652,7 @@ class MultiplayerQueue: ): assert self.room.host await self.add_item(self.current_item.clone(), self.room.host) + await self.update_current_item() async def update_queue_mode(self): if self.room.settings.queue_mode == QueueMode.HOST_ONLY and all( From 28f78882941cccfb3af5b69f6e5ba7d54f0bfc0b Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 12:35:26 +0000 Subject: [PATCH 42/65] fix(multiplayer): don't save `item_id` & `room_id` in database --- app/router/score.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/router/score.py b/app/router/score.py index d776b5a..73ec2e2 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -104,6 +104,8 @@ async def submit_score( fetcher, db, redis, + item_id, + room_id, ) await db.refresh(current_user) score_id = score.id From 0710ccecbef82037fefb6b73826b84a29b198ca4 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Fri, 8 Aug 2025 13:07:29 +0000 Subject: [PATCH 43/65] fix(multiplayer): move playlists rooms to database --- app/database/room.py | 3 ++- app/router/room.py | 45 +++++++++++++++++++------------------------- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/app/database/room.py b/app/database/room.py index 7817805..cbbc2ba 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -1,5 +1,6 @@ from datetime import UTC, datetime +from app.models.model import UTCBaseModel from app.models.multiplayer_hub import ServerMultiplayerRoom from app.models.room import ( MatchType, @@ -24,7 +25,7 @@ from sqlmodel import ( ) -class RoomBase(SQLModel): +class RoomBase(SQLModel, UTCBaseModel): name: str = Field(index=True) category: RoomCategory = Field(default=RoomCategory.NORMAL, index=True) duration: int | None = Field(default=None) # minutes diff --git a/app/router/room.py b/app/router/room.py index 2677b75..7ff1211 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -15,11 +15,6 @@ from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher -from app.models.multiplayer_hub import ( - MultiplayerRoom, - MultiplayerRoomUser, - ServerMultiplayerRoom, -) from app.models.room import RoomStatus from app.signalr.hub import MultiplayerHubs @@ -44,14 +39,11 @@ async def get_all_rooms( redis: Redis = Depends(get_redis), current_user: User = Depends(get_current_user), ): - rooms = MultiplayerHubs.rooms.values() resp_list: list[RoomResp] = [] - for room in rooms: - # if category == "realtime" and room.category != "normal": - # continue - # elif category != room.category and category != "": - # continue - resp_list.append(await RoomResp.from_hub(room)) + db_rooms = (await db.exec(select(Room).where(True))).unique().all() + for room in db_rooms: + if room.ended_at is not None and room.ended_at > datetime.now(UTC): + resp_list.append(await RoomResp.from_db(room)) return resp_list @@ -85,6 +77,10 @@ async def create_room( user_id = current_user.id db_room = room.to_room() db_room.host_id = current_user.id if current_user.id else 1 + db_room.starts_at = datetime.now(UTC) + # db_room.ended_at = db_room.starts_at + timedelta( + # minutes=db_room.duration if db_room.duration is not None else 0 + # ) db.add(db_room) await db.commit() await db.refresh(db_room) @@ -102,13 +98,7 @@ async def create_room( playlist.append(item) await db.refresh(db_room) db_room.playlist = playlist - server_room = ServerMultiplayerRoom( - room=MultiplayerRoom.from_db(db_room), - category=db_room.category, - start_at=datetime.now(UTC), - hub=MultiplayerHubs, - ) - MultiplayerHubs.rooms[db_room.id] = server_room + await db.refresh(db_room) created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room)) created_room.error = "" return created_room @@ -117,10 +107,15 @@ async def create_room( @router.get("/rooms/{room}", tags=["room"], response_model=RoomResp) async def get_room( room: int, + category: str = Query(default=""), db: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), ): - server_room = MultiplayerHubs.rooms[room] - return await RoomResp.from_hub(server_room) + # 直接从db获取信息,毕竟都一样 + db_room = (await db.exec(select(Room).where(Room.id == room))).first() + if db_room is None: + raise HTTPException(404, "Room not found") + return await RoomResp.from_db(db_room) @router.delete("/rooms/{room}", tags=["room"]) @@ -135,13 +130,11 @@ async def delete_room(room: int, db: AsyncSession = Depends(get_db)): @router.put("/rooms/{room}/users/{user}", tags=["room"]) async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_db)): - server_room = MultiplayerHubs.rooms[room] - server_room.room.users.append(MultiplayerRoomUser(user_id=user)) db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is not None: db_room.participant_count += 1 await db.commit() - resp = await RoomResp.from_hub(server_room) + resp = await RoomResp.from_db(db_room) await db.refresh(db_room) for item in db_room.playlist: resp.playlist.append(await PlaylistResp.from_db(item, ["beatmap"])) @@ -161,8 +154,8 @@ async def get_room_leaderboard( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - server_room = MultiplayerHubs.rooms[room] - if not server_room: + db_room = (await db.exec(select(Room).where(Room.id == room))).first() + if db_room is None: raise HTTPException(404, "Room not found") aggs = await db.exec( From a4461d4efb87707f31ba191a5541033f15dbcc86 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 17:34:47 +0000 Subject: [PATCH 44/65] fix(room): rename `ended_at` to `ends_at` to fix room ended bug --- app/database/room.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/database/room.py b/app/database/room.py index cbbc2ba..a478f16 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -35,7 +35,7 @@ class RoomBase(SQLModel, UTCBaseModel): ), default=datetime.now(UTC), ) - ended_at: datetime | None = Field( + ends_at: datetime | None = Field( sa_column=Column( DateTime(timezone=True), ), From 00076c247f654fc9c3c483e4d2ad7a3668120dd9 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 17:36:34 +0000 Subject: [PATCH 45/65] fix(playlist): fix model validation bug in playlist_attemps.py:106 --- app/database/playlist_attempts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py index 93bc8c5..d4085da 100644 --- a/app/database/playlist_attempts.py +++ b/app/database/playlist_attempts.py @@ -103,7 +103,7 @@ class ItemAttemptsResp(ItemAttemptsCountBase): session: AsyncSession, include: list[str] = [], ) -> "ItemAttemptsResp": - resp = cls.model_validate(item_attempts) + resp = cls.model_validate(item_attempts.model_dump()) resp.user = await UserResp.from_db( item_attempts.user, session=session, From f4a46054d2c9597d6b1432ea935faed6957aa941 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 17:38:02 +0000 Subject: [PATCH 46/65] fix(multiplayer): fix logic errors in a room's end time --- app/router/room.py | 13 +++++++------ app/router/score.py | 5 ++++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index 7ff1211..3da8ca5 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Literal from app.database.beatmap import Beatmap, BeatmapResp @@ -42,8 +42,8 @@ async def get_all_rooms( resp_list: list[RoomResp] = [] db_rooms = (await db.exec(select(Room).where(True))).unique().all() for room in db_rooms: - if room.ended_at is not None and room.ended_at > datetime.now(UTC): - resp_list.append(await RoomResp.from_db(room)) + # if room.ends_at is not None and room.ends_at > datetime.now(UTC): + resp_list.append(await RoomResp.from_db(room)) return resp_list @@ -78,9 +78,9 @@ async def create_room( db_room = room.to_room() db_room.host_id = current_user.id if current_user.id else 1 db_room.starts_at = datetime.now(UTC) - # db_room.ended_at = db_room.starts_at + timedelta( - # minutes=db_room.duration if db_room.duration is not None else 0 - # ) + db_room.ends_at = db_room.starts_at + timedelta( + minutes=db_room.duration if db_room.duration is not None else 0 + ) db.add(db_room) await db.commit() await db.refresh(db_room) @@ -134,6 +134,7 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ if db_room is not None: db_room.participant_count += 1 await db.commit() + await db.refresh(db_room) resp = await RoomResp.from_db(db_room) await db.refresh(db_room) for item in db_room.playlist: diff --git a/app/router/score.py b/app/router/score.py index 73ec2e2..9963559 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -285,7 +285,10 @@ async def create_playlist_score( 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): + db_room_time = ( + room.ends_at.replace(tzinfo=UTC) if room.ends_at is not None else room.starts_at + ) + if db_room_time and db_room_time < datetime.now(UTC): raise HTTPException(status_code=400, detail="Room has ended") item = ( await session.exec( From 698c0c2a818bccf82f3643b9616c526d80f9015b Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 17:38:37 +0000 Subject: [PATCH 47/65] chore: add a little script to clean ANSI charactors from logs from uvicorn --- remove_ansi.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 remove_ansi.py diff --git a/remove_ansi.py b/remove_ansi.py new file mode 100644 index 0000000..1720888 --- /dev/null +++ b/remove_ansi.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Script to remove ANSI escape codes from log files +""" + +from __future__ import annotations + +import re +import sys + + +def remove_ansi_codes(text): + """ + Remove ANSI escape codes from text + """ + # Regular expression to match ANSI escape codes + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + return ansi_escape.sub("", text) + + +def process_log_file(input_file, output_file=None): + """ + Process log file and remove ANSI escape codes + """ + if output_file is None: + output_file = ( + input_file.replace(".log", "_clean.log") + if ".log" in input_file + else input_file + "_clean" + ) + + with open(input_file, "r", encoding="utf-8") as infile: + content = infile.read() + + # Remove ANSI escape codes + clean_content = remove_ansi_codes(content) + + with open(output_file, "w", encoding="utf-8") as outfile: + outfile.write(clean_content) + + print(f"Processed {input_file} -> {output_file}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python remove_ansi.py [output_file]") + sys.exit(1) + + input_file = sys.argv[1] + output_file = sys.argv[2] if len(sys.argv) > 2 else None + + process_log_file(input_file, output_file) From c49c0481d08d816fcbbccfb25f78a0b8f2da765f Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 18:21:15 +0000 Subject: [PATCH 48/65] fix(multiplayer): fix acc bug on leaderboards --- app/database/playlist_attempts.py | 4 ++-- app/router/room.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py index d4085da..cfd8147 100644 --- a/app/database/playlist_attempts.py +++ b/app/database/playlist_attempts.py @@ -61,8 +61,7 @@ class ItemAttemptsCount(ItemAttemptsCountBase, table=True): 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 + sum(score.score.accuracy for score in playlist_scores) / self.completed if self.completed > 0 else 0.0 ) @@ -111,4 +110,5 @@ class ItemAttemptsResp(ItemAttemptsCountBase): ) if "position" in include: resp.position = await item_attempts.get_position(session) + # resp.accuracy *= 100 return resp diff --git a/app/router/room.py b/app/router/room.py index 3da8ca5..45d636b 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -169,6 +169,7 @@ async def get_room_leaderboard( for i, agg in enumerate(aggs): resp = await ItemAttemptsResp.from_db(agg, db) resp.position = i + 1 + # resp.accuracy *= 100 aggs_resp.append(resp) if agg.user_id == current_user.id: user_agg = resp From 4ded7f296909c0f962182236c87d3335df548b49 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 18:30:10 +0000 Subject: [PATCH 49/65] feat(multiplayer): add support for users leaving playlists room --- app/router/room.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/router/room.py b/app/router/room.py index 45d636b..f98e294 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -144,6 +144,19 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ raise HTTPException(404, "room not found0") +@router.delete("/rooms/{room}/users/{user}", tags=["room"]) +async def remove_user_from_room( + room: int, user: int, db: AsyncSession = Depends(get_db) +): + db_room = (await db.exec(select(Room).where(Room.id == room))).first() + if db_room is not None: + db_room.participant_count -= 1 + await db.commit() + return None + else: + raise HTTPException(404, "Room not found") + + class APILeaderboard(BaseModel): leaderboard: list[ItemAttemptsResp] = Field(default_factory=list) user_score: ItemAttemptsResp | None = None From a4f5582c95c85a31e0b13e42d2ff61155c8dea55 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 18:47:41 +0000 Subject: [PATCH 50/65] feat(multiplayer): add basic filter options for /rooms API --- app/router/room.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index f98e294..b4d77e2 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -32,7 +32,7 @@ async def get_all_rooms( mode: Literal["open", "ended", "participated", "owned", None] = Query( default="open" ), # TODO: 对房间根据状态进行筛选 - category: str = Query(default="realtime"), # TODO + category: str | None = Query(None), # TODO status: RoomStatus | None = Query(None), db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), @@ -42,8 +42,15 @@ async def get_all_rooms( resp_list: list[RoomResp] = [] db_rooms = (await db.exec(select(Room).where(True))).unique().all() for room in db_rooms: - # if room.ends_at is not None and room.ends_at > datetime.now(UTC): - resp_list.append(await RoomResp.from_db(room)) + if category == "realtime": + if room.id in MultiplayerHubs.rooms: + resp_list.append(await RoomResp.from_db(room)) + elif category is not None: + if category == room.category: + resp_list.append(await RoomResp.from_db(room)) + else: + if room.id not in MultiplayerHubs.rooms: + resp_list.append(await RoomResp.from_db(room)) return resp_list From 3e3cf27acc2a67d9d3884b68885135d201d1e2a5 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 18:49:05 +0000 Subject: [PATCH 51/65] fix(multiplayer): fxxk python datetime timezone-aware or not, who cares --- app/router/score.py | 6 ++---- app/signalr/hub/multiplayer.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/router/score.py b/app/router/score.py index 9963559..70664a5 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -285,10 +285,8 @@ async def create_playlist_score( room = await session.get(Room, room_id) if not room: raise HTTPException(status_code=404, detail="Room not found") - db_room_time = ( - room.ends_at.replace(tzinfo=UTC) if room.ends_at is not None else room.starts_at - ) - if db_room_time and db_room_time < datetime.now(UTC): + db_room_time = room.ends_at.replace(tzinfo=UTC) if room.ends_at else None + if db_room_time and db_room_time < datetime.now(UTC).replace(tzinfo=UTC): raise HTTPException(status_code=400, detail="Room has ended") item = ( await session.exec( diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index b7aa14c..135d2ac 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -858,7 +858,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): .where(col(Room.id) == room.room.room_id) .values( name=room.room.settings.name, - ended_at=datetime.now(UTC), + ends_at=datetime.now(UTC), type=room.room.settings.match_type, queue_mode=room.room.settings.queue_mode, auto_skip=room.room.settings.auto_skip, From d7002374b620a515c60d0c96da700c237fcd6406 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 04:53:20 +0000 Subject: [PATCH 52/65] fix(playlist): fix user attempts --- app/database/__init__.py | 7 +++++- app/database/playlist_attempts.py | 37 +++++++++++++++++++++++++++++ app/database/playlist_best_score.py | 11 +++++---- app/database/room.py | 15 +++++++++++- app/router/room.py | 6 ++++- 5 files changed, 68 insertions(+), 8 deletions(-) diff --git a/app/database/__init__.py b/app/database/__init__.py index 0ee253b..4501e21 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -16,7 +16,11 @@ from .lazer_user import ( UserResp, ) from .multiplayer_event import MultiplayerEvent, MultiplayerEventResp -from .playlist_attempts import ItemAttemptsCount, ItemAttemptsResp +from .playlist_attempts import ( + ItemAttemptsCount, + ItemAttemptsResp, + PlaylistAggregateScore, +) from .playlist_best_score import PlaylistBestScore from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore @@ -58,6 +62,7 @@ __all__ = [ "OAuthToken", "PPBestScore", "Playlist", + "PlaylistAggregateScore", "PlaylistBestScore", "PlaylistResp", "Relationship", diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py index cfd8147..5d580cf 100644 --- a/app/database/playlist_attempts.py +++ b/app/database/playlist_attempts.py @@ -1,6 +1,7 @@ from .lazer_user import User, UserResp from .playlist_best_score import PlaylistBestScore +from pydantic import BaseModel from sqlmodel import ( BigInteger, Column, @@ -112,3 +113,39 @@ class ItemAttemptsResp(ItemAttemptsCountBase): resp.position = await item_attempts.get_position(session) # resp.accuracy *= 100 return resp + + +class ItemAttemptsCountForItem(BaseModel): + id: int + attempts: int + passed: bool + + +class PlaylistAggregateScore(BaseModel): + playlist_item_attempts: list[ItemAttemptsCountForItem] = Field(default_factory=list) + + @classmethod + async def from_db( + cls, + room_id: int, + user_id: int, + session: AsyncSession, + ) -> "PlaylistAggregateScore": + playlist_scores = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.room_id == room_id, + PlaylistBestScore.user_id == user_id, + ) + ) + ).all() + playlist_item_attempts = [] + for score in playlist_scores: + playlist_item_attempts.append( + ItemAttemptsCountForItem( + id=score.playlist_id, + attempts=score.attempts, + passed=score.score.passed, + ) + ) + return cls(playlist_item_attempts=playlist_item_attempts) diff --git a/app/database/playlist_best_score.py b/app/database/playlist_best_score.py index 46bbfba..6ecb18a 100644 --- a/app/database/playlist_best_score.py +++ b/app/database/playlist_best_score.py @@ -62,20 +62,21 @@ async def process_playlist_best_score( ) ).first() if previous is None: - score = PlaylistBestScore( + previous = PlaylistBestScore( user_id=user_id, score_id=score_id, room_id=room_id, playlist_id=playlist_id, total_score=total_score, ) - session.add(score) - else: + session.add(previous) + elif not previous.score.passed or previous.total_score < total_score: previous.score_id = score_id previous.total_score = total_score - previous.attempts += 1 + previous.attempts += 1 await session.commit() - await redis.decr(f"multiplayer:{room_id}:gameplay:players") + if await redis.exists(f"multiplayer:{room_id}:gameplay:players"): + await redis.decr(f"multiplayer:{room_id}:gameplay:players") async def get_position( diff --git a/app/database/room.py b/app/database/room.py index a478f16..7fb18fa 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -1,5 +1,6 @@ from datetime import UTC, datetime +from app.database.playlist_attempts import PlaylistAggregateScore from app.models.model import UTCBaseModel from app.models.multiplayer_hub import ServerMultiplayerRoom from app.models.room import ( @@ -23,6 +24,7 @@ from sqlmodel import ( Relationship, SQLModel, ) +from sqlmodel.ext.asyncio.session import AsyncSession class RoomBase(SQLModel, UTCBaseModel): @@ -77,9 +79,16 @@ class RoomResp(RoomBase): playlist_item_stats: RoomPlaylistItemStats | None = None difficulty_range: RoomDifficultyRange | None = None current_playlist_item: PlaylistResp | None = None + current_user_score: PlaylistAggregateScore | None = None @classmethod - async def from_db(cls, room: Room) -> "RoomResp": + async def from_db( + cls, + room: Room, + include: list[str] = [], + session: AsyncSession | None = None, + user: User | None = None, + ) -> "RoomResp": resp = cls.model_validate(room.model_dump()) stats = RoomPlaylistItemStats(count_active=0, count_total=0) @@ -105,6 +114,10 @@ class RoomResp(RoomBase): resp.difficulty_range = difficulty_range resp.current_playlist_item = resp.playlist[-1] if resp.playlist else None + if "current_user_score" in include and user and session: + resp.current_user_score = await PlaylistAggregateScore.from_db( + room.id, user.id, session + ) return resp @classmethod diff --git a/app/router/room.py b/app/router/room.py index b4d77e2..8c35081 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -116,13 +116,17 @@ async def get_room( room: int, category: str = Query(default=""), db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), redis: Redis = Depends(get_redis), ): # 直接从db获取信息,毕竟都一样 db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is None: raise HTTPException(404, "Room not found") - return await RoomResp.from_db(db_room) + resp = await RoomResp.from_db( + db_room, include=["current_user_score"], session=db, user=current_user + ) + return resp @router.delete("/rooms/{room}", tags=["room"]) From 319599cacc16436ab550d71960955955b39b8688 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 05:49:01 +0000 Subject: [PATCH 53/65] feat(multiplayer,playlist): show host & renect participants --- app/database/__init__.py | 2 ++ app/database/room.py | 33 ++++++++++++++--- app/database/room_participated_user.py | 39 ++++++++++++++++++++ app/router/room.py | 50 ++++++++++++++++++++++---- app/signalr/hub/multiplayer.py | 45 +++++++++++++++++++++++ 5 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 app/database/room_participated_user.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 4501e21..f401e56 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -26,6 +26,7 @@ from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore from .relationship import Relationship, RelationshipResp, RelationshipType from .room import Room, RoomResp +from .room_participated_user import RoomParticipatedUser from .score import ( MultiplayerScores, Score, @@ -69,6 +70,7 @@ __all__ = [ "RelationshipResp", "RelationshipType", "Room", + "RoomParticipatedUser", "RoomResp", "Score", "ScoreAround", diff --git a/app/database/room.py b/app/database/room.py index 7fb18fa..fe63b9d 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -1,6 +1,7 @@ from datetime import UTC, datetime from app.database.playlist_attempts import PlaylistAggregateScore +from app.database.room_participated_user import RoomParticipatedUser from app.models.model import UTCBaseModel from app.models.multiplayer_hub import ServerMultiplayerRoom from app.models.room import ( @@ -15,6 +16,7 @@ from app.models.room import ( from .lazer_user import User, UserResp from .playlists import Playlist, PlaylistResp +from sqlalchemy.ext.asyncio import AsyncAttrs from sqlmodel import ( BigInteger, Column, @@ -23,6 +25,8 @@ from sqlmodel import ( ForeignKey, Relationship, SQLModel, + col, + select, ) from sqlmodel.ext.asyncio.session import AsyncSession @@ -51,10 +55,9 @@ class RoomBase(SQLModel, UTCBaseModel): auto_start_duration: int status: RoomStatus # TODO: channel_id - # recent_participants: list[User] -class Room(RoomBase, table=True): +class Room(AsyncAttrs, RoomBase, table=True): __tablename__ = "rooms" # pyright: ignore[reportAssignmentType] id: int = Field(default=None, primary_key=True, index=True) host_id: int = Field( @@ -80,13 +83,14 @@ class RoomResp(RoomBase): difficulty_range: RoomDifficultyRange | None = None current_playlist_item: PlaylistResp | None = None current_user_score: PlaylistAggregateScore | None = None + recent_participants: list[UserResp] = Field(default_factory=list) @classmethod async def from_db( cls, room: Room, + session: AsyncSession, include: list[str] = [], - session: AsyncSession | None = None, user: User | None = None, ) -> "RoomResp": resp = cls.model_validate(room.model_dump()) @@ -113,8 +117,27 @@ 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 - - if "current_user_score" in include and user and session: + resp.recent_participants = [] + for recent_participant in await session.exec( + select(RoomParticipatedUser) + .where( + RoomParticipatedUser.room_id == room.id, + col(RoomParticipatedUser.left_at).is_(None), + ) + .limit(8) + .order_by(col(RoomParticipatedUser.joined_at).desc()) + ): + resp.recent_participants.append( + await UserResp.from_db( + await recent_participant.awaitable_attrs.user, + session, + include=["statistics"], + ) + ) + resp.host = await UserResp.from_db( + await room.awaitable_attrs.host, session, include=["statistics"] + ) + if "current_user_score" in include and user: resp.current_user_score = await PlaylistAggregateScore.from_db( room.id, user.id, session ) diff --git a/app/database/room_participated_user.py b/app/database/room_participated_user.py new file mode 100644 index 0000000..18b0aeb --- /dev/null +++ b/app/database/room_participated_user.py @@ -0,0 +1,39 @@ +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlmodel import ( + BigInteger, + Column, + DateTime, + Field, + ForeignKey, + Relationship, + SQLModel, +) + +if TYPE_CHECKING: + from .lazer_user import User + from .room import Room + + +class RoomParticipatedUser(AsyncAttrs, SQLModel, table=True): + __tablename__ = "room_participated_users" # pyright: ignore[reportAssignmentType] + + id: int | None = Field( + default=None, sa_column=Column(BigInteger, primary_key=True, autoincrement=True) + ) + room_id: int = Field(sa_column=Column(ForeignKey("rooms.id"), nullable=False)) + user_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), nullable=False) + ) + joined_at: datetime = Field( + sa_column=Column(DateTime(timezone=True), nullable=False), + default=datetime.now(UTC), + ) + left_at: datetime | None = Field( + sa_column=Column(DateTime(timezone=True), nullable=True), default=None + ) + + room: "Room" = Relationship() + user: "User" = Relationship() diff --git a/app/router/room.py b/app/router/room.py index 8c35081..94d5711 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -10,6 +10,7 @@ from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventRes 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.database.room_participated_user import RoomParticipatedUser from app.database.score import Score from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher @@ -44,13 +45,13 @@ async def get_all_rooms( for room in db_rooms: if category == "realtime": if room.id in MultiplayerHubs.rooms: - resp_list.append(await RoomResp.from_db(room)) + resp_list.append(await RoomResp.from_db(room, db)) elif category is not None: if category == room.category: - resp_list.append(await RoomResp.from_db(room)) + resp_list.append(await RoomResp.from_db(room, db)) else: if room.id not in MultiplayerHubs.rooms: - resp_list.append(await RoomResp.from_db(room)) + resp_list.append(await RoomResp.from_db(room, db)) return resp_list @@ -73,6 +74,30 @@ class APIUploadedRoom(RoomBase): playlist: list[Playlist] +async def _participate_room( + room_id: int, user_id: int, db_room: Room, session: AsyncSession +): + participated_user = ( + await session.exec( + select(RoomParticipatedUser).where( + RoomParticipatedUser.room_id == room_id, + RoomParticipatedUser.user_id == user_id, + ) + ) + ).first() + if participated_user is None: + participated_user = RoomParticipatedUser( + room_id=room_id, + user_id=user_id, + joined_at=datetime.now(UTC), + ) + session.add(participated_user) + else: + participated_user.left_at = None + participated_user.joined_at = datetime.now(UTC) + db_room.participant_count += 1 + + @router.post("/rooms", tags=["room"], response_model=APICreatedRoom) async def create_room( room: APIUploadedRoom, @@ -91,6 +116,7 @@ async def create_room( db.add(db_room) await db.commit() await db.refresh(db_room) + await _participate_room(db_room.id, user_id, db_room, db) playlist: list[Playlist] = [] # 处理 APIUploadedRoom 里的 playlist 字段 @@ -106,7 +132,7 @@ async def create_room( await db.refresh(db_room) db_room.playlist = playlist await db.refresh(db_room) - created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room)) + created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db)) created_room.error = "" return created_room @@ -143,10 +169,10 @@ async def delete_room(room: int, db: AsyncSession = Depends(get_db)): async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_db)): db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is not None: - db_room.participant_count += 1 + await _participate_room(room, user, db_room, db) await db.commit() await db.refresh(db_room) - resp = await RoomResp.from_db(db_room) + resp = await RoomResp.from_db(db_room, db) await db.refresh(db_room) for item in db_room.playlist: resp.playlist.append(await PlaylistResp.from_db(item, ["beatmap"])) @@ -161,6 +187,16 @@ async def remove_user_from_room( ): db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is not None: + participated_user = ( + await db.exec( + select(RoomParticipatedUser).where( + RoomParticipatedUser.room_id == room, + RoomParticipatedUser.user_id == user, + ) + ) + ).first() + if participated_user is not None: + participated_user.left_at = datetime.now(UTC) db_room.participant_count -= 1 await db.commit() return None @@ -286,7 +322,7 @@ async def get_room_events( room = (await db.exec(select(Room).where(Room.id == room_id))).first() if room is None: raise HTTPException(404, "Room not found") - room_resp = await RoomResp.from_db(room) + room_resp = await RoomResp.from_db(room, db) users = await db.exec(select(User).where(col(User.id).in_(user_ids))) user_resps = [await UserResp.from_db(user, db) for user in users] diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 135d2ac..f7763df 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -10,6 +10,7 @@ from app.database.lazer_user import User from app.database.multiplayer_event import MultiplayerEvent from app.database.playlists import Playlist from app.database.relationship import Relationship, RelationshipType +from app.database.room_participated_user import RoomParticipatedUser from app.dependencies.database import engine, get_redis from app.dependencies.fetcher import get_fetcher from app.exception import InvokeException @@ -253,6 +254,32 @@ class MultiplayerHub(Hub[MultiplayerClientState]): self.add_to_group(client, self.group_id(room_id)) await server_room.match_type_handler.handle_join(user) await self.event_logger.player_joined(room_id, user.user_id) + + async with AsyncSession(engine) as session: + async with session.begin(): + if ( + participated_user := ( + await session.exec( + select(RoomParticipatedUser).where( + RoomParticipatedUser.room_id == room_id, + RoomParticipatedUser.user_id == client.user_id, + ) + ) + ).first() + ) is None: + participated_user = RoomParticipatedUser( + room_id=room_id, + user_id=client.user_id, + ) + session.add(participated_user) + else: + participated_user.left_at = None + participated_user.joined_at = datetime.now(UTC) + + room = await session.get(Room, room_id) + if room is None: + raise InvokeException("Room does not exist in database") + room.participant_count += 1 return room async def ChangeBeatmapAvailability( @@ -846,6 +873,24 @@ class MultiplayerHub(Hub[MultiplayerClientState]): self.group_id(room.room.room_id), "UserLeft", user ) + async with AsyncSession(engine) as session: + async with session.begin(): + participated_user = ( + await session.exec( + select(RoomParticipatedUser).where( + RoomParticipatedUser.room_id == room.room.room_id, + RoomParticipatedUser.user_id == user.user_id, + ) + ) + ).first() + if participated_user is not None: + participated_user.left_at = datetime.now(UTC) + + db_room = await session.get(Room, room.room.room_id) + if db_room is None: + raise InvokeException("Room does not exist in database") + db_room.participant_count -= 1 + target_store = self.state.get(user.user_id) if target_store: target_store.room_id = 0 From e4d883bf8105a28ab694f74606fdc73552fe474a Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 05:52:38 +0000 Subject: [PATCH 54/65] fix(playlist): cannot close playlist manually --- app/router/room.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/router/room.py b/app/router/room.py index 94d5711..05437b2 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -161,7 +161,8 @@ async def delete_room(room: int, db: AsyncSession = Depends(get_db)): if db_room is None: raise HTTPException(404, "Room not found") else: - await db.delete(db_room) + db_room.ends_at = datetime.now(UTC) + await db.commit() return None From 99f2b3db2ac8d8a43ae03431f63a160ecdc225c9 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 05:59:26 +0000 Subject: [PATCH 55/65] fix(playlist): duplicated item in list --- app/database/room.py | 2 +- app/router/room.py | 16 +++------------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/app/database/room.py b/app/database/room.py index fe63b9d..f0234e8 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -67,7 +67,7 @@ class Room(AsyncAttrs, RoomBase, table=True): host: User = Relationship() playlist: list[Playlist] = Relationship( sa_relationship_kwargs={ - "lazy": "joined", + "lazy": "selectin", "cascade": "all, delete-orphan", "overlaps": "room", } diff --git a/app/router/room.py b/app/router/room.py index 05437b2..c3d4aee 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -8,7 +8,7 @@ from app.database.beatmapset import BeatmapsetResp from app.database.lazer_user import User, UserResp from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp -from app.database.playlists import Playlist, PlaylistResp +from app.database.playlists import Playlist, PlaylistBase, PlaylistResp from app.database.room import Room, RoomBase, RoomResp from app.database.room_participated_user import RoomParticipatedUser from app.database.score import Score @@ -71,7 +71,7 @@ class APIUploadedRoom(RoomBase): id: int | None host_id: int | None = None - playlist: list[Playlist] + playlist: list[PlaylistBase] = Field(default_factory=list) async def _participate_room( @@ -118,19 +118,12 @@ async def create_room( await db.refresh(db_room) await _participate_room(db_room.id, user_id, db_room, db) - playlist: list[Playlist] = [] - # 处理 APIUploadedRoom 里的 playlist 字段 for item in room.playlist: - # 确保 room_id 正确赋值 item.id = await Playlist.get_next_id_for_room(db_room.id, db) item.room_id = db_room.id item.owner_id = user_id if user_id else 1 db.add(item) - await db.commit() - await db.refresh(item) - playlist.append(item) - await db.refresh(db_room) - db_room.playlist = playlist + await db.commit() await db.refresh(db_room) created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db)) created_room.error = "" @@ -174,9 +167,6 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ await db.commit() await db.refresh(db_room) resp = await RoomResp.from_db(db_room, db) - await db.refresh(db_room) - for item in db_room.playlist: - resp.playlist.append(await PlaylistResp.from_db(item, ["beatmap"])) return resp else: raise HTTPException(404, "room not found0") From e236c06f0f6c9bd0d2cf03d35e639555949f2393 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 06:28:37 +0000 Subject: [PATCH 56/65] feat(multiplayer,playlist): complete the filter for `/rooms` --- app/database/room.py | 2 +- app/models/room.py | 1 + app/router/room.py | 64 +++++++++++++++++++++++++--------- app/signalr/hub/multiplayer.py | 2 +- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/app/database/room.py b/app/database/room.py index f0234e8..3b59ab0 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -76,7 +76,7 @@ class Room(AsyncAttrs, RoomBase, table=True): class RoomResp(RoomBase): id: int - password: str | None = None + has_password: bool = False host: UserResp | None = None playlist: list[PlaylistResp] = [] playlist_item_stats: RoomPlaylistItemStats | None = None diff --git a/app/models/room.py b/app/models/room.py index 392562a..cc257c2 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -10,6 +10,7 @@ class RoomCategory(str, Enum): SPOTLIGHT = "spotlight" FEATURED_ARTIST = "featured_artist" DAILY_CHALLENGE = "daily_challenge" + REALTIME = "realtime" class MatchType(str, Enum): diff --git a/app/router/room.py b/app/router/room.py index c3d4aee..c13574b 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -13,10 +13,8 @@ from app.database.room import Room, RoomBase, RoomResp from app.database.room_participated_user import RoomParticipatedUser from app.database.score import Score from app.dependencies.database import get_db, get_redis -from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user -from app.fetcher import Fetcher -from app.models.room import RoomStatus +from app.models.room import RoomCategory, RoomStatus from app.signalr.hub import MultiplayerHubs from .api_router import router @@ -24,7 +22,7 @@ from .api_router import router from fastapi import Depends, HTTPException, Query from pydantic import BaseModel, Field from redis.asyncio import Redis -from sqlmodel import col, select +from sqlmodel import col, exists, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -32,26 +30,57 @@ from sqlmodel.ext.asyncio.session import AsyncSession async def get_all_rooms( mode: Literal["open", "ended", "participated", "owned", None] = Query( default="open" - ), # TODO: 对房间根据状态进行筛选 - category: str | None = Query(None), # TODO + ), + category: RoomCategory = Query(RoomCategory.NORMAL), status: RoomStatus | None = Query(None), db: AsyncSession = Depends(get_db), - fetcher: Fetcher = Depends(get_fetcher), - redis: Redis = Depends(get_redis), current_user: User = Depends(get_current_user), ): resp_list: list[RoomResp] = [] db_rooms = (await db.exec(select(Room).where(True))).unique().all() + now = datetime.now(UTC) for room in db_rooms: - if category == "realtime": - if room.id in MultiplayerHubs.rooms: - resp_list.append(await RoomResp.from_db(room, db)) - elif category is not None: - if category == room.category: - resp_list.append(await RoomResp.from_db(room, db)) - else: - if room.id not in MultiplayerHubs.rooms: - resp_list.append(await RoomResp.from_db(room, db)) + if category == RoomCategory.REALTIME and room.id not in MultiplayerHubs.rooms: + continue + elif category != RoomCategory.REALTIME and category != room.category: + continue + + if status is not None and room.status != status: + continue + + if ( + mode == "open" + and room.ends_at is not None + and room.ends_at.replace(tzinfo=UTC) < now + ): + continue + if ( + mode == "participated" + and not ( + await db.exec( + select(exists()).where( + RoomParticipatedUser.room_id == room.id, + RoomParticipatedUser.user_id == current_user.id, + ) + ) + ).first() + ): + continue + if mode == "owned" and room.host_id != current_user.id: + continue + if mode == "ended" and ( + room.ends_at is None + or room.ends_at.replace(tzinfo=UTC) < (now - timedelta(days=30)) + ): + continue + + resp = await RoomResp.from_db(room, db) + if category == RoomCategory.REALTIME: + resp.has_password = bool( + MultiplayerHubs.rooms[room.id].room.settings.password.strip() + ) + resp_list.append(resp) + return resp_list @@ -167,6 +196,7 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ await db.commit() await db.refresh(db_room) resp = await RoomResp.from_db(db_room, db) + return resp else: raise HTTPException(404, "room not found0") diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index f7763df..46f24d9 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -181,7 +181,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): async with session: db_room = Room( name=room.settings.name, - category=RoomCategory.NORMAL, + category=RoomCategory.REALTIME, type=room.settings.match_type, queue_mode=room.settings.queue_mode, auto_skip=room.settings.auto_skip, From e22c49d5db12d631c6e706dbea6a955819d5a1d4 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 08:35:31 +0000 Subject: [PATCH 57/65] chore(multiplayer): unready all users when settings were changed --- app/models/multiplayer_hub.py | 10 ++--- app/signalr/hub/multiplayer.py | 82 ++++++++++++++++++++++++++-------- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 13c7119..b0b4187 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -875,7 +875,7 @@ class ServerMultiplayerRoom: await self.stop_countdown(countdown) if countdown.is_exclusive: - await self.stop_all_countdowns() + await self.stop_all_countdowns(countdown.__class__) countdown.id = await self.get_next_countdown_id() info = CountdownInfo(countdown) self.room.active_countdowns.append(info.countdown) @@ -895,12 +895,10 @@ class ServerMultiplayerRoom: if info.task is not None and not info.task.done(): info.task.cancel() - async def stop_all_countdowns(self): + async def stop_all_countdowns(self, typ: type[MultiplayerCountdown]): for countdown in list(self._tracked_countdown.values()): - await self.stop_countdown(countdown.countdown) - - self._tracked_countdown.clear() - self.room.active_countdowns.clear() + if isinstance(countdown.countdown, typ): + await self.stop_countdown(countdown.countdown) class _MatchServerEvent(SignalRUnionMessage): ... diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 46f24d9..9d6ad8e 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -276,12 +276,32 @@ class MultiplayerHub(Hub[MultiplayerClientState]): participated_user.left_at = None participated_user.joined_at = datetime.now(UTC) - room = await session.get(Room, room_id) - if room is None: + db_room = await session.get(Room, room_id) + if db_room is None: raise InvokeException("Room does not exist in database") - room.participant_count += 1 + db_room.participant_count += 1 return room + async def change_beatmap_availability( + self, + room_id: int, + user: MultiplayerRoomUser, + beatmap_availability: BeatmapAvailability, + ): + availability = user.availability + if ( + availability.state == beatmap_availability.state + and availability.download_progress == beatmap_availability.download_progress + ): + return + user.availability = beatmap_availability + await self.broadcast_group_call( + self.group_id(room_id), + "UserBeatmapAvailabilityChanged", + user.user_id, + beatmap_availability, + ) + async def ChangeBeatmapAvailability( self, client: Client, beatmap_availability: BeatmapAvailability ): @@ -295,19 +315,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): user = next((u for u in room.users if u.user_id == client.user_id), None) if user is None: raise InvokeException("You are not in this room") - - availability = user.availability - if ( - availability.state == beatmap_availability.state - and availability.download_progress == beatmap_availability.download_progress - ): - return - user.availability = beatmap_availability - await self.broadcast_group_call( - self.group_id(store.room_id), - "UserBeatmapAvailabilityChanged", - user.user_id, - (beatmap_availability), + await self.change_beatmap_availability( + room.room_id, + user, + beatmap_availability, ) async def AddPlaylistItem(self, client: Client, item: PlaylistItem): @@ -365,17 +376,19 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def setting_changed(self, room: ServerMultiplayerRoom, beatmap_changed: bool): + await self.validate_styles(room) + await self.unready_all_users(room, beatmap_changed) await self.broadcast_group_call( self.group_id(room.room.room_id), "SettingsChanged", - (room.room.settings), + room.room.settings, ) async def playlist_added(self, room: ServerMultiplayerRoom, item: PlaylistItem): await self.broadcast_group_call( self.group_id(room.room.room_id), "PlaylistItemAdded", - (item), + item, ) async def playlist_removed(self, room: ServerMultiplayerRoom, item_id: int): @@ -388,10 +401,13 @@ class MultiplayerHub(Hub[MultiplayerClientState]): async def playlist_changed( self, room: ServerMultiplayerRoom, item: PlaylistItem, beatmap_changed: bool ): + if item.id == room.room.settings.playlist_item_id: + await self.validate_styles(room) + await self.unready_all_users(room, beatmap_changed) await self.broadcast_group_call( self.group_id(room.room.room_id), "PlaylistItemChanged", - (item), + item, ) async def ChangeUserStyle( @@ -1066,6 +1082,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def ChangeSettings(self, client: Client, settings: MultiplayerRoomSettings): + print(settings) store = self.get_or_create_state(client) if store.room_id == 0: raise InvokeException("You are not in a room") @@ -1202,3 +1219,30 @@ class MultiplayerHub(Hub[MultiplayerClientState]): room.room_id, room.settings.password, ) + + async def unready_all_users( + self, room: ServerMultiplayerRoom, reset_beatmap_availability: bool + ): + await asyncio.gather( + *[ + self.change_user_state( + room, + user, + MultiplayerUserState.IDLE, + ) + for user in room.room.users + if user.state == MultiplayerUserState.READY + ] + ) + if reset_beatmap_availability: + await asyncio.gather( + *[ + self.change_beatmap_availability( + room.room.room_id, + user, + BeatmapAvailability(state=DownloadState.UNKNOWN), + ) + for user in room.room.users + ] + ) + await room.stop_all_countdowns(MatchStartCountdown) From 076b9d901b72f22a349c5b39046bb74f271fbb7c Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 08:36:24 +0000 Subject: [PATCH 58/65] refactor(lounge): improve performance for list rooms --- app/models/room.py | 2 +- app/router/room.py | 70 ++++++++++++++++++++++++---------------------- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/app/models/room.py b/app/models/room.py index cc257c2..3cba32f 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -10,7 +10,7 @@ class RoomCategory(str, Enum): SPOTLIGHT = "spotlight" FEATURED_ARTIST = "featured_artist" DAILY_CHALLENGE = "daily_challenge" - REALTIME = "realtime" + REALTIME = "realtime" # INTERNAL USE ONLY, DO NOT USE IN API class MatchType(str, Enum): diff --git a/app/router/room.py b/app/router/room.py index c13574b..78e08dd 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -22,6 +22,7 @@ from .api_router import router from fastapi import Depends, HTTPException, Query from pydantic import BaseModel, Field from redis.asyncio import Redis +from sqlalchemy.sql.elements import ColumnElement from sqlmodel import col, exists, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -37,48 +38,51 @@ async def get_all_rooms( current_user: User = Depends(get_current_user), ): resp_list: list[RoomResp] = [] - db_rooms = (await db.exec(select(Room).where(True))).unique().all() + where_clauses: list[ColumnElement[bool]] = [col(Room.category) == category] now = datetime.now(UTC) - for room in db_rooms: - if category == RoomCategory.REALTIME and room.id not in MultiplayerHubs.rooms: - continue - elif category != RoomCategory.REALTIME and category != room.category: - continue + if status is not None: + where_clauses.append(col(Room.status) == status) + if mode == "open": + where_clauses.append( + (col(Room.ends_at).is_(None)) + | (col(Room.ends_at) > now.replace(tzinfo=UTC)) + ) + if category == RoomCategory.REALTIME: + where_clauses.append(col(Room.id).in_(MultiplayerHubs.rooms.keys())) + if mode == "participated": + where_clauses.append( + exists().where( + col(RoomParticipatedUser.room_id) == Room.id, + col(RoomParticipatedUser.user_id) == current_user.id, + ) + ) + if mode == "owned": + where_clauses.append(col(Room.host_id) == current_user.id) + if mode == "ended": + where_clauses.append( + (col(Room.ends_at).is_not(None)) + & (col(Room.ends_at) < now.replace(tzinfo=UTC)) + ) - if status is not None and room.status != status: - continue - - if ( - mode == "open" - and room.ends_at is not None - and room.ends_at.replace(tzinfo=UTC) < now - ): - continue - if ( - mode == "participated" - and not ( - await db.exec( - select(exists()).where( - RoomParticipatedUser.room_id == room.id, - RoomParticipatedUser.user_id == current_user.id, - ) + db_rooms = ( + ( + await db.exec( + select(Room).where( + *where_clauses, ) - ).first() - ): - continue - if mode == "owned" and room.host_id != current_user.id: - continue - if mode == "ended" and ( - room.ends_at is None - or room.ends_at.replace(tzinfo=UTC) < (now - timedelta(days=30)) - ): - continue + ) + ) + .unique() + .all() + ) + for room in db_rooms: resp = await RoomResp.from_db(room, db) if category == RoomCategory.REALTIME: resp.has_password = bool( MultiplayerHubs.rooms[room.id].room.settings.password.strip() ) + resp.category = RoomCategory.NORMAL resp_list.append(resp) return resp_list From 832a6fc95dcd5a2d21dd48b51e18feb7b3540d4a Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 11:18:29 +0000 Subject: [PATCH 59/65] feat(daily-challenge): simple implement --- app/database/__init__.py | 3 +- app/database/playlists.py | 2 +- app/database/room.py | 15 +++++ app/dependencies/scheduler.py | 26 +++++++++ app/models/metadata_hub.py | 4 ++ app/router/room.py | 44 +++----------- app/router/score.py | 11 ++++ app/service/__init__.py | 10 ++++ app/service/daily_challenge.py | 103 +++++++++++++++++++++++++++++++++ app/service/room.py | 78 +++++++++++++++++++++++++ app/signalr/hub/metadata.py | 28 ++++++++- main.py | 3 + pyproject.toml | 1 + uv.lock | 37 +++++++++++- 14 files changed, 323 insertions(+), 42 deletions(-) create mode 100644 app/dependencies/scheduler.py create mode 100644 app/service/__init__.py create mode 100644 app/service/daily_challenge.py create mode 100644 app/service/room.py diff --git a/app/database/__init__.py b/app/database/__init__.py index f401e56..7030ae7 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -25,7 +25,7 @@ from .playlist_best_score import PlaylistBestScore from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore from .relationship import Relationship, RelationshipResp, RelationshipType -from .room import Room, RoomResp +from .room import APIUploadedRoom, Room, RoomResp from .room_participated_user import RoomParticipatedUser from .score import ( MultiplayerScores, @@ -48,6 +48,7 @@ from .user_account_history import ( ) __all__ = [ + "APIUploadedRoom", "Beatmap", "Beatmapset", "BeatmapsetResp", diff --git a/app/database/playlists.py b/app/database/playlists.py index 3f7ae40..c177432 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -138,6 +138,6 @@ class PlaylistResp(PlaylistBase): ) -> "PlaylistResp": data = playlist.model_dump() if "beatmap" in include: - data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True) + data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap) resp = cls.model_validate(data) return resp diff --git a/app/database/room.py b/app/database/room.py index 3b59ab0..368a04a 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -160,3 +160,18 @@ class RoomResp(RoomBase): participant_count=len(room.users), ) return resp + + +class APIUploadedRoom(RoomBase): + def to_room(self) -> Room: + """ + 将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。 + """ + room_dict = self.model_dump() + room_dict.pop("playlist", None) + # host_id 已在字段中 + return Room(**room_dict) + + id: int | None + host_id: int | None = None + playlist: list[Playlist] = Field(default_factory=list) diff --git a/app/dependencies/scheduler.py b/app/dependencies/scheduler.py new file mode 100644 index 0000000..fa20396 --- /dev/null +++ b/app/dependencies/scheduler.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import UTC + +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +scheduler: AsyncIOScheduler | None = None + + +def init_scheduler(): + global scheduler + scheduler = AsyncIOScheduler(timezone=UTC) + scheduler.start() + + +def get_scheduler() -> AsyncIOScheduler: + global scheduler + if scheduler is None: + init_scheduler() + return scheduler # pyright: ignore[reportReturnType] + + +def stop_scheduler(): + global scheduler + if scheduler: + scheduler.shutdown() diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index 684ab54..7ef2b7a 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -123,3 +123,7 @@ class OnlineStatus(IntEnum): OFFLINE = 0 # 隐身 DO_NOT_DISTURB = 1 ONLINE = 2 + + +class DailyChallengeInfo(BaseModel): + room_id: int diff --git a/app/router/room.py b/app/router/room.py index 78e08dd..6918364 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from typing import Literal from app.database.beatmap import Beatmap, BeatmapResp @@ -8,13 +8,14 @@ from app.database.beatmapset import BeatmapsetResp from app.database.lazer_user import User, UserResp from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp -from app.database.playlists import Playlist, PlaylistBase, PlaylistResp -from app.database.room import Room, RoomBase, RoomResp +from app.database.playlists import Playlist, PlaylistResp +from app.database.room import APIUploadedRoom, Room, RoomResp from app.database.room_participated_user import RoomParticipatedUser from app.database.score import Score from app.dependencies.database import get_db, get_redis from app.dependencies.user import get_current_user from app.models.room import RoomCategory, RoomStatus +from app.service.room import create_playlist_room_from_api from app.signalr.hub import MultiplayerHubs from .api_router import router @@ -92,21 +93,6 @@ class APICreatedRoom(RoomResp): error: str = "" -class APIUploadedRoom(RoomBase): - def to_room(self) -> Room: - """ - 将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。 - """ - room_dict = self.model_dump() - room_dict.pop("playlist", None) - # host_id 已在字段中 - return Room(**room_dict) - - id: int | None - host_id: int | None = None - playlist: list[PlaylistBase] = Field(default_factory=list) - - async def _participate_room( room_id: int, user_id: int, db_room: Room, session: AsyncSession ): @@ -137,27 +123,11 @@ async def create_room( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - # db_room = Room.from_resp(room) - await db.refresh(current_user) user_id = current_user.id - db_room = room.to_room() - db_room.host_id = current_user.id if current_user.id else 1 - db_room.starts_at = datetime.now(UTC) - db_room.ends_at = db_room.starts_at + timedelta( - minutes=db_room.duration if db_room.duration is not None else 0 - ) - db.add(db_room) - await db.commit() - await db.refresh(db_room) + db_room = await create_playlist_room_from_api(db, room, user_id) await _participate_room(db_room.id, user_id, db_room, db) - - for item in room.playlist: - item.id = await Playlist.get_next_id_for_room(db_room.id, db) - item.room_id = db_room.id - item.owner_id = user_id if user_id else 1 - db.add(item) - await db.commit() - await db.refresh(db_room) + # await db.commit() + # await db.refresh(db_room) created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db)) created_room.error = "" return created_room diff --git a/app/router/score.py b/app/router/score.py index 70664a5..506ebac 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -32,6 +32,7 @@ from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher from app.models.beatmap import BeatmapRankStatus +from app.models.room import RoomCategory from app.models.score import ( INT_TO_MODE, GameMode, @@ -402,6 +403,10 @@ async def index_playlist_scores( current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db), ): + room = await session.get(Room, room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + limit = clamp(limit, 1, 50) scores = ( @@ -426,6 +431,12 @@ async def index_playlist_scores( score.position = await get_position(room_id, playlist_id, score.id, session) if score.user_id == current_user.id: user_score = score + + if room.category == RoomCategory.DAILY_CHALLENGE: + score_resp = [s for s in score_resp if s.passed] + if user_score and not user_score.passed: + user_score = None + resp = IndexedScoreResp( scores=score_resp, user_score=user_score, diff --git a/app/service/__init__.py b/app/service/__init__.py new file mode 100644 index 0000000..cbb83a2 --- /dev/null +++ b/app/service/__init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from .daily_challenge import create_daily_challenge_room +from .room import create_playlist_room, create_playlist_room_from_api + +__all__ = [ + "create_daily_challenge_room", + "create_playlist_room", + "create_playlist_room_from_api", +] diff --git a/app/service/daily_challenge.py b/app/service/daily_challenge.py new file mode 100644 index 0000000..1f92034 --- /dev/null +++ b/app/service/daily_challenge.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import json + +from app.database.playlists import Playlist +from app.database.room import Room +from app.dependencies.database import engine, get_redis +from app.dependencies.scheduler import get_scheduler +from app.log import logger +from app.models.metadata_hub import DailyChallengeInfo +from app.models.mods import APIMod +from app.models.room import RoomCategory +from app.signalr.hub import MetadataHubs + +from .room import create_playlist_room + +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def create_daily_challenge_room( + beatmap: int, ruleset_id: int, required_mods: list[APIMod] = [] +) -> Room: + async with AsyncSession(engine) as session: + today = datetime.now(UTC).date() + return await create_playlist_room( + session=session, + name=str(today), + host_id=3, + playlist=[ + Playlist( + id=0, + room_id=0, + owner_id=3, + ruleset_id=ruleset_id, + beatmap_id=beatmap, + required_mods=required_mods, + ) + ], + category=RoomCategory.DAILY_CHALLENGE, + duration=24 * 60 - 2, # remain 2 minute to apply new daily challenge + ) + + +@get_scheduler().scheduled_job("cron", hour=0, minute=0, second=0, id="daily_challenge") +async def daily_challenge_job(): + today = datetime.now(UTC).date() + redis = get_redis() + key = f"daily_challenge:{today.year}-{today.month}-{today.day}" + if not await redis.exists(key): + return + try: + beatmap = await redis.hget(key, "beatmap") # pyright: ignore[reportGeneralTypeIssues] + ruleset_id = await redis.hget(key, "ruleset_id") # pyright: ignore[reportGeneralTypeIssues] + required_mods = await redis.hget(key, "required_mods") # pyright: ignore[reportGeneralTypeIssues] + + if beatmap is None or ruleset_id is None: + logger.warning( + f"[DailyChallenge] Missing required data for daily challenge {today}." + " Will try again in 5 minutes." + ) + get_scheduler().add_job( + daily_challenge_job, + "date", + run_date=datetime.now(UTC) + timedelta(minutes=5), + ) + return + + beatmap_int = int(beatmap) + ruleset_id_int = int(ruleset_id) + + mods_list = [] + if required_mods: + mods_list = json.loads(required_mods) + + room = await create_daily_challenge_room( + beatmap=beatmap_int, + ruleset_id=ruleset_id_int, + required_mods=mods_list, + ) + await MetadataHubs.broadcast_call( + "DailyChallengeUpdated", DailyChallengeInfo(room_id=room.id) + ) + logger.success( + "[DailyChallenge] Added today's daily challenge: " + f"{beatmap=}, {ruleset_id=}, {required_mods=}" + ) + return + except (ValueError, json.JSONDecodeError) as e: + logger.warning( + f"[DailyChallenge] Error processing daily challenge data: {e}" + " Will try again in 5 minutes." + ) + except Exception as e: + logger.exception( + f"[DailyChallenge] Unexpected error in daily challenge job: {e}" + " Will try again in 5 minutes." + ) + get_scheduler().add_job( + daily_challenge_job, + "date", + run_date=datetime.now(UTC) + timedelta(minutes=5), + ) diff --git a/app/service/room.py b/app/service/room.py new file mode 100644 index 0000000..d11dced --- /dev/null +++ b/app/service/room.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from app.database.beatmap import Beatmap +from app.database.playlists import Playlist +from app.database.room import APIUploadedRoom, Room +from app.dependencies.fetcher import get_fetcher +from app.models.room import MatchType, QueueMode, RoomCategory, RoomStatus + +from sqlalchemy import exists +from sqlmodel import col, select +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def create_playlist_room_from_api( + session: AsyncSession, room: APIUploadedRoom, host_id: int +) -> Room: + db_room = room.to_room() + db_room.host_id = host_id + db_room.starts_at = datetime.now(UTC) + db_room.ends_at = db_room.starts_at + timedelta( + minutes=db_room.duration if db_room.duration is not None else 0 + ) + session.add(db_room) + await session.commit() + await session.refresh(db_room) + await add_playlists_to_room(session, db_room.id, room.playlist, host_id) + await session.refresh(db_room) + return db_room + + +async def create_playlist_room( + session: AsyncSession, + name: str, + host_id: int, + category: RoomCategory = RoomCategory.NORMAL, + duration: int = 30, + max_attempts: int | None = None, + playlist: list[Playlist] = [], +) -> Room: + db_room = Room( + name=name, + category=category, + duration=duration, + starts_at=datetime.now(UTC), + ends_at=datetime.now(UTC) + timedelta(minutes=duration), + participant_count=0, + max_attempts=max_attempts, + type=MatchType.PLAYLISTS, + queue_mode=QueueMode.HOST_ONLY, + auto_skip=False, + auto_start_duration=0, + status=RoomStatus.IDLE, + host_id=host_id, + ) + session.add(db_room) + await session.commit() + await session.refresh(db_room) + await add_playlists_to_room(session, db_room.id, playlist, host_id) + await session.refresh(db_room) + return db_room + + +async def add_playlists_to_room( + session: AsyncSession, room_id: int, playlist: list[Playlist], owner_id: int +): + for item in playlist: + if not ( + await session.exec(select(exists().where(col(Beatmap.id) == item.beatmap))) + ).first(): + fetcher = await get_fetcher() + await Beatmap.get_or_fetch(session, fetcher, item.beatmap_id) + item.id = await Playlist.get_next_id_for_room(room_id, session) + item.room_id = room_id + item.owner_id = owner_id + session.add(item) + await session.commit() diff --git a/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py index 08ee035..ef21c93 100644 --- a/app/signalr/hub/metadata.py +++ b/app/signalr/hub/metadata.py @@ -6,12 +6,19 @@ from datetime import UTC, datetime from typing import override from app.database import Relationship, RelationshipType, User +from app.database.room import Room from app.dependencies.database import engine, get_redis -from app.models.metadata_hub import MetadataClientState, OnlineStatus, UserActivity +from app.models.metadata_hub import ( + DailyChallengeInfo, + MetadataClientState, + OnlineStatus, + UserActivity, +) +from app.models.room import RoomCategory from .hub import Client, Hub -from sqlmodel import select +from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers" @@ -107,6 +114,23 @@ class MetadataHub(Hub[MetadataClientState]): ) ) await asyncio.gather(*tasks) + + daily_challenge_room = ( + await session.exec( + select(Room).where( + col(Room.ends_at) > datetime.now(UTC), + Room.category == RoomCategory.DAILY_CHALLENGE, + ) + ) + ).first() + if daily_challenge_room: + await self.call_noblock( + client, + "DailyChallengeUpdated", + DailyChallengeInfo( + room_id=daily_challenge_room.id, + ), + ) redis = get_redis() await redis.set(f"metadata:online:{user_id}", "") diff --git a/main.py b/main.py index b12f543..31cdd44 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ from datetime import datetime from app.config import settings from app.dependencies.database import create_tables, engine, redis_client from app.dependencies.fetcher import get_fetcher +from app.dependencies.scheduler import init_scheduler, stop_scheduler from app.router import ( api_router, auth_router, @@ -21,8 +22,10 @@ async def lifespan(app: FastAPI): # on startup await create_tables() await get_fetcher() # 初始化 fetcher + init_scheduler() # on shutdown yield + stop_scheduler() await engine.dispose() await redis_client.aclose() diff --git a/pyproject.toml b/pyproject.toml index cd90947..3ab61c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12" dependencies = [ "aiomysql>=0.2.0", "alembic>=1.12.1", + "apscheduler>=3.11.0", "bcrypt>=4.1.2", "cryptography>=41.0.7", "fastapi>=0.104.1", diff --git a/uv.lock b/uv.lock index 3fc7d3c..22a4f1d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [manifest] @@ -57,6 +57,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[package]] +name = "apscheduler" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -493,6 +505,7 @@ source = { virtual = "." } dependencies = [ { name = "aiomysql" }, { name = "alembic" }, + { name = "apscheduler" }, { name = "bcrypt" }, { name = "cryptography" }, { name = "fastapi" }, @@ -522,6 +535,7 @@ dev = [ requires-dist = [ { name = "aiomysql", specifier = ">=0.2.0" }, { name = "alembic", specifier = ">=1.12.1" }, + { name = "apscheduler", specifier = ">=3.11.0" }, { name = "bcrypt", specifier = ">=4.1.2" }, { name = "cryptography", specifier = ">=41.0.7" }, { name = "fastapi", specifier = ">=0.104.1" }, @@ -904,6 +918,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "uvicorn" version = "0.35.0" From 8531e6742363c904a3a187779cf6b6e1f7c75df3 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 12:09:23 +0000 Subject: [PATCH 60/65] feat(redis): add subscriber for pub/sub mode --- app/dependencies/database.py | 4 ++++ app/service/daily_challenge.py | 3 ++- app/service/subscriber.py | 20 ++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 app/service/subscriber.py diff --git a/app/dependencies/database.py b/app/dependencies/database.py index 77b15c3..a6b6e5c 100644 --- a/app/dependencies/database.py +++ b/app/dependencies/database.py @@ -38,3 +38,7 @@ async def create_tables(): # Redis 依赖 def get_redis(): return redis_client + + +def get_redis_pubsub(channel: str | None = None): + return redis_client.pubsub(ignore_subscribe_messages=True, channel=channel) diff --git a/app/service/daily_challenge.py b/app/service/daily_challenge.py index 1f92034..b2a6eb5 100644 --- a/app/service/daily_challenge.py +++ b/app/service/daily_challenge.py @@ -11,7 +11,6 @@ from app.log import logger from app.models.metadata_hub import DailyChallengeInfo from app.models.mods import APIMod from app.models.room import RoomCategory -from app.signalr.hub import MetadataHubs from .room import create_playlist_room @@ -44,6 +43,8 @@ async def create_daily_challenge_room( @get_scheduler().scheduled_job("cron", hour=0, minute=0, second=0, id="daily_challenge") async def daily_challenge_job(): + from app.signalr.hub import MetadataHubs + today = datetime.now(UTC).date() redis = get_redis() key = f"daily_challenge:{today.year}-{today.month}-{today.day}" diff --git a/app/service/subscriber.py b/app/service/subscriber.py new file mode 100644 index 0000000..39478ab --- /dev/null +++ b/app/service/subscriber.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from app.dependencies.database import get_redis_pubsub + + +class RedisSubscriber: + def __init__(self, channel: str): + self.pubsub = get_redis_pubsub(channel) + self.handlers: dict[str, list[Callable[[str, str], Awaitable[Any]]]] = {} + + async def listen(self): + async for message in self.pubsub.listen(): + if message is not None and message["type"] == "message": + method = self.handlers.get(message["channel"]) + if method: + for handler in method: + await handler(message["channel"], message["data"]) From 5fe3f36055fdadb03a4e924e52fa48b152c050cf Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 14:34:46 +0000 Subject: [PATCH 61/65] feat(daily-challenge): complete daily-challenge --- app/database/score.py | 1 + app/dependencies/database.py | 4 +- app/models/metadata_hub.py | 32 ++++++- app/router/score.py | 6 +- app/service/subscriber.py | 20 ---- app/service/subscribers/base.py | 48 ++++++++++ app/service/subscribers/score_processed.py | 87 +++++++++++++++++ app/signalr/hub/metadata.py | 105 +++++++++++++++++++++ app/signalr/hub/multiplayer.py | 1 - 9 files changed, 279 insertions(+), 25 deletions(-) delete mode 100644 app/service/subscriber.py create mode 100644 app/service/subscribers/base.py create mode 100644 app/service/subscribers/score_processed.py diff --git a/app/database/score.py b/app/database/score.py index 37b96a3..289660e 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -696,4 +696,5 @@ async def process_score( await session.refresh(score) await session.refresh(score_token) await session.refresh(user) + await redis.publish("score:processed", score.id) return score diff --git a/app/dependencies/database.py b/app/dependencies/database.py index a6b6e5c..e74af93 100644 --- a/app/dependencies/database.py +++ b/app/dependencies/database.py @@ -40,5 +40,5 @@ def get_redis(): return redis_client -def get_redis_pubsub(channel: str | None = None): - return redis_client.pubsub(ignore_subscribe_messages=True, channel=channel) +def get_redis_pubsub(): + return redis_client.pubsub() diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index 7ef2b7a..8bf237d 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -5,7 +5,9 @@ from typing import ClassVar, Literal from app.models.signalr import SignalRUnionMessage, UserState -from pydantic import BaseModel +from pydantic import BaseModel, Field + +TOTAL_SCORE_DISTRIBUTION_BINS = 13 class _UserActivity(SignalRUnionMessage): ... @@ -96,6 +98,7 @@ UserActivity = ( | ModdingBeatmap | TestingBeatmap | InDailyChallengeLobby + | PlayingDailyChallenge ) @@ -127,3 +130,30 @@ class OnlineStatus(IntEnum): class DailyChallengeInfo(BaseModel): room_id: int + + +class MultiplayerPlaylistItemStats(BaseModel): + playlist_item_id: int = 0 + total_score_distribution: list[int] = Field( + default_factory=list, + min_length=TOTAL_SCORE_DISTRIBUTION_BINS, + max_length=TOTAL_SCORE_DISTRIBUTION_BINS, + ) + cumulative_score: int = 0 + last_processed_score_id: int = 0 + + +class MultiplayerRoomStats(BaseModel): + room_id: int + playlist_item_stats: dict[int, MultiplayerPlaylistItemStats] = Field( + default_factory=dict + ) + + +class MultiplayerRoomScoreSetEvent(BaseModel): + room_id: int + playlist_item_id: int + score_id: int + user_id: int + total_score: int + new_rank: int | None = None diff --git a/app/router/score.py b/app/router/score.py index 506ebac..d826fd0 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -464,9 +464,13 @@ async def show_playlist_score( session: AsyncSession = Depends(get_db), redis: Redis = Depends(get_redis), ): + room = await session.get(Room, room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + start_time = time.time() score_record = None - completed = False + completed = room.category != RoomCategory.REALTIME while time.time() - start_time < READ_SCORE_TIMEOUT: if score_record is None: score_record = ( diff --git a/app/service/subscriber.py b/app/service/subscriber.py deleted file mode 100644 index 39478ab..0000000 --- a/app/service/subscriber.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from typing import Any - -from app.dependencies.database import get_redis_pubsub - - -class RedisSubscriber: - def __init__(self, channel: str): - self.pubsub = get_redis_pubsub(channel) - self.handlers: dict[str, list[Callable[[str, str], Awaitable[Any]]]] = {} - - async def listen(self): - async for message in self.pubsub.listen(): - if message is not None and message["type"] == "message": - method = self.handlers.get(message["channel"]) - if method: - for handler in method: - await handler(message["channel"], message["data"]) diff --git a/app/service/subscribers/base.py b/app/service/subscribers/base.py new file mode 100644 index 0000000..144dfd0 --- /dev/null +++ b/app/service/subscribers/base.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + +from app.dependencies.database import get_redis_pubsub + + +class RedisSubscriber: + def __init__(self): + self.pubsub = get_redis_pubsub() + self.handlers: dict[str, list[Callable[[str, str], Awaitable[Any]]]] = {} + self.task: asyncio.Task | None = None + + async def subscribe(self, channel: str): + await self.pubsub.subscribe(channel) + if channel not in self.handlers: + self.handlers[channel] = [] + + async def unsubscribe(self, channel: str): + if channel in self.handlers: + del self.handlers[channel] + await self.pubsub.unsubscribe(channel) + + async def listen(self): + while True: + message = await self.pubsub.get_message( + ignore_subscribe_messages=True, timeout=None + ) + if message is not None and message["type"] == "message": + method = self.handlers.get(message["channel"]) + if method: + await asyncio.gather( + *[ + handler(message["channel"], message["data"]) + for handler in method + ] + ) + + def start(self): + if self.task is None or self.task.done(): + self.task = asyncio.create_task(self.listen()) + + def stop(self): + if self.task is not None and not self.task.done(): + self.task.cancel() + self.task = None diff --git a/app/service/subscribers/score_processed.py b/app/service/subscribers/score_processed.py new file mode 100644 index 0000000..b1bc5bd --- /dev/null +++ b/app/service/subscribers/score_processed.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.database import PlaylistBestScore, Score +from app.database.playlist_best_score import get_position +from app.dependencies.database import engine +from app.models.metadata_hub import MultiplayerRoomScoreSetEvent + +from .base import RedisSubscriber + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +if TYPE_CHECKING: + from app.signalr.hub import MetadataHub + + +CHANNEL = "score:processed" + + +class ScoreSubscriber(RedisSubscriber): + def __init__(self): + super().__init__() + self.room_subscriber: dict[int, list[int]] = {} + self.metadata_hub: "MetadataHub | None " = None + self.subscribed = False + self.handlers[CHANNEL] = [self._handler] + + async def subscribe_room_score(self, room_id: int, user_id: int): + if room_id not in self.room_subscriber: + await self.subscribe(CHANNEL) + self.start() + self.room_subscriber.setdefault(room_id, []).append(user_id) + + async def unsubscribe_room_score(self, room_id: int, user_id: int): + if room_id in self.room_subscriber: + self.room_subscriber[room_id].remove(user_id) + if not self.room_subscriber[room_id]: + del self.room_subscriber[room_id] + + async def _notify_room_score_processed(self, score_id: int): + if not self.metadata_hub: + return + async with AsyncSession(engine) as session: + score = await session.get(Score, score_id) + if ( + not score + or not score.passed + or score.room_id is None + or score.playlist_item_id is None + ): + return + if not self.room_subscriber.get(score.room_id, []): + return + + new_rank = None + user_best = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.user_id == score.user_id, + PlaylistBestScore.room_id == score.room_id, + ) + ) + ).first() + if user_best and user_best.score_id == score_id: + new_rank = await get_position( + user_best.room_id, + user_best.playlist_id, + user_best.score_id, + session, + ) + + event = MultiplayerRoomScoreSetEvent( + room_id=score.room_id, + playlist_item_id=score.playlist_item_id, + score_id=score_id, + user_id=score.user_id, + total_score=score.total_score, + new_rank=new_rank, + ) + await self.metadata_hub.notify_room_score_processed(event) + + async def _handler(self, channel: str, data: str): + score_id = int(data) + if self.metadata_hub: + await self._notify_room_score_processed(score_id) diff --git a/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py index ef21c93..f81aefa 100644 --- a/app/signalr/hub/metadata.py +++ b/app/signalr/hub/metadata.py @@ -1,20 +1,30 @@ from __future__ import annotations import asyncio +from collections import defaultdict from collections.abc import Coroutine from datetime import UTC, datetime +import math from typing import override +from app.calculator import clamp from app.database import Relationship, RelationshipType, User +from app.database.playlist_best_score import PlaylistBestScore +from app.database.playlists import Playlist from app.database.room import Room from app.dependencies.database import engine, get_redis from app.models.metadata_hub import ( + TOTAL_SCORE_DISTRIBUTION_BINS, DailyChallengeInfo, MetadataClientState, + MultiplayerPlaylistItemStats, + MultiplayerRoomScoreSetEvent, + MultiplayerRoomStats, OnlineStatus, UserActivity, ) from app.models.room import RoomCategory +from app.service.subscribers.score_processed import ScoreSubscriber from .hub import Client, Hub @@ -27,11 +37,33 @@ ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers" class MetadataHub(Hub[MetadataClientState]): def __init__(self) -> None: super().__init__() + self.subscriber = ScoreSubscriber() + self.subscriber.metadata_hub = self + self._daily_challenge_stats: MultiplayerRoomStats | None = None + self._today = datetime.now(UTC).date() + self._lock = asyncio.Lock() + + def get_daily_challenge_stats( + self, daily_challenge_room: int + ) -> MultiplayerRoomStats: + if ( + self._daily_challenge_stats is None + or self._today != datetime.now(UTC).date() + ): + self._daily_challenge_stats = MultiplayerRoomStats( + room_id=daily_challenge_room, + playlist_item_stats={}, + ) + return self._daily_challenge_stats @staticmethod def online_presence_watchers_group() -> str: return ONLINE_PRESENCE_WATCHERS_GROUP + @staticmethod + def room_watcher_group(room_id: int) -> str: + return f"metadata:multiplayer-room-watchers:{room_id}" + def broadcast_tasks( self, user_id: int, store: MetadataClientState | None ) -> set[Coroutine]: @@ -186,3 +218,76 @@ class MetadataHub(Hub[MetadataClientState]): async def EndWatchingUserPresence(self, client: Client) -> None: self.remove_from_group(client, self.online_presence_watchers_group()) + + async def notify_room_score_processed(self, event: MultiplayerRoomScoreSetEvent): + await self.broadcast_group_call( + self.room_watcher_group(event.room_id), "MultiplayerRoomScoreSet", event + ) + + async def BeginWatchingMultiplayerRoom(self, client: Client, room_id: int): + self.add_to_group(client, self.room_watcher_group(room_id)) + await self.subscriber.subscribe_room_score(room_id, client.user_id) + stats = self.get_daily_challenge_stats(room_id) + await self.update_daily_challenge_stats(stats) + return list(stats.playlist_item_stats.values()) + + async def update_daily_challenge_stats(self, stats: MultiplayerRoomStats) -> None: + async with AsyncSession(engine) as session: + playlist_ids = ( + await session.exec( + select(Playlist.id).where( + Playlist.room_id == stats.room_id, + ) + ) + ).all() + for playlist_id in playlist_ids: + item = stats.playlist_item_stats.get(playlist_id, None) + if item is None: + item = MultiplayerPlaylistItemStats( + playlist_item_id=playlist_id, + total_score_distribution=[0] * TOTAL_SCORE_DISTRIBUTION_BINS, + cumulative_score=0, + last_processed_score_id=0, + ) + stats.playlist_item_stats[playlist_id] = item + last_processed_score_id = item.last_processed_score_id + scores = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.room_id == stats.room_id, + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.score_id > last_processed_score_id, + ) + ) + ).all() + if len(scores) == 0: + continue + + async with self._lock: + if item.last_processed_score_id == last_processed_score_id: + totals = defaultdict(int) + for score in scores: + bin_index = int( + clamp( + math.floor(score.total_score / 100000), + 0, + TOTAL_SCORE_DISTRIBUTION_BINS - 1, + ) + ) + totals[bin_index] += 1 + + item.cumulative_score += sum( + score.total_score for score in scores + ) + + for j in range(TOTAL_SCORE_DISTRIBUTION_BINS): + item.total_score_distribution[j] += totals.get(j, 0) + + if scores: + item.last_processed_score_id = max( + score.score_id for score in scores + ) + + async def EndWatchingMultiplayerRoom(self, client: Client, room_id: int): + self.remove_from_group(client, self.room_watcher_group(room_id)) + await self.subscriber.unsubscribe_room_score(room_id, client.user_id) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 9d6ad8e..e397031 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -1082,7 +1082,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def ChangeSettings(self, client: Client, settings: MultiplayerRoomSettings): - print(settings) store = self.get_or_create_state(client) if store.room_id == 0: raise InvokeException("You are not in a room") From dcdbac8cb06b6f62a80bf0ac4186e5b99bc93c3e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 14:39:20 +0000 Subject: [PATCH 62/65] chore(daily-challenge): update redis key --- app/service/daily_challenge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/service/daily_challenge.py b/app/service/daily_challenge.py index b2a6eb5..cca07b7 100644 --- a/app/service/daily_challenge.py +++ b/app/service/daily_challenge.py @@ -47,7 +47,7 @@ async def daily_challenge_job(): today = datetime.now(UTC).date() redis = get_redis() - key = f"daily_challenge:{today.year}-{today.month}-{today.day}" + key = f"daily_challenge:{today}" if not await redis.exists(key): return try: From 7f224aee8d6b417b8551fc4f35e7ad7d23515291 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 14:59:51 +0000 Subject: [PATCH 63/65] feat(daily-challenge): create on startup --- app/service/daily_challenge.py | 13 +++++++++++++ main.py | 2 ++ 2 files changed, 15 insertions(+) diff --git a/app/service/daily_challenge.py b/app/service/daily_challenge.py index cca07b7..ea93da7 100644 --- a/app/service/daily_challenge.py +++ b/app/service/daily_challenge.py @@ -14,6 +14,7 @@ from app.models.room import RoomCategory from .room import create_playlist_room +from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -50,6 +51,18 @@ async def daily_challenge_job(): key = f"daily_challenge:{today}" if not await redis.exists(key): return + async with AsyncSession(engine) as session: + room = ( + await session.exec( + select(Room).where( + Room.category == RoomCategory.DAILY_CHALLENGE, + col(Room.ends_at) > datetime.now(UTC), + ) + ) + ).first() + if room: + return + try: beatmap = await redis.hget(key, "beatmap") # pyright: ignore[reportGeneralTypeIssues] ruleset_id = await redis.hget(key, "ruleset_id") # pyright: ignore[reportGeneralTypeIssues] diff --git a/main.py b/main.py index 31cdd44..8569afb 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,7 @@ from app.router import ( fetcher_router, signalr_router, ) +from app.service.daily_challenge import daily_challenge_job from fastapi import FastAPI @@ -23,6 +24,7 @@ async def lifespan(app: FastAPI): await create_tables() await get_fetcher() # 初始化 fetcher init_scheduler() + await daily_challenge_job() # on shutdown yield stop_scheduler() From cc0413ea41160b8138893fb426fbe0a42541edf8 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 15:38:15 +0000 Subject: [PATCH 64/65] fix(daily-challenge): correct the duration --- app/service/daily_challenge.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/service/daily_challenge.py b/app/service/daily_challenge.py index ea93da7..ec7f9d0 100644 --- a/app/service/daily_challenge.py +++ b/app/service/daily_challenge.py @@ -19,7 +19,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession async def create_daily_challenge_room( - beatmap: int, ruleset_id: int, required_mods: list[APIMod] = [] + beatmap: int, ruleset_id: int, duration: int, required_mods: list[APIMod] = [] ) -> Room: async with AsyncSession(engine) as session: today = datetime.now(UTC).date() @@ -38,7 +38,7 @@ async def create_daily_challenge_room( ) ], category=RoomCategory.DAILY_CHALLENGE, - duration=24 * 60 - 2, # remain 2 minute to apply new daily challenge + duration=duration, ) @@ -46,9 +46,9 @@ async def create_daily_challenge_room( async def daily_challenge_job(): from app.signalr.hub import MetadataHubs - today = datetime.now(UTC).date() + now = datetime.now(UTC) redis = get_redis() - key = f"daily_challenge:{today}" + key = f"daily_challenge:{now.date()}" if not await redis.exists(key): return async with AsyncSession(engine) as session: @@ -70,7 +70,7 @@ async def daily_challenge_job(): if beatmap is None or ruleset_id is None: logger.warning( - f"[DailyChallenge] Missing required data for daily challenge {today}." + f"[DailyChallenge] Missing required data for daily challenge {now}." " Will try again in 5 minutes." ) get_scheduler().add_job( @@ -87,10 +87,14 @@ async def daily_challenge_job(): if required_mods: mods_list = json.loads(required_mods) + next_day = (now + timedelta(days=1)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) room = await create_daily_challenge_room( beatmap=beatmap_int, ruleset_id=ruleset_id_int, required_mods=mods_list, + duration=int((next_day - now - timedelta(minutes=2)).total_seconds() / 60), ) await MetadataHubs.broadcast_call( "DailyChallengeUpdated", DailyChallengeInfo(room_id=room.id) From 1330f90b4cb32266835d2bd252fa7bb9d1475278 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 15:46:08 +0000 Subject: [PATCH 65/65] fix(multiplayer): round borin mode doesn't work as expected --- app/models/multiplayer_hub.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index b0b4187..35b94c1 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -467,9 +467,6 @@ class MultiplayerQueue: ordered_active_items.extend(current_set) is_first_set = False - - for idx, item in enumerate(ordered_active_items): - item.playlist_order = idx case _: ordered_active_items = sorted( (item for item in self.room.playlist if not item.expired),