feat(multiplayer): support change settings

This commit is contained in:
MingxuanGame
2025-08-03 15:14:30 +00:00
parent 1e304542bd
commit 34bf2c6b32
2 changed files with 240 additions and 24 deletions

View File

@@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from enum import IntEnum from enum import IntEnum
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, override
from app.database.beatmap import Beatmap from app.database.beatmap import Beatmap
from app.dependencies.database import engine from app.dependencies.database import engine
@@ -291,7 +292,9 @@ class MultiplayerQueue:
current_set.append(items[i]) current_set.append(items[i])
if is_first_set: if is_first_set:
current_set.sort(key=lambda item: (item.order, item.id)) current_set.sort(
key=lambda item: (item.playlist_order, item.id)
)
ordered_active_items.extend(current_set) ordered_active_items.extend(current_set)
first_set_order_by_user_id = { first_set_order_by_user_id = {
item.owner_id: idx item.owner_id: idx
@@ -308,7 +311,7 @@ class MultiplayerQueue:
is_first_set = False is_first_set = False
for idx, item in enumerate(ordered_active_items): for idx, item in enumerate(ordered_active_items):
item.order = idx item.playlist_order = idx
case _: case _:
ordered_active_items = sorted( ordered_active_items = sorted(
(item for item in self.room.playlist if not item.expired), (item for item in self.room.playlist if not item.expired),
@@ -487,6 +490,15 @@ class MultiplayerQueue:
assert self.room.host assert self.room.host
await self.add_item(self.current_item.clone(), self.room.host) await self.add_item(self.current_item.clone(), self.room.host)
async def update_queue_mode(self):
if self.room.settings.queue_mode == QueueMode.HOST_ONLY and all(
playitem.expired for playitem in self.room.playlist
):
assert self.room.host
await self.add_item(self.current_item.clone(), self.room.host)
await self.update_order()
await self.update_current_item()
@property @property
def current_item(self): def current_item(self):
return self.room.playlist[self.current_index] return self.room.playlist[self.current_index]
@@ -507,6 +519,125 @@ class CountdownInfo:
) )
class _MatchRequest(SignalRUnionMessage): ...
class ChangeTeamRequest(_MatchRequest):
union_type: ClassVar[Literal[0]] = 0
team_id: int
class StartMatchCountdownRequest(_MatchRequest):
union_type: ClassVar[Literal[1]] = 1
duration: timedelta
class StopCountdownRequest(_MatchRequest):
union_type: ClassVar[Literal[2]] = 2
id: int
MatchRequest = ChangeTeamRequest | StartMatchCountdownRequest | StopCountdownRequest
class MatchTypeHandler(ABC):
def __init__(self, room: "ServerMultiplayerRoom"):
self.room = room
self.hub = room.hub
@abstractmethod
async def handle_join(self, user: MultiplayerRoomUser): ...
@abstractmethod
async def handle_request(
self, user: MultiplayerRoomUser, request: MatchRequest
): ...
@abstractmethod
async def handle_leave(self, user: MultiplayerRoomUser): ...
class HeadToHeadHandler(MatchTypeHandler):
@override
async def handle_join(self, user: MultiplayerRoomUser):
if user.match_state is not None:
user.match_state = None
await self.hub.change_user_match_state(self.room, user)
@override
async def handle_request(
self, user: MultiplayerRoomUser, request: MatchRequest
): ...
@override
async def handle_leave(self, user: MultiplayerRoomUser): ...
class TeamVersusHandler(MatchTypeHandler):
@override
def __init__(self, room: "ServerMultiplayerRoom"):
super().__init__(room)
self.state = TeamVersusRoomState()
room.room.match_state = self.state
task = asyncio.create_task(self.hub.change_room_match_state(self.room))
self.hub.tasks.add(task)
task.add_done_callback(self.hub.tasks.discard)
def _get_best_available_team(self) -> int:
for team in self.state.teams:
if all(
(
user.match_state is None
or not isinstance(user.match_state, TeamVersusUserState)
or user.match_state.team_id != team.id
)
for user in self.room.room.users
):
return team.id
from collections import defaultdict
team_counts = defaultdict(int)
for user in self.room.room.users:
if user.match_state is not None and isinstance(
user.match_state, TeamVersusUserState
):
team_counts[user.match_state.team_id] += 1
if team_counts:
min_count = min(team_counts.values())
for team_id, count in team_counts.items():
if count == min_count:
return team_id
return self.state.teams[0].id if self.state.teams else 0
@override
async def handle_join(self, user: MultiplayerRoomUser):
best_team_id = self._get_best_available_team()
user.match_state = TeamVersusUserState(team_id=best_team_id)
await self.hub.change_user_match_state(self.room, user)
@override
async def handle_request(self, user: MultiplayerRoomUser, request: MatchRequest):
if not isinstance(request, ChangeTeamRequest):
return
if request.team_id not in [team.id for team in self.state.teams]:
raise InvokeException("Invalid team ID")
user.match_state = TeamVersusUserState(team_id=request.team_id)
await self.hub.change_user_match_state(self.room, user)
@override
async def handle_leave(self, user: MultiplayerRoomUser): ...
MATCH_TYPE_HANDLERS = {
MatchType.HEAD_TO_HEAD: HeadToHeadHandler,
MatchType.TEAM_VERSUS: TeamVersusHandler,
}
@dataclass @dataclass
class ServerMultiplayerRoom: class ServerMultiplayerRoom:
room: MultiplayerRoom room: MultiplayerRoom
@@ -514,10 +645,35 @@ class ServerMultiplayerRoom:
status: RoomStatus status: RoomStatus
start_at: datetime start_at: datetime
hub: "MultiplayerHub" hub: "MultiplayerHub"
queue: MultiplayerQueue | None = None match_type_handler: MatchTypeHandler
_next_countdown_id: int = 0 queue: MultiplayerQueue
_countdown_id_lock: asyncio.Lock = field(default_factory=asyncio.Lock) _next_countdown_id: int
_tracked_countdown: dict[int, CountdownInfo] = field(default_factory=dict) _countdown_id_lock: asyncio.Lock
_tracked_countdown: dict[int, CountdownInfo]
def __init__(
self,
room: MultiplayerRoom,
category: RoomCategory,
start_at: datetime,
hub: "MultiplayerHub",
):
self.room = room
self.category = category
self.status = RoomStatus.IDLE
self.start_at = start_at
self.hub = hub
self.queue = MultiplayerQueue(self)
self._next_countdown_id = 0
self._countdown_id_lock = asyncio.Lock()
self._tracked_countdown = {}
async def set_handler(self):
self.match_type_handler = MATCH_TYPE_HANDLERS[self.room.settings.match_type](
self
)
for i in self.room.users:
await self.match_type_handler.handle_join(i)
async def get_next_countdown_id(self) -> int: async def get_next_countdown_id(self) -> int:
async with self._countdown_id_lock: async with self._countdown_id_lock:

View File

@@ -15,16 +15,20 @@ from app.models.multiplayer_hub import (
BeatmapAvailability, BeatmapAvailability,
ForceGameplayStartCountdown, ForceGameplayStartCountdown,
GameplayAbortReason, GameplayAbortReason,
MatchRequest,
MatchServerEvent, MatchServerEvent,
MultiplayerClientState, MultiplayerClientState,
MultiplayerQueue,
MultiplayerRoom, MultiplayerRoom,
MultiplayerRoomSettings,
MultiplayerRoomUser, MultiplayerRoomUser,
PlaylistItem, PlaylistItem,
ServerMultiplayerRoom, ServerMultiplayerRoom,
StartMatchCountdownRequest,
StopCountdownRequest,
) )
from app.models.room import ( from app.models.room import (
DownloadState, DownloadState,
MatchType,
MultiplayerRoomState, MultiplayerRoomState,
MultiplayerUserState, MultiplayerUserState,
RoomCategory, RoomCategory,
@@ -88,15 +92,11 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
server_room = ServerMultiplayerRoom( server_room = ServerMultiplayerRoom(
room=room, room=room,
category=RoomCategory.NORMAL, category=RoomCategory.NORMAL,
status=RoomStatus.IDLE,
start_at=starts_at, start_at=starts_at,
hub=self, hub=self,
) )
queue = MultiplayerQueue(
room=server_room,
)
server_room.queue = queue
self.rooms[room.room_id] = server_room self.rooms[room.room_id] = server_room
await server_room.set_handler()
return await self.JoinRoomWithPassword( return await self.JoinRoomWithPassword(
client, room.room_id, room.settings.password client, room.room_id, room.settings.password
) )
@@ -126,6 +126,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
await self.broadcast_group_call(self.group_id(room_id), "UserJoined", user) await self.broadcast_group_call(self.group_id(room_id), "UserJoined", user)
room.users.append(user) room.users.append(user)
self.add_to_group(client, self.group_id(room_id)) self.add_to_group(client, self.group_id(room_id))
await server_room.match_type_handler.handle_join(user)
return room return room
async def ChangeBeatmapAvailability( async def ChangeBeatmapAvailability(
@@ -164,7 +165,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
raise InvokeException("Room does not exist") raise InvokeException("Room does not exist")
server_room = self.rooms[store.room_id] server_room = self.rooms[store.room_id]
room = server_room.room room = server_room.room
assert server_room.queue
user = next((u for u in room.users if u.user_id == client.user_id), None) user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None: if user is None:
raise InvokeException("You are not in this room") raise InvokeException("You are not in this room")
@@ -182,7 +183,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
raise InvokeException("Room does not exist") raise InvokeException("Room does not exist")
server_room = self.rooms[store.room_id] server_room = self.rooms[store.room_id]
room = server_room.room room = server_room.room
assert server_room.queue
user = next((u for u in room.users if u.user_id == client.user_id), None) user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None: if user is None:
raise InvokeException("You are not in this room") raise InvokeException("You are not in this room")
@@ -200,7 +201,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
raise InvokeException("Room does not exist") raise InvokeException("Room does not exist")
server_room = self.rooms[store.room_id] server_room = self.rooms[store.room_id]
room = server_room.room room = server_room.room
assert server_room.queue
user = next((u for u in room.users if u.user_id == client.user_id), None) user = next((u for u in room.users if u.user_id == client.user_id), None)
if user is None: if user is None:
raise InvokeException("You are not in this room") raise InvokeException("You are not in this room")
@@ -262,7 +263,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
) )
async def validate_styles(self, room: ServerMultiplayerRoom): async def validate_styles(self, room: ServerMultiplayerRoom):
assert room.queue
if not room.queue.current_item.freestyle: if not room.queue.current_item.freestyle:
for user in room.room.users: for user in room.room.users:
await self.change_user_style( await self.change_user_style(
@@ -323,7 +323,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
return return
if beatmap_id is not None or ruleset_id is not None: if beatmap_id is not None or ruleset_id is not None:
assert room.queue
if not room.queue.current_item.freestyle: if not room.queue.current_item.freestyle:
raise InvokeException("Current item does not allow free user styles.") raise InvokeException("Current item does not allow free user styles.")
@@ -388,7 +387,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
room: ServerMultiplayerRoom, room: ServerMultiplayerRoom,
user: MultiplayerRoomUser, user: MultiplayerRoomUser,
): ):
assert room.queue
is_valid, valid_mods = room.queue.current_item.validate_user_mods( is_valid, valid_mods = room.queue.current_item.validate_user_mods(
user, new_mods user, new_mods
) )
@@ -418,7 +416,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
old: MultiplayerUserState, old: MultiplayerUserState,
new: MultiplayerUserState, new: MultiplayerUserState,
): ):
assert room.queue
match new: match new:
case MultiplayerUserState.IDLE: case MultiplayerUserState.IDLE:
if old.is_playing: if old.is_playing:
@@ -515,7 +512,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
if played_count == ready_count: if played_count == ready_count:
await self.start_gameplay(room) await self.start_gameplay(room)
case MultiplayerRoomState.PLAYING: case MultiplayerRoomState.PLAYING:
assert room.queue
if all( if all(
u.state != MultiplayerUserState.PLAYING for u in room.room.users u.state != MultiplayerUserState.PLAYING for u in room.room.users
): ):
@@ -562,7 +558,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
await self.start_match(server_room) await self.start_match(server_room)
async def start_match(self, room: ServerMultiplayerRoom): async def start_match(self, room: ServerMultiplayerRoom):
assert room.queue
if room.room.state != MultiplayerRoomState.OPEN: if room.room.state != MultiplayerRoomState.OPEN:
raise InvokeException("Can't start match when already in a running state.") raise InvokeException("Can't start match when already in a running state.")
if room.queue.current_item.expired: if room.queue.current_item.expired:
@@ -598,7 +593,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
) )
async def start_gameplay(self, room: ServerMultiplayerRoom): async def start_gameplay(self, room: ServerMultiplayerRoom):
assert room.queue
if room.room.state != MultiplayerRoomState.WAITING_FOR_LOAD: if room.room.state != MultiplayerRoomState.WAITING_FOR_LOAD:
raise InvokeException("Room is not ready for gameplay") raise InvokeException("Room is not ready for gameplay")
if room.queue.current_item.expired: if room.queue.current_item.expired:
@@ -801,3 +795,69 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
GameplayAbortReason.HOST_ABORTED, GameplayAbortReason.HOST_ABORTED,
) )
await self.update_room_state(server_room) await self.update_room_state(server_room)
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):
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]
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")
if room.state != MultiplayerRoomState.OPEN:
raise InvokeException("Cannot change settings while playing")
if settings.match_type == MatchType.PLAYLISTS:
raise InvokeException("Invalid match type selected")
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):
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]
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):
# TODO: countdown
...
elif isinstance(request, StopCountdownRequest):
...
else:
await server_room.match_type_handler.handle_request(user, request)