feat(multiplayer): support change settings
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user