feat(multiplayer): support multiplayer events
This commit is contained in:
@@ -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",
|
||||
|
||||
53
app/database/multiplayer_event.py
Normal file
53
app/database/multiplayer_event.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user