feat(multiplayer): support multiplayer events

This commit is contained in:
MingxuanGame
2025-08-07 16:18:54 +00:00
parent bc2961de10
commit 7a2c8c1fb4
8 changed files with 341 additions and 9 deletions

View File

@@ -15,6 +15,7 @@ from .lazer_user import (
User, User,
UserResp, UserResp,
) )
from .multiplayer_event import MultiplayerEvent, MultiplayerEventResp
from .playlist_attempts import ItemAttemptsCount, ItemAttemptsResp from .playlist_attempts import ItemAttemptsCount, ItemAttemptsResp
from .playlist_best_score import PlaylistBestScore from .playlist_best_score import PlaylistBestScore
from .playlists import Playlist, PlaylistResp from .playlists import Playlist, PlaylistResp
@@ -51,6 +52,8 @@ __all__ = [
"FavouriteBeatmapset", "FavouriteBeatmapset",
"ItemAttemptsCount", "ItemAttemptsCount",
"ItemAttemptsResp", "ItemAttemptsResp",
"MultiplayerEvent",
"MultiplayerEventResp",
"MultiplayerScores", "MultiplayerScores",
"OAuthToken", "OAuthToken",
"PPBestScore", "PPBestScore",

View File

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

View File

@@ -29,9 +29,7 @@ class ItemAttemptsCountBase(SQLModel):
class ItemAttemptsCount(ItemAttemptsCountBase, table=True): class ItemAttemptsCount(ItemAttemptsCountBase, table=True):
__tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType] __tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType]
id: int | None = Field( id: int | None = Field(default=None, primary_key=True)
default=None, foreign_key="room_playlists.db_id", primary_key=True
)
user: User = Relationship() user: User = Relationship()

View File

@@ -133,8 +133,11 @@ class PlaylistResp(PlaylistBase):
beatmap: BeatmapResp | None = None beatmap: BeatmapResp | None = None
@classmethod @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 = 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) resp = cls.model_validate(data)
return resp return resp

View File

@@ -98,7 +98,7 @@ class RoomResp(RoomBase):
difficulty_range.max = max( difficulty_range.max = max(
difficulty_range.max, playlist.beatmap.difficulty_rating 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) stats.ruleset_ids = list(rulesets)
resp.playlist_item_stats = stats resp.playlist_item_stats = stats
resp.difficulty_range = difficulty_range resp.difficulty_range = difficulty_range

View File

@@ -6,7 +6,16 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from enum import IntEnum 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.database.beatmap import Beatmap
from app.dependencies.database import engine from app.dependencies.database import engine
@@ -705,6 +714,9 @@ class MatchTypeHandler(ABC):
@abstractmethod @abstractmethod
async def handle_leave(self, user: MultiplayerRoomUser): ... async def handle_leave(self, user: MultiplayerRoomUser): ...
@abstractmethod
def get_details(self) -> MatchStartedEventDetail: ...
class HeadToHeadHandler(MatchTypeHandler): class HeadToHeadHandler(MatchTypeHandler):
@override @override
@@ -721,6 +733,11 @@ class HeadToHeadHandler(MatchTypeHandler):
@override @override
async def handle_leave(self, user: MultiplayerRoomUser): ... 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): class TeamVersusHandler(MatchTypeHandler):
@override @override
@@ -780,6 +797,17 @@ class TeamVersusHandler(MatchTypeHandler):
@override @override
async def handle_leave(self, user: MultiplayerRoomUser): ... 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 = { MATCH_TYPE_HANDLERS = {
MatchType.HEAD_TO_HEAD: HeadToHeadHandler, MatchType.HEAD_TO_HEAD: HeadToHeadHandler,
@@ -890,3 +918,8 @@ MatchServerEvent = CountdownStartedEvent | CountdownStoppedEvent
class GameplayAbortReason(IntEnum): class GameplayAbortReason(IntEnum):
LOAD_TOOK_TOO_LONG = 0 LOAD_TOOK_TOO_LONG = 0
HOST_ABORTED = 1 HOST_ABORTED = 1
class MatchStartedEventDetail(TypedDict):
room_type: Literal["playlists", "head_to_head", "team_versus"]
team: dict[int, Literal["blue", "red"]] | None

View File

@@ -3,10 +3,14 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Literal 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.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp
from app.database.playlists import Playlist, PlaylistResp from app.database.playlists import Playlist, PlaylistResp
from app.database.room import Room, RoomBase, RoomResp from app.database.room import Room, RoomBase, RoomResp
from app.database.score import Score
from app.dependencies.database import get_db, get_redis from app.dependencies.database import get_db, get_redis
from app.dependencies.fetcher import get_fetcher from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user 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) resp = await RoomResp.from_hub(server_room)
await db.refresh(db_room) await db.refresh(db_room)
for item in db_room.playlist: 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 return resp
else: else:
raise HTTPException(404, "room not found0") raise HTTPException(404, "room not found0")
@@ -178,3 +182,115 @@ async def get_room_leaderboard(
leaderboard=aggs_resp, leaderboard=aggs_resp,
user_score=user_agg, 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,
)

View File

@@ -7,6 +7,7 @@ from typing import override
from app.database import Room from app.database import Room
from app.database.beatmap import Beatmap from app.database.beatmap import Beatmap
from app.database.lazer_user import User from app.database.lazer_user import User
from app.database.multiplayer_event import MultiplayerEvent
from app.database.playlists import Playlist from app.database.playlists import Playlist
from app.database.relationship import Relationship, RelationshipType from app.database.relationship import Relationship, RelationshipType
from app.dependencies.database import engine, get_redis from app.dependencies.database import engine, get_redis
@@ -20,6 +21,7 @@ from app.models.multiplayer_hub import (
MatchRequest, MatchRequest,
MatchServerEvent, MatchServerEvent,
MatchStartCountdown, MatchStartCountdown,
MatchStartedEventDetail,
MultiplayerClientState, MultiplayerClientState,
MultiplayerRoom, MultiplayerRoom,
MultiplayerRoomSettings, MultiplayerRoomSettings,
@@ -49,11 +51,100 @@ from sqlmodel.ext.asyncio.session import AsyncSession
GAMEPLAY_LOAD_TIMEOUT = 30 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]): class MultiplayerHub(Hub[MultiplayerClientState]):
@override @override
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.rooms: dict[int, ServerMultiplayerRoom] = {} self.rooms: dict[int, ServerMultiplayerRoom] = {}
self.event_logger = MultiplayerEventLogger()
@staticmethod @staticmethod
def group_id(room: int) -> str: def group_id(room: int) -> str:
@@ -113,6 +204,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
) )
self.rooms[room.room_id] = server_room self.rooms[room.room_id] = server_room
await server_room.set_handler() await server_room.set_handler()
await self.event_logger.room_created(room.room_id, client.user_id)
return await self.JoinRoomWithPassword( return await self.JoinRoomWithPassword(
client, room.room_id, room.settings.password client, room.room_id, room.settings.password
) )
@@ -143,6 +235,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
room.users.append(user) room.users.append(user)
self.add_to_group(client, self.group_id(room_id)) self.add_to_group(client, self.group_id(room_id))
await server_room.match_type_handler.handle_join(user) await server_room.match_type_handler.handle_join(user)
await self.event_logger.player_joined(room_id, user.user_id)
return room return room
async def ChangeBeatmapAvailability( async def ChangeBeatmapAvailability(
@@ -550,10 +643,12 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
if all( if all(
u.state != MultiplayerUserState.PLAYING for u in room.room.users u.state != MultiplayerUserState.PLAYING for u in room.room.users
): ):
any_user_finished_playing = False
for u in filter( for u in filter(
lambda u: u.state == MultiplayerUserState.FINISHED_PLAY, lambda u: u.state == MultiplayerUserState.FINISHED_PLAY,
room.room.users, room.room.users,
): ):
any_user_finished_playing = True
await self.change_user_state( await self.change_user_state(
room, u, MultiplayerUserState.RESULTS room, u, MultiplayerUserState.RESULTS
) )
@@ -562,6 +657,16 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
self.group_id(room.room.room_id), self.group_id(room.room.room_id),
"ResultsReady", "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() await room.queue.finish_current_item()
async def change_room_state( async def change_room_state(
@@ -635,6 +740,11 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
), ),
self.start_gameplay, 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): async def start_gameplay(self, room: ServerMultiplayerRoom):
if room.room.state != MultiplayerRoomState.WAITING_FOR_LOAD: if room.room.state != MultiplayerRoomState.WAITING_FOR_LOAD:
@@ -737,6 +847,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
host_id=room.room.host.user_id, 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] del self.rooms[room.room.room_id]
async def LeaveRoom(self, client: Client): async def LeaveRoom(self, client: Client):
@@ -751,6 +865,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
if user is None: if user is None:
raise InvokeException("You are not in this room") 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) await self.make_user_leave(client, server_room, user)
async def KickUser(self, client: Client, user_id: int): async def KickUser(self, client: Client, user_id: int):
@@ -772,6 +890,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
if user is None: if user is None:
raise InvokeException("User not found in this room") 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)) target_client = self.get_client_by_id(str(user.user_id))
if target_client is None: if target_client is None:
return 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) new_host = next((u for u in room.users if u.user_id == user_id), None)
if new_host is None: if new_host is None:
raise InvokeException("User not found in this room") 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) await self.set_host(server_room, new_host)
async def AbortGameplay(self, client: Client): async def AbortGameplay(self, client: Client):