From 319599cacc16436ab550d71960955955b39b8688 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 05:49:01 +0000 Subject: [PATCH] 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