diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 9c30523..97148db 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -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: diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 1a2b332..1a27497 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -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)