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 abc import ABC, abstractmethod
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
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.dependencies.database import engine
@@ -291,7 +292,9 @@ class MultiplayerQueue:
current_set.append(items[i])
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)
first_set_order_by_user_id = {
item.owner_id: idx
@@ -308,7 +311,7 @@ class MultiplayerQueue:
is_first_set = False
for idx, item in enumerate(ordered_active_items):
item.order = idx
item.playlist_order = idx
case _:
ordered_active_items = sorted(
(item for item in self.room.playlist if not item.expired),
@@ -487,6 +490,15 @@ class MultiplayerQueue:
assert 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
def current_item(self):
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
class ServerMultiplayerRoom:
room: MultiplayerRoom
@@ -514,10 +645,35 @@ class ServerMultiplayerRoom:
status: RoomStatus
start_at: datetime
hub: "MultiplayerHub"
queue: MultiplayerQueue | None = None
_next_countdown_id: int = 0
_countdown_id_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_tracked_countdown: dict[int, CountdownInfo] = field(default_factory=dict)
match_type_handler: MatchTypeHandler
queue: MultiplayerQueue
_next_countdown_id: int
_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 with self._countdown_id_lock:

View File

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