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