修复多人游戏排行榜问题

This commit is contained in:
咕谷酱
2025-08-22 13:52:28 +08:00
parent 6136b9fed3
commit b300ce9b09
13 changed files with 1008 additions and 324 deletions

View File

@@ -2,7 +2,9 @@ from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from typing import override
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
@@ -54,6 +56,152 @@ 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):
"""创建游戏状态快照用于新加入的观众"""
snapshot = {
'room_id': room_id,
'state': room_data.get('state'),
'current_item': room_data.get('current_item'),
'users': room_data.get('users', []),
'leaderboard': self.get_leaderboard(room_id),
'created_at': datetime.now(UTC)
}
self.gameplay_snapshots[room_id] = snapshot
return snapshot
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)
class SpectatorSyncManager:
"""观战同步管理器处理跨Hub通信"""
def __init__(self, redis_client):
self.redis = redis_client
self.channel_prefix = "multiplayer_spectator"
async def notify_spectator_hubs(self, room_id: int, event_type: str, data: Dict):
"""通知观战Hub游戏状态变化"""
message = {
'room_id': room_id,
'event_type': event_type,
'data': 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
@@ -148,6 +296,79 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
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] = {}
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:
@@ -271,6 +492,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
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")
@@ -293,9 +518,13 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
self.add_to_group(client, self.group_id(room_id))
await server_room.match_type_handler.handle_join(user)
# Critical fix: Send current room and gameplay state to new user
# 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)
@@ -327,6 +556,42 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
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
@@ -704,14 +969,32 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
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}"
@@ -729,8 +1012,63 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
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 change_user_state(
self,
room: ServerMultiplayerRoom,
@@ -1017,16 +1355,44 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
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.room.room_id} state "
f"changed from {room.room.state} to {state}"
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.room.room_id),
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)
@@ -1099,6 +1465,8 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
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:
@@ -1112,6 +1480,15 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
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(
@@ -1119,11 +1496,28 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
"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",
@@ -1224,8 +1618,86 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
room.room.room_id,
room.room.host.user_id,
)
del self.rooms[room.room.room_id]
logger.info(f"[MultiplayerHub] Room {room.room.room_id} ended")
room_id = room.room.room_id
# 清理实时数据
await self._stop_leaderboard_broadcast_task(room_id)
await gameplay_buffer.cleanup_room(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)
})
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)