Files
g0v0-server/app/signalr/hub/multiplayer.py
2025-08-22 14:58:13 +08:00

2311 lines
91 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from typing import override, Dict, List, Optional, Tuple
import json
from collections import defaultdict, deque
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 .multiplayer_packet_cleaner import packet_cleaner, GameSessionCleaner
from httpx import HTTPError
from sqlalchemy import update
from sqlmodel import col, exists, select
GAMEPLAY_LOAD_TIMEOUT = 30
class GameplayStateBuffer:
"""游戏状态缓冲区,用于管理实时排行榜和观战数据同步"""
def __init__(self):
# 房间ID -> 用户分数数据缓冲区
self.score_buffers: Dict[int, Dict[int, deque]] = defaultdict(lambda: defaultdict(lambda: deque(maxlen=50)))
# 房间ID -> 实时排行榜数据
self.leaderboards: Dict[int, List[Dict]] = defaultdict(list)
# 房间ID -> 游戏状态快照
self.gameplay_snapshots: Dict[int, Dict] = {}
# 用户观战状态缓存
self.spectator_states: Dict[Tuple[int, int], Dict] = {} # (room_id, user_id) -> state
async def add_score_frame(self, room_id: int, user_id: int, frame_data: Dict):
"""添加分数帧数据到缓冲区"""
self.score_buffers[room_id][user_id].append({
**frame_data,
'timestamp': datetime.now(UTC),
'user_id': user_id
})
# 更新实时排行榜
await self._update_leaderboard(room_id)
async def _update_leaderboard(self, room_id: int):
"""更新实时排行榜"""
leaderboard = []
for user_id, frames in self.score_buffers[room_id].items():
if not frames:
continue
latest_frame = frames[-1]
leaderboard.append({
'user_id': user_id,
'score': latest_frame.get('score', 0),
'combo': latest_frame.get('combo', 0),
'accuracy': latest_frame.get('accuracy', 0.0),
'completed': latest_frame.get('completed', False),
'timestamp': latest_frame['timestamp']
})
# 按分数排序
leaderboard.sort(key=lambda x: (-x['score'], -x['accuracy']))
self.leaderboards[room_id] = leaderboard
def get_leaderboard(self, room_id: int) -> List[Dict]:
"""获取房间实时排行榜"""
return self.leaderboards.get(room_id, [])
async def create_gameplay_snapshot(self, room_id: int, room_data: Dict):
"""创建游戏状态快照用于新加入的观众"""
# 序列化复杂对象
serialized_room_data = self._serialize_room_data(room_data)
snapshot = {
'room_id': room_id,
'state': serialized_room_data.get('state'),
'current_item': serialized_room_data.get('current_item'),
'users': serialized_room_data.get('users', []),
'leaderboard': self.get_leaderboard(room_id),
'created_at': datetime.now(UTC).isoformat()
}
self.gameplay_snapshots[room_id] = snapshot
return snapshot
def _serialize_room_data(self, room_data: Dict) -> Dict:
"""序列化房间数据"""
result = {}
for key, value in room_data.items():
if hasattr(value, 'value') and hasattr(value, 'name'):
# 枚举类型
result[key] = {'name': value.name, 'value': value.value}
elif hasattr(value, '__dict__'):
# 复杂对象
if hasattr(value, 'model_dump'):
result[key] = value.model_dump()
elif hasattr(value, 'dict'):
result[key] = value.dict()
else:
# 手动序列化
obj_dict = {}
for attr_name, attr_value in value.__dict__.items():
if not attr_name.startswith('_'):
obj_dict[attr_name] = self._serialize_value(attr_value)
result[key] = obj_dict
elif isinstance(value, (list, tuple)):
result[key] = [self._serialize_value(item) for item in value]
else:
result[key] = self._serialize_value(value)
return result
def _serialize_value(self, value):
"""序列化单个值"""
if hasattr(value, 'value') and hasattr(value, 'name'):
# 枚举类型
return {'name': value.name, 'value': value.value}
elif hasattr(value, '__dict__'):
# 复杂对象
if hasattr(value, 'model_dump'):
return value.model_dump()
elif hasattr(value, 'dict'):
return value.dict()
else:
obj_dict = {}
for attr_name, attr_value in value.__dict__.items():
if not attr_name.startswith('_'):
obj_dict[attr_name] = self._serialize_value(attr_value)
return obj_dict
elif isinstance(value, (list, tuple)):
return [self._serialize_value(item) for item in value]
elif isinstance(value, dict):
return {k: self._serialize_value(v) for k, v in value.items()}
elif isinstance(value, (str, int, float, bool, type(None))):
return value
else:
return str(value)
def get_gameplay_snapshot(self, room_id: int) -> Optional[Dict]:
"""获取游戏状态快照"""
return self.gameplay_snapshots.get(room_id)
async def set_spectator_state(self, room_id: int, user_id: int, state_data: Dict):
"""设置观战者状态"""
key = (room_id, user_id)
self.spectator_states[key] = {
**state_data,
'last_updated': datetime.now(UTC)
}
def get_spectator_state(self, room_id: int, user_id: int) -> Optional[Dict]:
"""获取观战者状态"""
key = (room_id, user_id)
return self.spectator_states.get(key)
async def cleanup_room(self, room_id: int):
"""清理房间相关数据"""
self.score_buffers.pop(room_id, None)
self.leaderboards.pop(room_id, None)
self.gameplay_snapshots.pop(room_id, None)
# 清理观战者状态
keys_to_remove = [key for key in self.spectator_states.keys() if key[0] == room_id]
for key in keys_to_remove:
self.spectator_states.pop(key, None)
async def cleanup_game_session(self, room_id: int):
"""清理单局游戏会话数据(每局游戏结束后调用)"""
# 清理分数缓冲区但保留房间结构
if room_id in self.score_buffers:
self.score_buffers[room_id].clear()
# 清理实时排行榜
self.leaderboards.pop(room_id, None)
# 清理游戏状态快照
self.gameplay_snapshots.pop(room_id, None)
# 清理观战者状态但不删除房间相关键
keys_to_remove = []
for key in self.spectator_states.keys():
if key[0] == room_id:
# 保留连接状态,清理游戏数据
state = self.spectator_states[key]
if 'game_data' in state:
state.pop('game_data', None)
if 'score_data' in state:
state.pop('score_data', None)
logger.info(f"[GameplayStateBuffer] Cleaned game session data for room {room_id}")
def reset_user_gameplay_state(self, room_id: int, user_id: int):
"""重置单个用户的游戏状态"""
# 清理用户分数缓冲区
if room_id in self.score_buffers and user_id in self.score_buffers[room_id]:
self.score_buffers[room_id][user_id].clear()
# 重置观战者状态中的游戏数据
key = (room_id, user_id)
if key in self.spectator_states:
state = self.spectator_states[key]
state.pop('game_data', None)
state.pop('score_data', None)
state['last_reset'] = datetime.now(UTC)
class SpectatorSyncManager:
"""观战同步管理器处理跨Hub通信"""
def __init__(self, redis_client):
self.redis = redis_client
self.channel_prefix = "multiplayer_spectator"
def _serialize_for_json(self, obj):
"""递归序列化对象为JSON兼容格式"""
if hasattr(obj, '__dict__'):
# 如果对象有__dict__属性将其转换为字典
if hasattr(obj, 'model_dump'):
# 对于Pydantic模型
return obj.model_dump()
elif hasattr(obj, 'dict'):
# 对于较旧的Pydantic模型
return obj.dict()
else:
# 对于普通对象
result = {}
for key, value in obj.__dict__.items():
if not key.startswith('_'): # 跳过私有属性
result[key] = self._serialize_for_json(value)
return result
elif isinstance(obj, dict):
return {key: self._serialize_for_json(value) for key, value in obj.items()}
elif isinstance(obj, (list, tuple)):
return [self._serialize_for_json(item) for item in obj]
elif isinstance(obj, datetime):
# 处理datetime对象
return obj.isoformat()
elif hasattr(obj, 'value') and hasattr(obj, 'name'):
# 对于枚举类型
return {'name': obj.name, 'value': obj.value}
elif isinstance(obj, (str, int, float, bool, type(None))):
return obj
else:
# 对于其他类型,尝试转换为字符串
return str(obj)
async def notify_spectator_hubs(self, room_id: int, event_type: str, data: Dict):
"""通知观战Hub游戏状态变化"""
# 序列化复杂对象为JSON兼容格式
serialized_data = self._serialize_for_json(data)
message = {
'room_id': room_id,
'event_type': event_type,
'data': serialized_data,
'timestamp': datetime.now(UTC).isoformat()
}
channel = f"{self.channel_prefix}:room:{room_id}"
await self.redis.publish(channel, json.dumps(message))
async def notify_gameplay_started(self, room_id: int, game_data: Dict):
"""通知游戏开始"""
await self.notify_spectator_hubs(room_id, "gameplay_started", game_data)
async def notify_gameplay_ended(self, room_id: int, results_data: Dict):
"""通知游戏结束"""
await self.notify_spectator_hubs(room_id, "gameplay_ended", results_data)
async def notify_user_state_change(self, room_id: int, user_id: int, old_state: str, new_state: str):
"""通知用户状态变化"""
await self.notify_spectator_hubs(room_id, "user_state_changed", {
'user_id': user_id,
'old_state': old_state,
'new_state': new_state
})
async def subscribe_to_spectator_events(self, callback):
"""订阅观战事件"""
pattern = f"{self.channel_prefix}:*"
pubsub = self.redis.pubsub()
await pubsub.psubscribe(pattern)
async for message in pubsub.listen():
if message['type'] == 'pmessage':
try:
data = json.loads(message['data'])
await callback(message['channel'], data)
except Exception as e:
logger.error(f"Error processing spectator event: {e}")
# 全局实例
gameplay_buffer = GameplayStateBuffer()
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()
self.spectator_sync_manager: Optional[SpectatorSyncManager] = None
# 实时数据推送任务管理
self.leaderboard_tasks: Dict[int, asyncio.Task] = {}
# 观战状态同步任务
self.spectator_sync_tasks: Dict[int, asyncio.Task] = {}
# 启动定期清理任务参考osu源码的清理机制
self._cleanup_task = asyncio.create_task(self._periodic_cleanup())
async def _periodic_cleanup(self):
"""定期清理过期数据包的后台任务"""
while True:
try:
await asyncio.sleep(60) # 每分钟执行一次
await packet_cleaner.cleanup_expired_packets()
# 记录清理统计
stats = packet_cleaner.get_cleanup_stats()
if stats['pending_packets'] > 0:
logger.debug(f"[MultiplayerHub] Cleanup stats: {stats}")
except Exception as e:
logger.error(f"[MultiplayerHub] Error in periodic cleanup: {e}")
except asyncio.CancelledError:
logger.info("[MultiplayerHub] Periodic cleanup task cancelled")
break
async def initialize_managers(self):
"""初始化管理器"""
if not self.spectator_sync_manager:
redis = get_redis()
self.spectator_sync_manager = SpectatorSyncManager(redis)
# 启动观战事件监听
asyncio.create_task(self.spectator_sync_manager.subscribe_to_spectator_events(
self._handle_spectator_event
))
async def _handle_spectator_event(self, channel: str, data: Dict):
"""处理观战事件"""
try:
room_id = data.get('room_id')
event_type = data.get('event_type')
event_data = data.get('data', {})
if room_id and event_type and room_id in self.rooms:
server_room = self.rooms[room_id]
await self._process_spectator_event(server_room, event_type, event_data)
except Exception as e:
logger.error(f"Error handling spectator event: {e}")
async def _process_spectator_event(self, server_room: ServerMultiplayerRoom, event_type: str, event_data: Dict):
"""处理具体的观战事件"""
room_id = server_room.room.room_id
if event_type == "spectator_joined":
user_id = event_data.get('user_id')
if user_id:
await self._sync_spectator_with_current_state(room_id, user_id)
elif event_type == "request_leaderboard":
user_id = event_data.get('user_id')
if user_id:
leaderboard = gameplay_buffer.get_leaderboard(room_id)
await self._send_leaderboard_to_spectator(user_id, leaderboard)
async def _sync_spectator_with_current_state(self, room_id: int, user_id: int):
"""同步观战者与当前游戏状态"""
try:
snapshot = gameplay_buffer.get_gameplay_snapshot(room_id)
if snapshot:
# 通过Redis发送状态同步信息给SpectatorHub
redis = get_redis()
sync_data = {
'target_user': user_id,
'snapshot': snapshot,
'timestamp': datetime.now(UTC).isoformat()
}
await redis.publish(f"spectator_sync:{room_id}", json.dumps(sync_data))
except Exception as e:
logger.error(f"Error syncing spectator {user_id} with room {room_id}: {e}")
async def _send_leaderboard_to_spectator(self, user_id: int, leaderboard: List[Dict]):
"""发送排行榜数据给观战者"""
try:
redis = get_redis()
leaderboard_data = {
'target_user': user_id,
'leaderboard': leaderboard,
'timestamp': datetime.now(UTC).isoformat()
}
await redis.publish(f"leaderboard_update:{user_id}", json.dumps(leaderboard_data))
except Exception as e:
logger.error(f"Error sending leaderboard to spectator {user_id}: {e}")
@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 # 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}")
# 初始化管理器
await self.initialize_managers()
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)
# Enhanced: 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)
# 如果正在进行游戏,同步游戏状态
if room.state in [MultiplayerRoomState.PLAYING, MultiplayerRoomState.WAITING_FOR_LOAD]:
await self._sync_new_user_with_gameplay(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}")
# 通知观战Hub有新用户加入
if self.spectator_sync_manager:
await self.spectator_sync_manager.notify_spectator_hubs(
room_id, "user_joined", {'user_id': user.user_id}
)
return room
async def _sync_new_user_with_gameplay(self, client: Client, room: ServerMultiplayerRoom):
"""同步新用户与正在进行的游戏状态"""
try:
room_id = room.room.room_id
# 获取游戏状态快照
snapshot = gameplay_buffer.get_gameplay_snapshot(room_id)
if not snapshot:
# 创建新的快照
room_data = {
'state': room.room.state,
'current_item': room.queue.current_item,
'users': [{'user_id': u.user_id, 'state': u.state} for u in room.room.users]
}
snapshot = await gameplay_buffer.create_gameplay_snapshot(room_id, room_data)
# 发送游戏状态到新用户
await self.broadcast_call(client.connection_id, "GameplayStateSync", snapshot)
# 发送实时排行榜
leaderboard = gameplay_buffer.get_leaderboard(room_id)
if leaderboard:
await self.broadcast_call(client.connection_id, "LeaderboardUpdate", leaderboard)
logger.info(f"[MultiplayerHub] Synced gameplay state for user {client.user_id} in room {room_id}")
except Exception as e:
logger.error(f"Error syncing new user with gameplay: {e}")
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:
# 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
# 记录状态变化用于观战同步
old_state = user.state
# Special handling for state changes during gameplay
match state:
case MultiplayerUserState.IDLE:
if user.state.is_playing:
# 玩家退出游戏时,清理分数缓冲区
room_id = room.room_id
if room_id in gameplay_buffer.score_buffers:
gameplay_buffer.score_buffers[room_id].pop(user.user_id, None)
await gameplay_buffer._update_leaderboard(room_id)
await self._broadcast_leaderboard_update(server_room)
return
case MultiplayerUserState.LOADED | MultiplayerUserState.READY_FOR_GAMEPLAY:
if not user.state.is_playing:
return
case MultiplayerUserState.PLAYING:
# 开始游戏时初始化分数缓冲区
room_id = room.room_id
await gameplay_buffer.add_score_frame(room_id, user.user_id, {
'score': 0,
'combo': 0,
'accuracy': 100.0,
'completed': False
})
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)
# 通知观战Hub状态变化
if self.spectator_sync_manager:
await self.spectator_sync_manager.notify_user_state_change(
room.room_id, user.user_id, old_state.name, state.name
)
await self.update_room_state(server_room)
async def _broadcast_leaderboard_update(self, room: ServerMultiplayerRoom):
"""广播实时排行榜更新"""
try:
room_id = room.room.room_id
leaderboard = gameplay_buffer.get_leaderboard(room_id)
if leaderboard:
await self.broadcast_group_call(
self.group_id(room_id),
"LeaderboardUpdate",
leaderboard
)
logger.debug(f"[MultiplayerHub] Broadcasted leaderboard update to room {room_id}")
except Exception as e:
logger.error(f"Error broadcasting leaderboard update: {e}")
async def _start_leaderboard_broadcast_task(self, room_id: int):
"""启动实时排行榜广播任务"""
if room_id in self.leaderboard_tasks:
return
async def leaderboard_broadcast_loop():
try:
while room_id in self.rooms and room_id in self.leaderboard_tasks:
if room_id in self.rooms:
server_room = self.rooms[room_id]
if server_room.room.state == MultiplayerRoomState.PLAYING:
await self._broadcast_leaderboard_update(server_room)
await asyncio.sleep(1.0) # 每秒更新一次排行榜
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Error in leaderboard broadcast loop for room {room_id}: {e}")
task = asyncio.create_task(leaderboard_broadcast_loop())
self.leaderboard_tasks[room_id] = task
async def _stop_leaderboard_broadcast_task(self, room_id: int):
"""停止实时排行榜广播任务"""
if room_id in self.leaderboard_tasks:
task = self.leaderboard_tasks.pop(room_id)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def _cleanup_game_session(self, room_id: int, game_completed: bool):
"""清理单局游戏会话数据基于osu源码实现"""
try:
# 停止实时排行榜广播
await self._stop_leaderboard_broadcast_task(room_id)
# 获取最终排行榜
final_leaderboard = gameplay_buffer.get_leaderboard(room_id)
# 发送最终排行榜给所有用户
if final_leaderboard:
await self.broadcast_group_call(
self.group_id(room_id),
"FinalLeaderboard",
final_leaderboard
)
# 使用新的清理管理器清理游戏会话参考osu源码
await GameSessionCleaner.cleanup_game_session(room_id, game_completed)
# 清理游戏会话数据
await gameplay_buffer.cleanup_game_session(room_id)
# 通知观战同步管理器游戏结束
if hasattr(self, 'spectator_sync_manager') and self.spectator_sync_manager:
await self.spectator_sync_manager.notify_gameplay_ended(room_id, {
'final_leaderboard': final_leaderboard,
'completed': game_completed,
'timestamp': datetime.now(UTC).isoformat()
})
# 重置所有用户的游戏状态
if room_id in self.rooms:
room = self.rooms[room_id]
for user in room.room.users:
gameplay_buffer.reset_user_gameplay_state(room_id, user.user_id)
# 安排用户会话清理
await GameSessionCleaner.cleanup_user_session(room_id, user.user_id)
logger.info(f"[MultiplayerHub] Cleaned up game session for room {room_id} (completed: {game_completed})")
except Exception as e:
logger.error(f"[MultiplayerHub] Failed to cleanup game session for room {room_id}: {e}")
# 即使清理失败也不应该影响游戏流程
async def change_user_state(
self,
room: ServerMultiplayerRoom,
user: MultiplayerRoomUser,
state: MultiplayerUserState,
):
old_state = user.state
logger.info(
f"[MultiplayerHub] {user.user_id}'s state "
f"changed from {old_state} to {state}"
)
user.state = state
# 在用户进入RESULTS状态时清理其游戏数据参考osu源码
if state == MultiplayerUserState.RESULTS and old_state.is_playing:
room_id = room.room.room_id
gameplay_buffer.reset_user_gameplay_state(room_id, user.user_id)
logger.debug(f"[MultiplayerHub] Reset gameplay state for user {user.user_id} in room {room_id}")
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)
# 每局游戏结束后的清理工作
room_id = room.room.room_id
await self._cleanup_game_session(room_id, any_user_finished_playing)
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
):
old_state = room.room.state
room_id = room.room.room_id
logger.debug(
f"[MultiplayerHub] Room {room_id} state "
f"changed from {old_state} to {state}"
)
room.room.state = state
await self.broadcast_group_call(
self.group_id(room_id),
"RoomStateChanged",
state,
)
# 处理状态变化的特殊逻辑
if old_state == MultiplayerRoomState.PLAYING and state == MultiplayerRoomState.OPEN:
# 游戏结束,停止实时排行榜广播
await self._stop_leaderboard_broadcast_task(room_id)
# 发送最终排行榜
leaderboard = gameplay_buffer.get_leaderboard(room_id)
if leaderboard:
await self.broadcast_group_call(
self.group_id(room_id),
"FinalLeaderboard",
leaderboard
)
# 通知观战Hub游戏结束
if self.spectator_sync_manager:
await self.spectator_sync_manager.notify_gameplay_ended(room_id, {
'leaderboard': leaderboard
})
elif state == MultiplayerRoomState.PLAYING:
# 游戏开始,启动实时排行榜
await self._start_leaderboard_broadcast_task(room_id)
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
room_id = room.room.room_id
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")
# 初始化玩家分数缓冲区
await gameplay_buffer.add_score_frame(room_id, user.user_id, {
'score': 0,
'combo': 0,
'accuracy': 100.0,
'completed': False
})
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:
# 创建游戏状态快照
room_data = {
'state': room.room.state,
'current_item': room.queue.current_item,
'users': [{'user_id': u.user_id, 'state': u.state} for u in room.room.users]
}
await gameplay_buffer.create_gameplay_snapshot(room_id, room_data)
# 启动实时排行榜广播
await self._start_leaderboard_broadcast_task(room_id)
# 通知观战Hub游戏开始
if self.spectator_sync_manager:
await self.spectator_sync_manager.notify_gameplay_started(room_id, room_data)
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,
):
room_id = room.room.room_id
user_id = user.user_id
if client:
self.remove_from_group(client, self.group_id(room_id))
room.room.users.remove(user)
target_store = self.state.get(user_id)
if target_store:
target_store.room_id = 0
# 清理用户的游戏状态数据参考osu源码
gameplay_buffer.reset_user_gameplay_state(room_id, user_id)
# 使用清理管理器安排用户会话清理
await GameSessionCleaner.cleanup_user_session(room_id, user_id)
redis = get_redis()
await redis.publish("chat:room:left", f"{room.room.channel_id}:{user_id}")
async with with_db() as session:
async with session.begin():
participated_user = (
await session.exec(
select(RoomParticipatedUser).where(
RoomParticipatedUser.room_id == room_id,
RoomParticipatedUser.user_id == user_id,
)
)
).first()
if participated_user is not None:
participated_user.left_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")
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_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_id), "UserKicked", user
)
else:
await self.broadcast_group_call(
self.group_id(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,
)
room_id = room.room.room_id
# 清理实时数据
await self._stop_leaderboard_broadcast_task(room_id)
await gameplay_buffer.cleanup_room(room_id)
# 使用清理管理器完全清理房间参考osu源码
await GameSessionCleaner.cleanup_room_fully(room_id)
# 清理观战同步任务
if room_id in self.spectator_sync_tasks:
task = self.spectator_sync_tasks.pop(room_id)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
del self.rooms[room_id]
logger.info(f"[MultiplayerHub] Room {room_id} ended")
async def UpdateScore(self, client: Client, score_data: Dict):
"""接收并处理实时分数更新"""
try:
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("User not found in room")
if room.state != MultiplayerRoomState.PLAYING:
return
if user.state != MultiplayerUserState.PLAYING:
return
room_id = room.room_id
# 添加分数帧到缓冲区
await gameplay_buffer.add_score_frame(room_id, client.user_id, {
'score': score_data.get('score', 0),
'combo': score_data.get('combo', 0),
'accuracy': score_data.get('accuracy', 0.0),
'completed': score_data.get('completed', False),
'hp': score_data.get('hp', 1.0),
'position': score_data.get('position', 0)
})
# 安排分数数据包清理参考osu源码的数据包管理
await packet_cleaner.schedule_cleanup(room_id, {
'type': 'score',
'user_id': client.user_id,
'data_size': len(str(score_data)),
'timestamp': datetime.now(UTC).isoformat()
})
# 如果游戏完成,标记用户状态
if score_data.get('completed', False):
await self.change_user_state(
server_room, user, MultiplayerUserState.FINISHED_PLAY
)
# 立即安排该用户的清理
await GameSessionCleaner.cleanup_user_session(room_id, client.user_id)
except Exception as e:
logger.error(f"Error updating score for user {client.user_id}: {e}")
async def GetLeaderboard(self, client: Client) -> List[Dict]:
"""获取当前房间的实时排行榜"""
try:
server_room = self._ensure_in_room(client)
room_id = server_room.room.room_id
return gameplay_buffer.get_leaderboard(room_id)
except Exception as e:
logger.error(f"Error getting leaderboard for user {client.user_id}: {e}")
return []
async def RequestSpectatorSync(self, client: Client):
"""观战者请求同步当前游戏状态"""
try:
server_room = self._ensure_in_room(client)
room_id = server_room.room.room_id
# 发送游戏状态快照
snapshot = gameplay_buffer.get_gameplay_snapshot(room_id)
if snapshot:
await self.broadcast_call(client.connection_id, "GameplayStateSync", snapshot)
# 发送当前排行榜
leaderboard = gameplay_buffer.get_leaderboard(room_id)
if leaderboard:
await self.broadcast_call(client.connection_id, "LeaderboardUpdate", leaderboard)
logger.info(f"[MultiplayerHub] Sent spectator sync to user {client.user_id}")
except Exception as e:
logger.error(f"Error handling spectator sync request: {e}")
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")
# 清理用户游戏数据参考osu源码
room_id = room.room_id
gameplay_buffer.reset_user_gameplay_state(room_id, user.user_id)
await self.change_user_state(
server_room,
user,
MultiplayerUserState.IDLE,
)
await self.update_room_state(server_room)
logger.info(f"[MultiplayerHub] User {user.user_id} aborted gameplay in room {room_id}")
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.")
room_id = room.room_id
# 清理所有玩家的游戏状态数据参考osu源码
for user in room.users:
if user.state.is_playing:
gameplay_buffer.reset_user_gameplay_state(room_id, user.user_id)
await asyncio.gather(
*[
self.change_user_state(server_room, u, MultiplayerUserState.IDLE)
for u in room.users
if u.state.is_playing
]
)
# 执行完整的游戏会话清理
await self._cleanup_game_session(room_id, False) # False表示游戏被中断而非完成
await self.broadcast_group_call(
self.group_id(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_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.signalr.hub import SpectatorHubs
from app.models.spectator_hub import SpectatedUserState, SpectatorState
from .spectator_buffer import spectator_state_manager
room_id = room.room.room_id
# 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={},
)
# 同步到观战缓冲区管理器
await spectator_state_manager.handle_user_finished_playing(
room_user.user_id,
finished_state
)
# 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] Synced and notified SpectatorHub that user {room_user.user_id} finished game"
)
# 同步游戏中玩家的状态
elif room_user.state == MultiplayerUserState.PLAYING:
try:
multiplayer_data = {
'room_id': room_id,
'beatmap_id': room.queue.current_item.beatmap_id,
'ruleset_id': room_user.ruleset_id or 0,
'mods': room_user.mods,
'state': room_user.state,
'maximum_statistics': {}
}
await spectator_state_manager.sync_with_multiplayer(
room_user.user_id,
multiplayer_data
)
logger.debug(
f"[MultiplayerHub] Synced playing state for user {room_user.user_id} to SpectatorHub buffer"
)
except Exception as e:
logger.debug(
f"[MultiplayerHub] Failed to sync playing state for user {room_user.user_id}: {e}"
)
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