Files
g0v0-server/app/signalr/hub/multiplayer.py
2025-08-22 03:16:21 +08:00

1440 lines
54 KiB
Python

from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from typing import override
from app.database import Room
from app.database.beatmap import Beatmap
from app.database.chat import ChannelType, ChatChannel
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 get_redis, with_db
from app.dependencies.fetcher import get_fetcher
from app.exception import InvokeException
from app.log import logger
from app.models.mods import APIMod
from app.models.multiplayer_hub import (
BeatmapAvailability,
ForceGameplayStartCountdown,
GameplayAbortReason,
MatchRequest,
MatchServerEvent,
MatchStartCountdown,
MatchStartedEventDetail,
MultiplayerClientState,
MultiplayerRoom,
MultiplayerRoomSettings,
MultiplayerRoomUser,
PlaylistItem,
ServerMultiplayerRoom,
ServerShuttingDownCountdown,
StartMatchCountdownRequest,
StopCountdownRequest,
)
from app.models.room import (
DownloadState,
MatchType,
MultiplayerRoomState,
MultiplayerUserState,
RoomCategory,
RoomStatus,
)
from app.models.score import GameMode
from .hub import Client, Hub
from httpx import HTTPError
from sqlalchemy import update
from sqlmodel import col, exists, select
GAMEPLAY_LOAD_TIMEOUT = 30
class MultiplayerEventLogger:
def __init__(self):
pass
async def log_event(self, event: MultiplayerEvent):
try:
async with with_db() 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:
return f"room:{room}"
@override
def create_state(self, client: Client) -> MultiplayerClientState:
return MultiplayerClientState(
connection_id=client.connection_id,
connection_token=client.connection_token,
)
@override
async def _clean_state(self, state: MultiplayerClientState):
user_id = int(state.connection_id)
# Remove from online user tracking
from app.router.v2.stats import remove_online_user
asyncio.create_task(remove_online_user(user_id))
if state.room_id != 0 and state.room_id in self.rooms:
server_room = self.rooms[state.room_id]
room = server_room.room
user = next((u for u in room.users if u.user_id == user_id), None)
if user is not None:
await self.make_user_leave(
self.get_client_by_id(str(user_id)), server_room, user
)
async def on_client_connect(self, client: Client) -> None:
"""Track online users when connecting to multiplayer hub"""
logger.info(f"[MultiplayerHub] Client {client.user_id} connected")
# Track online user
from app.router.v2.stats import add_online_user
asyncio.create_task(add_online_user(client.user_id))
def _ensure_in_room(self, client: Client) -> ServerMultiplayerRoom:
store = self.get_or_create_state(client)
if store.room_id == 0:
raise InvokeException("You are not in a room")
if store.room_id not in self.rooms:
raise InvokeException("Room does not exist")
server_room = self.rooms[store.room_id]
return server_room
def _ensure_host(self, client: Client, server_room: ServerMultiplayerRoom):
room = server_room.room
if room.host is None or room.host.user_id != client.user_id:
raise InvokeException("You are not the host of this room")
async def CreateRoom(self, client: Client, room: MultiplayerRoom):
logger.info(f"[MultiplayerHub] {client.user_id} creating room")
store = self.get_or_create_state(client)
if store.room_id != 0:
raise InvokeException("You are already in a room")
async with with_db() as session:
async with session:
db_room = Room(
name=room.settings.name,
category=RoomCategory.REALTIME,
type=room.settings.match_type,
queue_mode=room.settings.queue_mode,
auto_skip=room.settings.auto_skip,
auto_start_duration=int(
room.settings.auto_start_duration.total_seconds()
),
host_id=client.user_id,
status=RoomStatus.IDLE,
)
session.add(db_room)
await session.commit()
await session.refresh(db_room)
channel = ChatChannel(
name=f"room_{db_room.id}",
description="Multiplayer room",
type=ChannelType.MULTIPLAYER,
)
session.add(channel)
await session.commit()
await session.refresh(channel)
await session.refresh(db_room)
room.channel_id = channel.channel_id # pyright: ignore[reportAttributeAccessIssue]
db_room.channel_id = channel.channel_id
item = room.playlist[0]
item.owner_id = client.user_id
room.room_id = db_room.id
starts_at = db_room.starts_at or datetime.now(UTC)
beatmap_exists = await session.exec(
select(exists().where(col(Beatmap.id) == item.beatmap_id))
)
if not beatmap_exists.one():
fetcher = await get_fetcher()
try:
await Beatmap.get_or_fetch(
session, fetcher, bid=item.beatmap_id
)
except HTTPError:
raise InvokeException(
"Failed to fetch beatmap, please retry later"
)
await Playlist.add_to_db(item, room.room_id, session)
server_room = ServerMultiplayerRoom(
room=room,
category=RoomCategory.NORMAL,
start_at=starts_at,
hub=self,
)
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
)
async def JoinRoom(self, client: Client, room_id: int):
return self.JoinRoomWithPassword(client, room_id, "")
async def JoinRoomWithPassword(self, client: Client, room_id: int, password: str):
logger.info(f"[MultiplayerHub] {client.user_id} joining room {room_id}")
store = self.get_or_create_state(client)
if store.room_id != 0:
raise InvokeException("You are already in a room")
user = MultiplayerRoomUser(user_id=client.user_id)
if room_id not in self.rooms:
raise InvokeException("Room does not exist")
server_room = self.rooms[room_id]
room = server_room.room
for u in room.users:
if u.user_id == client.user_id:
raise InvokeException("You are already in this room")
if room.settings.password != password:
raise InvokeException("Incorrect password")
if room.host is None:
# from CreateRoom
room.host = user
store.room_id = room_id
await self.broadcast_group_call(self.group_id(room_id), "UserJoined", user)
room.users.append(user)
self.add_to_group(client, self.group_id(room_id))
await server_room.match_type_handler.handle_join(user)
# Critical fix: Send current room and gameplay state to new user
# This ensures spectators joining ongoing games get proper state sync
await self._send_room_state_to_new_user(client, server_room)
await self.event_logger.player_joined(room_id, user.user_id)
async with with_db() 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)
db_room = await session.get(Room, room_id)
if db_room is None:
raise InvokeException("Room does not exist in database")
db_room.participant_count += 1
redis = get_redis()
await redis.publish("chat:room:joined", f"{room.channel_id}:{user.user_id}")
return room
async def change_beatmap_availability(
self,
room_id: int,
user: MultiplayerRoomUser,
beatmap_availability: BeatmapAvailability,
):
availability = user.availability
if (
availability.state == beatmap_availability.state
and availability.download_progress == beatmap_availability.download_progress
):
return
user.availability = beatmap_availability
await self.broadcast_group_call(
self.group_id(room_id),
"UserBeatmapAvailabilityChanged",
user.user_id,
beatmap_availability,
)
async def ChangeBeatmapAvailability(
self, client: Client, beatmap_availability: BeatmapAvailability
):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
await self.change_beatmap_availability(
room.room_id,
user,
beatmap_availability,
)
async def AddPlaylistItem(self, client: Client, item: PlaylistItem):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
logger.info(
f"[MultiplayerHub] {client.user_id} adding "
f"beatmap {item.beatmap_id} to room {room.room_id}"
)
await server_room.queue.add_item(
item,
user,
)
async def EditPlaylistItem(self, client: Client, item: PlaylistItem):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
logger.info(
f"[MultiplayerHub] {client.user_id} editing "
f"item {item.id} in room {room.room_id}"
)
await server_room.queue.edit_item(
item,
user,
)
async def RemovePlaylistItem(self, client: Client, item_id: int):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
logger.info(
f"[MultiplayerHub] {client.user_id} removing "
f"item {item_id} from room {room.room_id}"
)
await server_room.queue.remove_item(
item_id,
user,
)
async def change_db_settings(self, room: ServerMultiplayerRoom):
async with with_db() as session:
await session.execute(
update(Room)
.where(col(Room.id) == room.room.room_id)
.values(
name=room.room.settings.name,
type=room.room.settings.match_type,
queue_mode=room.room.settings.queue_mode,
auto_skip=room.room.settings.auto_skip,
auto_start_duration=int(
room.room.settings.auto_start_duration.total_seconds()
),
host_id=room.room.host.user_id if room.room.host else None,
)
)
await session.commit()
async def setting_changed(self, room: ServerMultiplayerRoom, beatmap_changed: bool):
await self.change_db_settings(room)
await self.validate_styles(room)
await self.unready_all_users(room, beatmap_changed)
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"SettingsChanged",
room.room.settings,
)
async def playlist_added(self, room: ServerMultiplayerRoom, item: PlaylistItem):
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"PlaylistItemAdded",
item,
)
async def playlist_removed(self, room: ServerMultiplayerRoom, item_id: int):
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"PlaylistItemRemoved",
item_id,
)
async def playlist_changed(
self, room: ServerMultiplayerRoom, item: PlaylistItem, beatmap_changed: bool
):
if item.id == room.room.settings.playlist_item_id:
await self.validate_styles(room)
await self.unready_all_users(room, beatmap_changed)
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"PlaylistItemChanged",
item,
)
async def ChangeUserStyle(
self, client: Client, beatmap_id: int | None, ruleset_id: int | None
):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
await self.change_user_style(
beatmap_id,
ruleset_id,
server_room,
user,
)
async def validate_styles(self, room: ServerMultiplayerRoom):
fetcher = await get_fetcher()
if not room.queue.current_item.freestyle:
for user in room.room.users:
await self.change_user_style(
None,
None,
room,
user,
)
async with with_db() as session:
try:
beatmap = await Beatmap.get_or_fetch(
session, fetcher, bid=room.queue.current_item.beatmap_id
)
except HTTPError:
raise InvokeException("Current item beatmap not found")
beatmap_ids = (
await session.exec(
select(Beatmap.id, Beatmap.mode).where(
Beatmap.beatmapset_id == beatmap.beatmapset_id,
)
)
).all()
for user in room.room.users:
beatmap_id = user.beatmap_id
ruleset_id = user.ruleset_id
user_beatmap = next(
(b for b in beatmap_ids if b[0] == beatmap_id),
None,
)
if beatmap_id is not None and user_beatmap is None:
beatmap_id = None
beatmap_ruleset = user_beatmap[1] if user_beatmap else beatmap.mode
if (
ruleset_id is not None
and beatmap_ruleset != GameMode.OSU
and ruleset_id != beatmap_ruleset
):
ruleset_id = None
await self.change_user_style(
beatmap_id,
ruleset_id,
room,
user,
)
for user in room.room.users:
is_valid, valid_mods = room.queue.current_item.validate_user_mods(
user, user.mods
)
if not is_valid:
await self.change_user_mods(valid_mods, room, user)
async def change_user_style(
self,
beatmap_id: int | None,
ruleset_id: int | None,
room: ServerMultiplayerRoom,
user: MultiplayerRoomUser,
):
if user.beatmap_id == beatmap_id and user.ruleset_id == ruleset_id:
return
if beatmap_id is not None or ruleset_id is not None:
if not room.queue.current_item.freestyle:
raise InvokeException("Current item does not allow free user styles.")
async with with_db() as session:
item_beatmap = await session.get(
Beatmap, room.queue.current_item.beatmap_id
)
if item_beatmap is None:
raise InvokeException("Item beatmap not found")
user_beatmap = (
item_beatmap
if beatmap_id is None
else await session.get(Beatmap, beatmap_id)
)
if user_beatmap is None:
raise InvokeException("Invalid beatmap selected.")
if user_beatmap.beatmapset_id != item_beatmap.beatmapset_id:
raise InvokeException(
"Selected beatmap is not from the same beatmap set."
)
if (
ruleset_id is not None
and user_beatmap.mode != GameMode.OSU
and ruleset_id != int(user_beatmap.mode)
):
raise InvokeException(
"Selected ruleset is not supported for the given beatmap."
)
user.beatmap_id = beatmap_id
user.ruleset_id = ruleset_id
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"UserStyleChanged",
user.user_id,
beatmap_id,
ruleset_id,
)
async def ChangeUserMods(self, client: Client, new_mods: list[APIMod]):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
await self.change_user_mods(new_mods, server_room, user)
async def change_user_mods(
self,
new_mods: list[APIMod],
room: ServerMultiplayerRoom,
user: MultiplayerRoomUser,
):
is_valid, valid_mods = room.queue.current_item.validate_user_mods(
user, new_mods
)
if not is_valid:
incompatible_mods = [
mod["acronym"] for mod in new_mods if mod not in valid_mods
]
raise InvokeException(
f"Incompatible mods were selected: {','.join(incompatible_mods)}"
)
if user.mods == valid_mods:
return
user.mods = valid_mods
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"UserModsChanged",
user.user_id,
valid_mods,
)
async def validate_user_stare(
self,
room: ServerMultiplayerRoom,
old: MultiplayerUserState,
new: MultiplayerUserState,
):
match new:
case MultiplayerUserState.IDLE:
if old.is_playing:
raise InvokeException(
"Cannot return to idle without aborting gameplay."
)
case MultiplayerUserState.READY:
if old != MultiplayerUserState.IDLE:
raise InvokeException(f"Cannot change state from {old} to {new}")
if room.queue.current_item.expired:
raise InvokeException(
"Cannot ready up while all items have been played."
)
case MultiplayerUserState.WAITING_FOR_LOAD:
raise InvokeException(f"Cannot change state from {old} to {new}")
case MultiplayerUserState.LOADED:
if old != MultiplayerUserState.WAITING_FOR_LOAD:
raise InvokeException(f"Cannot change state from {old} to {new}")
case MultiplayerUserState.READY_FOR_GAMEPLAY:
if old != MultiplayerUserState.LOADED:
raise InvokeException(f"Cannot change state from {old} to {new}")
case MultiplayerUserState.PLAYING:
raise InvokeException("State is managed by the server.")
case MultiplayerUserState.FINISHED_PLAY:
if old != MultiplayerUserState.PLAYING:
raise InvokeException(f"Cannot change state from {old} to {new}")
case MultiplayerUserState.RESULTS:
raise InvokeException(f"Cannot change state from {old} to {new}")
case MultiplayerUserState.SPECTATING:
# Enhanced spectator validation - allow transitions from more states
# This matches official osu-server-spectator behavior
if old not in (
MultiplayerUserState.IDLE,
MultiplayerUserState.READY,
MultiplayerUserState.RESULTS, # Allow spectating after results
):
# Allow spectating during gameplay states only if the room is in appropriate state
if not (old.is_playing and room.room.state in (
MultiplayerRoomState.WAITING_FOR_LOAD,
MultiplayerRoomState.PLAYING
)):
raise InvokeException(f"Cannot change state from {old} to {new}")
case _:
raise InvokeException(f"Invalid state transition from {old} to {new}")
async def ChangeState(self, client: Client, state: MultiplayerUserState):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
if user.state == state:
return
# Special handling for state changes during gameplay
match state:
case MultiplayerUserState.IDLE:
if user.state.is_playing:
return
case MultiplayerUserState.LOADED | MultiplayerUserState.READY_FOR_GAMEPLAY:
if not user.state.is_playing:
return
logger.info(
f"[MultiplayerHub] User {user.user_id} changing state from {user.state} to {state}"
)
await self.validate_user_stare(
server_room,
user.state,
state,
)
await self.change_user_state(server_room, user, state)
# Enhanced spectator handling based on official implementation
if state == MultiplayerUserState.SPECTATING:
await self.handle_spectator_state_change(client, server_room, user)
await self.update_room_state(server_room)
async def change_user_state(
self,
room: ServerMultiplayerRoom,
user: MultiplayerRoomUser,
state: MultiplayerUserState,
):
logger.info(
f"[MultiplayerHub] {user.user_id}'s state "
f"changed from {user.state} to {state}"
)
user.state = state
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"UserStateChanged",
user.user_id,
user.state,
)
async def handle_spectator_state_change(
self,
client: Client,
room: ServerMultiplayerRoom,
user: MultiplayerRoomUser
):
"""
Handle special logic for users entering spectator mode during ongoing gameplay.
Based on official osu-server-spectator implementation.
"""
room_state = room.room.state
# If switching to spectating during gameplay, immediately request load
if room_state == MultiplayerRoomState.WAITING_FOR_LOAD:
logger.info(
f"[MultiplayerHub] Spectator {user.user_id} joining during load phase"
)
await self.call_noblock(client, "LoadRequested")
elif room_state == MultiplayerRoomState.PLAYING:
logger.info(
f"[MultiplayerHub] Spectator {user.user_id} joining during active gameplay"
)
await self.call_noblock(client, "LoadRequested")
async def _send_current_gameplay_state_to_spectator(
self,
client: Client,
room: ServerMultiplayerRoom
):
"""
Send current gameplay state information to a newly joined spectator.
This helps spectators sync with ongoing gameplay.
"""
try:
# Send current room state
await self.call_noblock(
client,
"RoomStateChanged",
room.room.state
)
# Send current user states for all players
for room_user in room.room.users:
if room_user.state.is_playing:
await self.call_noblock(
client,
"UserStateChanged",
room_user.user_id,
room_user.state,
)
logger.debug(
f"[MultiplayerHub] Sent current gameplay state to spectator {client.user_id}"
)
except Exception as e:
logger.error(
f"[MultiplayerHub] Failed to send gameplay state to spectator {client.user_id}: {e}"
)
async def _send_room_state_to_new_user(
self,
client: Client,
room: ServerMultiplayerRoom
):
"""
Send complete room state to a newly joined user.
Critical for spectators joining ongoing games.
"""
try:
# Send current room state
if room.room.state != MultiplayerRoomState.OPEN:
await self.call_noblock(
client,
"RoomStateChanged",
room.room.state
)
# If room is in gameplay state, send LoadRequested immediately
if room.room.state in (
MultiplayerRoomState.WAITING_FOR_LOAD,
MultiplayerRoomState.PLAYING
):
logger.info(
f"[MultiplayerHub] Sending LoadRequested to user {client.user_id} "
f"joining ongoing game (room state: {room.room.state})"
)
await self.call_noblock(client, "LoadRequested")
# Send all user states to help with synchronization
for room_user in room.room.users:
if room_user.user_id != client.user_id: # Don't send own state
await self.call_noblock(
client,
"UserStateChanged",
room_user.user_id,
room_user.state,
)
# Critical addition: Send current playing users to SpectatorHub for cross-hub sync
# This ensures spectators can watch multiplayer players properly
await self._sync_with_spectator_hub(client, room)
logger.debug(
f"[MultiplayerHub] Sent complete room state to new user {client.user_id}"
)
except Exception as e:
logger.error(
f"[MultiplayerHub] Failed to send room state to user {client.user_id}: {e}"
)
async def _sync_with_spectator_hub(
self,
client: Client,
room: ServerMultiplayerRoom
):
"""
Sync with SpectatorHub to ensure cross-hub spectating works properly.
This is crucial for users watching multiplayer players from other pages.
"""
try:
# Import here to avoid circular imports
from app.signalr.hub import SpectatorHubs
# For each playing user in the room, check if they have SpectatorHub state
# and notify the new client about their playing status
for room_user in room.room.users:
if room_user.state.is_playing:
spectator_state = SpectatorHubs.state.get(room_user.user_id)
if spectator_state and spectator_state.state:
# Send the spectator state to help with cross-hub watching
await self.call_noblock(
client,
"UserBeganPlaying",
room_user.user_id,
spectator_state.state,
)
logger.debug(
f"[MultiplayerHub] Synced spectator state for user {room_user.user_id} "
f"to new client {client.user_id}"
)
except Exception as e:
logger.debug(f"[MultiplayerHub] Failed to sync with SpectatorHub: {e}")
# This is not critical, so we don't raise the exception
async def update_room_state(self, room: ServerMultiplayerRoom):
match room.room.state:
case MultiplayerRoomState.OPEN:
if room.room.settings.auto_start_enabled:
if (
not room.queue.current_item.expired
and any(
u.state == MultiplayerUserState.READY
for u in room.room.users
)
and not any(
isinstance(countdown, MatchStartCountdown)
for countdown in room.room.active_countdowns
)
):
await room.start_countdown(
MatchStartCountdown(
time_remaining=room.room.settings.auto_start_duration
),
self.start_match,
)
case MultiplayerRoomState.WAITING_FOR_LOAD:
played_count = len(
[True for user in room.room.users if user.state.is_playing]
)
ready_count = len(
[
True
for user in room.room.users
if user.state == MultiplayerUserState.READY_FOR_GAMEPLAY
]
)
if played_count == ready_count:
await self.start_gameplay(room)
case MultiplayerRoomState.PLAYING:
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
)
await self.change_room_state(room, MultiplayerRoomState.OPEN)
await self.broadcast_group_call(
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(
self, room: ServerMultiplayerRoom, state: MultiplayerRoomState
):
logger.debug(
f"[MultiplayerHub] Room {room.room.room_id} state "
f"changed from {room.room.state} to {state}"
)
room.room.state = state
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"RoomStateChanged",
state,
)
async def StartMatch(self, client: Client):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
self._ensure_host(client, server_room)
# Check host state - host must be ready or spectating
if room.host and room.host.state not in (
MultiplayerUserState.SPECTATING,
MultiplayerUserState.READY,
):
raise InvokeException("Can't start match when the host is not ready.")
# Check if any users are ready
if all(u.state != MultiplayerUserState.READY for u in room.users):
raise InvokeException("Can't start match when no users are ready.")
await self.start_match(server_room)
async def start_match(self, room: ServerMultiplayerRoom):
if room.room.state != MultiplayerRoomState.OPEN:
raise InvokeException("Can't start match when already in a running state.")
if room.queue.current_item.expired:
raise InvokeException("Current playlist item is expired")
if all(u.state != MultiplayerUserState.READY for u in room.room.users):
await room.queue.finish_current_item()
logger.info(f"[MultiplayerHub] Room {room.room.room_id} match started")
ready_users = [
u
for u in room.room.users
if u.availability.state == DownloadState.LOCALLY_AVAILABLE
and (
u.state == MultiplayerUserState.READY
or u.state == MultiplayerUserState.IDLE
)
]
for u in ready_users:
await self.change_user_state(room, u, MultiplayerUserState.WAITING_FOR_LOAD)
await self.change_room_state(
room,
MultiplayerRoomState.WAITING_FOR_LOAD,
)
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"LoadRequested",
)
await room.start_countdown(
ForceGameplayStartCountdown(
time_remaining=timedelta(seconds=GAMEPLAY_LOAD_TIMEOUT)
),
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:
raise InvokeException("Room is not ready for gameplay")
if room.queue.current_item.expired:
raise InvokeException("Current playlist item is expired")
await room.stop_all_countdowns(ForceGameplayStartCountdown)
playing = False
played_user = 0
for user in room.room.users:
client = self.get_client_by_id(str(user.user_id))
if client is None:
continue
if user.state in (
MultiplayerUserState.READY_FOR_GAMEPLAY,
MultiplayerUserState.LOADED,
):
playing = True
played_user += 1
await self.change_user_state(room, user, MultiplayerUserState.PLAYING)
await self.call_noblock(client, "GameplayStarted")
elif user.state == MultiplayerUserState.WAITING_FOR_LOAD:
await self.change_user_state(room, user, MultiplayerUserState.IDLE)
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"GameplayAborted",
GameplayAbortReason.LOAD_TOOK_TOO_LONG,
)
await self.change_room_state(
room,
(MultiplayerRoomState.PLAYING if playing else MultiplayerRoomState.OPEN),
)
if playing:
redis = get_redis()
await redis.set(
f"multiplayer:{room.room.room_id}:gameplay:players",
played_user,
ex=3600,
)
else:
await room.queue.finish_current_item()
async def send_match_event(
self, room: ServerMultiplayerRoom, event: MatchServerEvent
):
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"MatchEvent",
event,
)
async def make_user_leave(
self,
client: Client | None,
room: ServerMultiplayerRoom,
user: MultiplayerRoomUser,
kicked: bool = False,
):
if client:
self.remove_from_group(client, self.group_id(room.room.room_id))
room.room.users.remove(user)
target_store = self.state.get(user.user_id)
if target_store:
target_store.room_id = 0
redis = get_redis()
await redis.publish("chat:room:left", f"{room.room.channel_id}:{user.user_id}")
async with with_db() 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")
if db_room.participant_count > 0:
db_room.participant_count -= 1
if len(room.room.users) == 0:
await self.end_room(room)
return
await self.update_room_state(room)
if (
len(room.room.users) != 0
and room.room.host
and room.room.host.user_id == user.user_id
):
next_host = room.room.users[0]
await self.set_host(room, next_host)
if kicked:
if client:
await self.call_noblock(client, "UserKicked", user)
await self.broadcast_group_call(
self.group_id(room.room.room_id), "UserKicked", user
)
else:
await self.broadcast_group_call(
self.group_id(room.room.room_id), "UserLeft", user
)
async def end_room(self, room: ServerMultiplayerRoom):
assert room.room.host
async with with_db() as session:
await session.execute(
update(Room)
.where(col(Room.id) == room.room.room_id)
.values(
name=room.room.settings.name,
ends_at=datetime.now(UTC),
type=room.room.settings.match_type,
queue_mode=room.room.settings.queue_mode,
auto_skip=room.room.settings.auto_skip,
auto_start_duration=int(
room.room.settings.auto_start_duration.total_seconds()
),
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]
logger.info(f"[MultiplayerHub] Room {room.room.room_id} ended")
async def LeaveRoom(self, client: Client):
store = self.get_or_create_state(client)
if store.room_id == 0:
return
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
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)
logger.info(f"[MultiplayerHub] {client.user_id} left room {room.room_id}")
async def KickUser(self, client: Client, user_id: int):
server_room = self._ensure_in_room(client)
room = server_room.room
self._ensure_host(client, server_room)
if user_id == client.user_id:
raise InvokeException("Can't kick self")
user = next((u for u in room.users if u.user_id == user_id), None)
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))
await self.make_user_leave(target_client, server_room, user, kicked=True)
logger.info(
f"[MultiplayerHub] {user.user_id} was kicked from room {room.room_id}"
f"by {client.user_id}"
)
async def set_host(self, room: ServerMultiplayerRoom, user: MultiplayerRoomUser):
room.room.host = user
await self.change_db_settings(room)
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"HostChanged",
user.user_id,
)
async def TransferHost(self, client: Client, user_id: int):
server_room = self._ensure_in_room(client)
room = server_room.room
self._ensure_host(client, server_room)
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)
logger.info(
f"[MultiplayerHub] {client.user_id} transferred host to {new_host.user_id}"
f" in room {room.room_id}"
)
async def AbortGameplay(self, client: Client):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
if not user.state.is_playing:
raise InvokeException("Cannot abort gameplay while not in a gameplay state")
await self.change_user_state(
server_room,
user,
MultiplayerUserState.IDLE,
)
await self.update_room_state(server_room)
async def AbortMatch(self, client: Client):
server_room = self._ensure_in_room(client)
room = server_room.room
self._ensure_host(client, server_room)
if (
room.state != MultiplayerRoomState.PLAYING
and room.state != MultiplayerRoomState.WAITING_FOR_LOAD
):
raise InvokeException("Cannot abort a match that hasn't started.")
await asyncio.gather(
*[
self.change_user_state(server_room, u, MultiplayerUserState.IDLE)
for u in room.users
if u.state.is_playing
]
)
await self.broadcast_group_call(
self.group_id(room.room_id),
"GameplayAborted",
GameplayAbortReason.HOST_ABORTED,
)
await self.update_room_state(server_room)
logger.info(
f"[MultiplayerHub] {client.user_id} aborted match in room {room.room_id}"
)
async def change_user_match_state(
self, room: ServerMultiplayerRoom, user: MultiplayerRoomUser
):
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"MatchUserStateChanged",
user.user_id,
user.match_state,
)
async def change_room_match_state(self, room: ServerMultiplayerRoom):
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"MatchRoomStateChanged",
room.room.match_state,
)
async def ChangeSettings(self, client: Client, settings: MultiplayerRoomSettings):
server_room = self._ensure_in_room(client)
self._ensure_host(client, server_room)
room = server_room.room
if room.state != MultiplayerRoomState.OPEN:
raise InvokeException("Cannot change settings while playing")
if settings.match_type == MatchType.PLAYLISTS:
raise InvokeException("Invalid match type selected")
settings.playlist_item_id = room.settings.playlist_item_id
previous_settings = room.settings
room.settings = settings
if previous_settings.match_type != settings.match_type:
await server_room.set_handler()
if previous_settings.queue_mode != settings.queue_mode:
await server_room.queue.update_queue_mode()
await self.setting_changed(server_room, beatmap_changed=False)
await self.update_room_state(server_room)
async def SendMatchRequest(self, client: Client, request: MatchRequest):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
if isinstance(request, StartMatchCountdownRequest):
if room.host and room.host.user_id != user.user_id:
raise InvokeException("You are not the host of this room")
if room.state != MultiplayerRoomState.OPEN:
raise InvokeException("Cannot start match countdown when not open")
await server_room.start_countdown(
MatchStartCountdown(time_remaining=request.duration),
self.start_match,
)
elif isinstance(request, StopCountdownRequest):
countdown = next(
(c for c in room.active_countdowns if c.id == request.id),
None,
)
if countdown is None:
return
if (
isinstance(countdown, MatchStartCountdown)
and room.settings.auto_start_enabled
) or isinstance(
countdown, (ForceGameplayStartCountdown | ServerShuttingDownCountdown)
):
raise InvokeException("Cannot stop the requested countdown")
await server_room.stop_countdown(countdown)
else:
await server_room.match_type_handler.handle_request(user, request)
async def InvitePlayer(self, client: Client, user_id: int):
server_room = self._ensure_in_room(client)
room = server_room.room
user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None:
raise InvokeException("You are not in this room")
async with with_db() as session:
db_user = await session.get(User, user_id)
target_relationship = (
await session.exec(
select(Relationship).where(
Relationship.user_id == user_id,
Relationship.target_id == client.user_id,
)
)
).first()
inviter_relationship = (
await session.exec(
select(Relationship).where(
Relationship.user_id == client.user_id,
Relationship.target_id == user_id,
)
)
).first()
if db_user is None:
raise InvokeException("User not found")
if db_user.id == client.user_id:
raise InvokeException("You cannot invite yourself")
if db_user.id in [u.user_id for u in room.users]:
raise InvokeException("User already invited")
if db_user.is_restricted:
raise InvokeException("User is restricted")
if (
inviter_relationship
and inviter_relationship.type == RelationshipType.BLOCK
):
raise InvokeException("Cannot perform action due to user being blocked")
if (
target_relationship
and target_relationship.type == RelationshipType.BLOCK
):
raise InvokeException("Cannot perform action due to user being blocked")
if (
db_user.pm_friends_only
and target_relationship is not None
and target_relationship.type != RelationshipType.FOLLOW
):
raise InvokeException(
"Cannot perform action "
"because user has disabled non-friend communications"
)
target_client = self.get_client_by_id(str(user_id))
if target_client is None:
raise InvokeException("User is not online")
await self.call_noblock(
target_client,
"Invited",
client.user_id,
room.room_id,
room.settings.password,
)
async def unready_all_users(
self, room: ServerMultiplayerRoom, reset_beatmap_availability: bool
):
await asyncio.gather(
*[
self.change_user_state(
room,
user,
MultiplayerUserState.IDLE,
)
for user in room.room.users
if user.state == MultiplayerUserState.READY
]
)
if reset_beatmap_availability:
await asyncio.gather(
*[
self.change_beatmap_availability(
room.room.room_id,
user,
BeatmapAvailability(state=DownloadState.UNKNOWN),
)
for user in room.room.users
]
)
await room.stop_all_countdowns(MatchStartCountdown)