feat(multiplayer): complete validation
This commit is contained in:
@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
|
|||||||
from dataclasses import dataclass
|
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, override
|
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, cast, 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
|
||||||
@@ -107,6 +107,97 @@ class PlaylistItem(BaseModel):
|
|||||||
star_rating: float
|
star_rating: float
|
||||||
freestyle: bool
|
freestyle: bool
|
||||||
|
|
||||||
|
def _get_api_mods(self):
|
||||||
|
from app.models.mods import API_MODS, init_mods
|
||||||
|
|
||||||
|
if not API_MODS:
|
||||||
|
init_mods()
|
||||||
|
return API_MODS
|
||||||
|
|
||||||
|
def _validate_mod_for_ruleset(
|
||||||
|
self, mod: APIMod, ruleset_key: int, context: str = "mod"
|
||||||
|
) -> None:
|
||||||
|
from typing import Literal, cast
|
||||||
|
|
||||||
|
API_MODS = self._get_api_mods()
|
||||||
|
typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key)
|
||||||
|
|
||||||
|
# Check if mod is valid for ruleset
|
||||||
|
if (
|
||||||
|
typed_ruleset_key not in API_MODS
|
||||||
|
or mod["acronym"] not in API_MODS[typed_ruleset_key]
|
||||||
|
):
|
||||||
|
raise InvokeException(
|
||||||
|
f"{context} {mod['acronym']} is invalid for this ruleset"
|
||||||
|
)
|
||||||
|
|
||||||
|
mod_settings = API_MODS[typed_ruleset_key][mod["acronym"]]
|
||||||
|
|
||||||
|
# Check if mod is unplayable in multiplayer
|
||||||
|
if mod_settings.get("UserPlayable", True) is False:
|
||||||
|
raise InvokeException(
|
||||||
|
f"{context} {mod['acronym']} is not playable by users"
|
||||||
|
)
|
||||||
|
|
||||||
|
if mod_settings.get("ValidForMultiplayer", True) is False:
|
||||||
|
raise InvokeException(
|
||||||
|
f"{context} {mod['acronym']} is not valid for multiplayer"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_mod_compatibility(self, mods: list[APIMod], ruleset_key: int) -> None:
|
||||||
|
from typing import Literal, cast
|
||||||
|
|
||||||
|
API_MODS = self._get_api_mods()
|
||||||
|
typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key)
|
||||||
|
|
||||||
|
for i, mod1 in enumerate(mods):
|
||||||
|
mod1_settings = API_MODS[typed_ruleset_key].get(mod1["acronym"])
|
||||||
|
if mod1_settings:
|
||||||
|
incompatible = set(mod1_settings.get("IncompatibleMods", []))
|
||||||
|
for mod2 in mods[i + 1 :]:
|
||||||
|
if mod2["acronym"] in incompatible:
|
||||||
|
raise InvokeException(
|
||||||
|
f"Mods {mod1['acronym']} and "
|
||||||
|
f"{mod2['acronym']} are incompatible"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_required_allowed_compatibility(self, ruleset_key: int) -> None:
|
||||||
|
from typing import Literal, cast
|
||||||
|
|
||||||
|
API_MODS = self._get_api_mods()
|
||||||
|
typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key)
|
||||||
|
allowed_acronyms = {mod["acronym"] for mod in self.allowed_mods}
|
||||||
|
|
||||||
|
for req_mod in self.required_mods:
|
||||||
|
req_acronym = req_mod["acronym"]
|
||||||
|
req_settings = API_MODS[typed_ruleset_key].get(req_acronym)
|
||||||
|
if req_settings:
|
||||||
|
incompatible = set(req_settings.get("IncompatibleMods", []))
|
||||||
|
conflicting_allowed = allowed_acronyms & incompatible
|
||||||
|
if conflicting_allowed:
|
||||||
|
conflict_list = ", ".join(conflicting_allowed)
|
||||||
|
raise InvokeException(
|
||||||
|
f"Required mod {req_acronym} conflicts with "
|
||||||
|
f"allowed mods: {conflict_list}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_playlist_item_mods(self) -> None:
|
||||||
|
ruleset_key = cast(Literal[0, 1, 2, 3], self.ruleset_id)
|
||||||
|
|
||||||
|
# Validate required mods
|
||||||
|
for mod in self.required_mods:
|
||||||
|
self._validate_mod_for_ruleset(mod, ruleset_key, "Required mod")
|
||||||
|
|
||||||
|
# Validate allowed mods
|
||||||
|
for mod in self.allowed_mods:
|
||||||
|
self._validate_mod_for_ruleset(mod, ruleset_key, "Allowed mod")
|
||||||
|
|
||||||
|
# Check internal compatibility of required mods
|
||||||
|
self._check_mod_compatibility(self.required_mods, ruleset_key)
|
||||||
|
|
||||||
|
# Check compatibility between required and allowed mods
|
||||||
|
self._check_required_allowed_compatibility(ruleset_key)
|
||||||
|
|
||||||
def validate_user_mods(
|
def validate_user_mods(
|
||||||
self,
|
self,
|
||||||
user: "MultiplayerRoomUser",
|
user: "MultiplayerRoomUser",
|
||||||
@@ -118,10 +209,7 @@ class PlaylistItem(BaseModel):
|
|||||||
"""
|
"""
|
||||||
from typing import Literal, cast
|
from typing import Literal, cast
|
||||||
|
|
||||||
from app.models.mods import API_MODS, init_mods
|
API_MODS = self._get_api_mods()
|
||||||
|
|
||||||
if not API_MODS:
|
|
||||||
init_mods()
|
|
||||||
|
|
||||||
ruleset_id = user.ruleset_id if user.ruleset_id is not None else self.ruleset_id
|
ruleset_id = user.ruleset_id if user.ruleset_id is not None else self.ruleset_id
|
||||||
ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_id)
|
ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_id)
|
||||||
@@ -367,7 +455,8 @@ class MultiplayerQueue:
|
|||||||
raise InvokeException("Beatmap not found")
|
raise InvokeException("Beatmap not found")
|
||||||
if item.beatmap_checksum != beatmap.checksum:
|
if item.beatmap_checksum != beatmap.checksum:
|
||||||
raise InvokeException("Checksum mismatch")
|
raise InvokeException("Checksum mismatch")
|
||||||
# TODO: mods validation
|
|
||||||
|
item.validate_playlist_item_mods()
|
||||||
item.owner_id = user.user_id
|
item.owner_id = user.user_id
|
||||||
item.star_rating = float(
|
item.star_rating = float(
|
||||||
beatmap.difficulty_rating
|
beatmap.difficulty_rating
|
||||||
@@ -410,7 +499,7 @@ class MultiplayerQueue:
|
|||||||
"Attempted to change an item which has already been played"
|
"Attempted to change an item which has already been played"
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: mods validation
|
item.validate_playlist_item_mods()
|
||||||
item.owner_id = user.user_id
|
item.owner_id = user.user_id
|
||||||
item.star_rating = float(beatmap.difficulty_rating)
|
item.star_rating = float(beatmap.difficulty_rating)
|
||||||
item.playlist_order = existing_item.playlist_order
|
item.playlist_order = existing_item.playlist_order
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
|
|||||||
connection_token=client.connection_token,
|
connection_token=client.connection_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def _clean_state(self, state: MultiplayerClientState):
|
||||||
|
user_id = int(state.connection_id)
|
||||||
|
if state.room_id != 0 and state.room_id in self.rooms:
|
||||||
|
server_room = self.rooms[state.room_id]
|
||||||
|
room = server_room.room
|
||||||
|
user = next((u for u in room.users if u.user_id == user_id), None)
|
||||||
|
if user is not None:
|
||||||
|
await self.make_user_leave(
|
||||||
|
self.get_client_by_id(str(user_id)), server_room, user
|
||||||
|
)
|
||||||
|
|
||||||
async def CreateRoom(self, client: Client, room: MultiplayerRoom):
|
async def CreateRoom(self, client: Client, room: MultiplayerRoom):
|
||||||
logger.info(f"[MultiplayerHub] {client.user_id} creating room")
|
logger.info(f"[MultiplayerHub] {client.user_id} creating room")
|
||||||
store = self.get_or_create_state(client)
|
store = self.get_or_create_state(client)
|
||||||
@@ -554,8 +566,17 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
|
|||||||
raise InvokeException("You are not in this room")
|
raise InvokeException("You are not in this room")
|
||||||
if room.host is None or room.host.user_id != client.user_id:
|
if room.host is None or room.host.user_id != client.user_id:
|
||||||
raise InvokeException("You are not the host of this room")
|
raise InvokeException("You are not the host of this room")
|
||||||
if any(u.state != MultiplayerUserState.READY for u in room.users):
|
|
||||||
raise InvokeException("Not all users are ready")
|
# Check host state - host must be ready or spectating
|
||||||
|
if room.host.state not in (
|
||||||
|
MultiplayerUserState.SPECTATING,
|
||||||
|
MultiplayerUserState.READY,
|
||||||
|
):
|
||||||
|
raise InvokeException("Can't start match when the host is not ready.")
|
||||||
|
|
||||||
|
# Check if any users are ready
|
||||||
|
if all(u.state != MultiplayerUserState.READY for u in room.users):
|
||||||
|
raise InvokeException("Can't start match when no users are ready.")
|
||||||
|
|
||||||
await self.start_match(server_room)
|
await self.start_match(server_room)
|
||||||
|
|
||||||
@@ -646,7 +667,11 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
|
|||||||
if len(room.room.users) == 0:
|
if len(room.room.users) == 0:
|
||||||
await self.end_room(room)
|
await self.end_room(room)
|
||||||
await self.update_room_state(room)
|
await self.update_room_state(room)
|
||||||
if room.room.host and room.room.host.user_id == user.user_id:
|
if (
|
||||||
|
len(room.room.users) != 0
|
||||||
|
and room.room.host
|
||||||
|
and room.room.host.user_id == user.user_id
|
||||||
|
):
|
||||||
next_host = room.room.users[0]
|
next_host = room.room.users[0]
|
||||||
await self.set_host(room, next_host)
|
await self.set_host(room, next_host)
|
||||||
|
|
||||||
@@ -710,6 +735,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
|
|||||||
if room.host is None or room.host.user_id != client.user_id:
|
if room.host is None or room.host.user_id != client.user_id:
|
||||||
raise InvokeException("You are not the host of this room")
|
raise InvokeException("You are not the host of this room")
|
||||||
|
|
||||||
|
if user_id == client.user_id:
|
||||||
|
raise InvokeException("Can't kick self")
|
||||||
|
|
||||||
user = next((u for u in room.users if u.user_id == user_id), None)
|
user = next((u for u in room.users if u.user_id == user_id), None)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise InvokeException("User not found in this room")
|
raise InvokeException("User not found in this room")
|
||||||
@@ -780,9 +808,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
room.state != MultiplayerRoomState.PLAYING
|
room.state != MultiplayerRoomState.PLAYING
|
||||||
or room.state == MultiplayerRoomState.WAITING_FOR_LOAD
|
and room.state != MultiplayerRoomState.WAITING_FOR_LOAD
|
||||||
):
|
):
|
||||||
raise InvokeException("Room is not in a playable state")
|
raise InvokeException("Cannot abort a match that hasn't started.")
|
||||||
|
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
*[
|
*[
|
||||||
|
|||||||
Reference in New Issue
Block a user