feat(multiplayer): complete validation

This commit is contained in:
MingxuanGame
2025-08-04 02:20:14 +00:00
parent f82a1bb3c0
commit 9da9f27feb
2 changed files with 129 additions and 12 deletions

View File

@@ -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

View File

@@ -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(
*[ *[