Files
g0v0-server/app/signalr/hub/multiplayer.py
咕谷酱 5959254de6 Improve Redis key handling and spectator sync logic
Enhances Redis key type checks and cleanup in message system, adds periodic cleanup task, and improves error handling for Redis operations. Refines multiplayer and spectator hub logic to better synchronize player states and prevent invalid spectator sessions. Adds more detailed logging for channel/user join/leave events and spectator watch requests.
2025-08-26 13:18:11 +08:00

1404 lines
57 KiB
Python

from __future__ import annotations
import asyncio
from datetime import 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 app.utils import utcnow
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)
# Use centralized offline status management
from app.service.online_status_manager import online_status_manager
await online_status_manager.set_user_offline(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")
# Use centralized online status management
from app.service.online_status_manager import online_status_manager
await online_status_manager.set_user_online(client.user_id, "multiplayer")
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
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 utcnow()
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 = utcnow()
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 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 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 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:
# Allow server-managed transitions to RESULTS state
# This includes spectators who need to see results
if old not in (
MultiplayerUserState.FINISHED_PLAY,
MultiplayerUserState.SPECTATING, # Allow spectators to see 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 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")
# Also sync the spectator with current game state
await self._send_current_gameplay_state_to_spectator(client, room)
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 or room_user.state == MultiplayerUserState.RESULTS:
await self.call_noblock(
client,
"UserStateChanged",
room_user.user_id,
room_user.state,
)
# If the room is in OPEN state but we have users in RESULTS state,
# this means the game just finished and we should send ResultsReady
if room.room.state == MultiplayerRoomState.OPEN and any(
u.state == MultiplayerUserState.RESULTS for u in room.room.users
):
logger.debug(f"[MultiplayerHub] Sending ResultsReady to new spectator {client.user_id}")
await self.call_noblock(client, "ResultsReady")
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 fix: If room is OPEN but has users in RESULTS state,
# send ResultsReady to new joiners (including spectators)
if room.room.state == MultiplayerRoomState.OPEN and any(
u.state == MultiplayerUserState.RESULTS for u in room.room.users
):
logger.info(f"[MultiplayerHub] Sending ResultsReady to newly joined user {client.user_id}")
await self.call_noblock(client, "ResultsReady")
# 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 user in the room, check their state and sync appropriately
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}"
)
# Critical addition: Notify SpectatorHub about users in RESULTS state
elif room_user.state == MultiplayerUserState.RESULTS:
# Create a synthetic finished state for cross-hub spectating
try:
from app.models.spectator_hub import (
SpectatedUserState,
SpectatorState,
)
finished_state = SpectatorState(
beatmap_id=room.queue.current_item.beatmap_id,
ruleset_id=room_user.ruleset_id or 0,
mods=room_user.mods,
state=SpectatedUserState.Passed, # Assume passed for results
maximum_statistics={},
)
await self.call_noblock(
client,
"UserFinishedPlaying",
room_user.user_id,
finished_state,
)
logger.debug(
f"[MultiplayerHub] Sent synthetic finished state for user {room_user.user_id} "
f"to client {client.user_id}"
)
except Exception as e:
logger.debug(f"[MultiplayerHub] Failed to create synthetic finished state: {e}")
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
# Handle finished players first
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)
# Critical fix: Handle spectators who should also see results
# Move spectators to RESULTS state so they can see the results screen
for u in filter(
lambda u: u.state == MultiplayerUserState.SPECTATING,
room.room.users,
):
logger.debug(f"[MultiplayerHub] Moving spectator {u.user_id} to RESULTS state")
await self.change_user_state(room, u, MultiplayerUserState.RESULTS)
await self.change_room_state(room, MultiplayerRoomState.OPEN)
# Send ResultsReady to all room members
await self.broadcast_group_call(
self.group_id(room.room.room_id),
"ResultsReady",
)
# Critical addition: Notify SpectatorHub about finished games
# This ensures cross-hub spectating works properly
await self._notify_spectator_hub_game_ended(room)
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 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,
)
# Ensure spectator hub is aware of all active players for the new game.
# This helps spectators receive score data for every participant,
# especially in subsequent rounds where state may get out of sync.
for room_user in room.room.users:
if (client := self.get_client_by_id(str(room_user.user_id))) is not None:
try:
await self._sync_with_spectator_hub(client, room)
except Exception as e:
logger.debug(
f"[MultiplayerHub] Failed to resync spectator hub for user {room_user.user_id}: {e}"
)
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 = utcnow()
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=utcnow(),
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}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} 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)
async def _notify_spectator_hub_game_ended(self, room: ServerMultiplayerRoom):
"""
Notify SpectatorHub about ended multiplayer game.
This ensures cross-hub spectating works properly when games end.
"""
try:
# Import here to avoid circular imports
from app.models.spectator_hub import SpectatedUserState, SpectatorState
from app.signalr.hub import SpectatorHubs
# For each user who finished the game, notify SpectatorHub
for room_user in room.room.users:
if room_user.state == MultiplayerUserState.RESULTS:
# Create a synthetic finished state
finished_state = SpectatorState(
beatmap_id=room.queue.current_item.beatmap_id,
ruleset_id=room_user.ruleset_id or 0,
mods=room_user.mods,
state=SpectatedUserState.Passed, # Assume passed for results
maximum_statistics={},
)
# Notify all SpectatorHub watchers that this user finished
await SpectatorHubs.broadcast_group_call(
SpectatorHubs.group_id(room_user.user_id),
"UserFinishedPlaying",
room_user.user_id,
finished_state,
)
logger.debug(f"[MultiplayerHub] Notified SpectatorHub that user {room_user.user_id} finished game")
except Exception as e:
logger.debug(f"[MultiplayerHub] Failed to notify SpectatorHub about game end: {e}")
# This is not critical, so we don't raise the exception