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):