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,
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",

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

View File

@@ -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()
if "beatmap" in include:
data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True)
resp = cls.model_validate(data)
return resp

View File

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

View File

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

View File

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

View File

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