2311 lines
91 KiB
Python
2311 lines
91 KiB
Python
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
|