feat(multiplay): support CreateRoom hub method
This commit is contained in:
@@ -15,8 +15,11 @@ from .lazer_user import (
|
||||
User,
|
||||
UserResp,
|
||||
)
|
||||
from .playlist_attempts import ItemAttemptsCount
|
||||
from .playlists import Playlist, PlaylistResp
|
||||
from .pp_best_score import PPBestScore
|
||||
from .relationship import Relationship, RelationshipResp, RelationshipType
|
||||
from .room import Room, RoomResp
|
||||
from .score import (
|
||||
Score,
|
||||
ScoreBase,
|
||||
@@ -43,11 +46,16 @@ __all__ = [
|
||||
"DailyChallengeStats",
|
||||
"DailyChallengeStatsResp",
|
||||
"FavouriteBeatmapset",
|
||||
"ItemAttemptsCount",
|
||||
"OAuthToken",
|
||||
"PPBestScore",
|
||||
"Playlist",
|
||||
"PlaylistResp",
|
||||
"Relationship",
|
||||
"RelationshipResp",
|
||||
"RelationshipType",
|
||||
"Room",
|
||||
"RoomResp",
|
||||
"Score",
|
||||
"ScoreBase",
|
||||
"ScoreResp",
|
||||
|
||||
9
app/database/playlist_attempts.py
Normal file
9
app/database/playlist_attempts.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class ItemAttemptsCount(SQLModel, table=True):
|
||||
__tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType]
|
||||
id: int = Field(foreign_key="room_playlists.db_id", primary_key=True, index=True)
|
||||
room_id: int = Field(foreign_key="rooms.id", index=True)
|
||||
attempts: int = Field(default=0)
|
||||
passed: int = Field(default=0)
|
||||
85
app/database/playlists.py
Normal file
85
app/database/playlists.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models.model import UTCBaseModel
|
||||
from app.models.mods import APIMod, msgpack_to_apimod
|
||||
from app.models.multiplayer_hub import PlaylistItem
|
||||
|
||||
from .beatmap import Beatmap, BeatmapResp
|
||||
|
||||
from sqlmodel import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Column,
|
||||
DateTime,
|
||||
Field,
|
||||
ForeignKey,
|
||||
Relationship,
|
||||
SQLModel,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .room import Room
|
||||
|
||||
|
||||
class PlaylistBase(SQLModel, UTCBaseModel):
|
||||
id: int = 0
|
||||
owner_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id")))
|
||||
ruleset_id: int = Field(ge=0, le=3)
|
||||
expired: bool = Field(default=False)
|
||||
playlist_order: int = Field(default=0)
|
||||
played_at: datetime | None = Field(
|
||||
sa_column=Column(DateTime(timezone=True)),
|
||||
default=None,
|
||||
)
|
||||
allowed_mods: list[APIMod] = Field(
|
||||
default_factory=list,
|
||||
sa_column=Column(JSON),
|
||||
)
|
||||
required_mods: list[APIMod] = Field(
|
||||
default_factory=list,
|
||||
sa_column=Column(JSON),
|
||||
)
|
||||
beatmap_id: int = Field(
|
||||
foreign_key="beatmaps.id",
|
||||
)
|
||||
freestyle: bool = Field(default=False)
|
||||
|
||||
|
||||
class Playlist(PlaylistBase, table=True):
|
||||
__tablename__ = "room_playlists" # pyright: ignore[reportAssignmentType]
|
||||
db_id: int = Field(default=None, primary_key=True, index=True, exclude=True)
|
||||
room_id: int = Field(foreign_key="rooms.id", exclude=True)
|
||||
|
||||
beatmap: Beatmap = Relationship(
|
||||
sa_relationship_kwargs={
|
||||
"lazy": "joined",
|
||||
}
|
||||
)
|
||||
room: "Room" = Relationship()
|
||||
|
||||
@classmethod
|
||||
async def from_hub(cls, playlist: PlaylistItem, room_id: int) -> "Playlist":
|
||||
return cls(
|
||||
id=playlist.id,
|
||||
owner_id=playlist.owner_id,
|
||||
ruleset_id=playlist.ruleset_id,
|
||||
beatmap_id=playlist.beatmap_id,
|
||||
required_mods=[msgpack_to_apimod(mod) for mod in playlist.required_mods],
|
||||
allowed_mods=[msgpack_to_apimod(mod) for mod in playlist.allowed_mods],
|
||||
expired=playlist.expired,
|
||||
playlist_order=playlist.order,
|
||||
played_at=playlist.played_at,
|
||||
freestyle=playlist.freestyle,
|
||||
room_id=room_id,
|
||||
)
|
||||
|
||||
|
||||
class PlaylistResp(PlaylistBase):
|
||||
beatmap: BeatmapResp | None = None
|
||||
|
||||
@classmethod
|
||||
async def from_db(cls, playlist: Playlist) -> "PlaylistResp":
|
||||
resp = cls.model_validate(playlist)
|
||||
resp.beatmap = await BeatmapResp.from_db(playlist.beatmap)
|
||||
return resp
|
||||
@@ -1,6 +1,135 @@
|
||||
from sqlmodel import Field, SQLModel
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.models.multiplayer_hub import ServerMultiplayerRoom
|
||||
from app.models.room import (
|
||||
MatchType,
|
||||
QueueMode,
|
||||
RoomCategory,
|
||||
RoomDifficultyRange,
|
||||
RoomPlaylistItemStats,
|
||||
RoomStatus,
|
||||
)
|
||||
|
||||
from .lazer_user import User, UserResp
|
||||
from .playlist_attempts import ItemAttemptsCount
|
||||
from .playlists import Playlist, PlaylistResp
|
||||
|
||||
from sqlmodel import (
|
||||
BigInteger,
|
||||
Column,
|
||||
DateTime,
|
||||
Field,
|
||||
ForeignKey,
|
||||
Relationship,
|
||||
SQLModel,
|
||||
)
|
||||
|
||||
|
||||
class RoomIndex(SQLModel, table=True):
|
||||
__tablename__ = "mp_room_index" # pyright: ignore[reportAssignmentType]
|
||||
id: int = Field(default=None, primary_key=True, index=True) # pyright: ignore[reportCallIssue]
|
||||
class RoomBase(SQLModel):
|
||||
name: str = Field(index=True)
|
||||
category: RoomCategory = Field(default=RoomCategory.NORMAL, index=True)
|
||||
duration: int | None = Field(default=None) # minutes
|
||||
starts_at: datetime = Field(
|
||||
sa_column=Column(
|
||||
DateTime(timezone=True),
|
||||
),
|
||||
default=datetime.now(UTC),
|
||||
)
|
||||
ended_at: datetime | None = Field(
|
||||
sa_column=Column(
|
||||
DateTime(timezone=True),
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
participant_count: int = Field(default=0)
|
||||
max_attempts: int | None = Field(default=None) # playlists
|
||||
type: MatchType
|
||||
queue_mode: QueueMode
|
||||
auto_skip: bool
|
||||
auto_start_duration: int
|
||||
status: RoomStatus
|
||||
# TODO: channel_id
|
||||
# recent_participants: list[User]
|
||||
|
||||
|
||||
class Room(RoomBase, table=True):
|
||||
__tablename__ = "rooms" # pyright: ignore[reportAssignmentType]
|
||||
id: int = Field(default=None, primary_key=True, index=True)
|
||||
host_id: int = Field(
|
||||
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
|
||||
)
|
||||
|
||||
host: User = Relationship()
|
||||
playlist: list[Playlist] = Relationship(
|
||||
sa_relationship_kwargs={
|
||||
"lazy": "joined",
|
||||
"cascade": "all, delete-orphan",
|
||||
"overlaps": "room",
|
||||
}
|
||||
)
|
||||
# playlist_item_attempts: list["ItemAttemptsCount"] = Relationship(
|
||||
# sa_relationship_kwargs={
|
||||
# "lazy": "joined",
|
||||
# "cascade": "all, delete-orphan",
|
||||
# "primaryjoin": "ItemAttemptsCount.room_id == Room.id",
|
||||
# }
|
||||
# )
|
||||
|
||||
|
||||
class RoomResp(RoomBase):
|
||||
id: int
|
||||
password: str | None = None
|
||||
host: UserResp | None = None
|
||||
playlist: list[PlaylistResp] = []
|
||||
playlist_item_stats: RoomPlaylistItemStats | None = None
|
||||
difficulty_range: RoomDifficultyRange | None = None
|
||||
current_playlist_item: PlaylistResp | None = None
|
||||
playlist_item_attempts: list[ItemAttemptsCount] = []
|
||||
|
||||
@classmethod
|
||||
async def from_db(cls, room: Room) -> "RoomResp":
|
||||
resp = cls.model_validate(room.model_dump())
|
||||
|
||||
stats = RoomPlaylistItemStats(count_active=0, count_total=0)
|
||||
difficulty_range = RoomDifficultyRange(
|
||||
min=0,
|
||||
max=0,
|
||||
)
|
||||
rulesets = set()
|
||||
for playlist in room.playlist:
|
||||
stats.count_total += 1
|
||||
if not playlist.expired:
|
||||
stats.count_active += 1
|
||||
rulesets.add(playlist.ruleset_id)
|
||||
difficulty_range.min = min(
|
||||
difficulty_range.min, playlist.beatmap.difficulty_rating
|
||||
)
|
||||
difficulty_range.max = max(
|
||||
difficulty_range.max, playlist.beatmap.difficulty_rating
|
||||
)
|
||||
resp.playlist.append(await PlaylistResp.from_db(playlist))
|
||||
stats.ruleset_ids = list(rulesets)
|
||||
resp.playlist_item_stats = stats
|
||||
resp.difficulty_range = difficulty_range
|
||||
resp.current_playlist_item = resp.playlist[-1] if resp.playlist else None
|
||||
# resp.playlist_item_attempts = room.playlist_item_attempts
|
||||
|
||||
return resp
|
||||
|
||||
@classmethod
|
||||
async def from_hub(cls, server_room: ServerMultiplayerRoom) -> "RoomResp":
|
||||
room = server_room.room
|
||||
resp = cls(
|
||||
id=room.room_id,
|
||||
name=room.settings.name,
|
||||
type=room.settings.match_type,
|
||||
queue_mode=room.settings.queue_mode,
|
||||
auto_skip=room.settings.auto_skip,
|
||||
auto_start_duration=room.settings.auto_start_duration,
|
||||
status=server_room.status,
|
||||
category=server_room.category,
|
||||
# duration = room.settings.duration,
|
||||
starts_at=server_room.start_at,
|
||||
participant_count=len(room.users),
|
||||
)
|
||||
return resp
|
||||
|
||||
@@ -5,10 +5,12 @@ from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
from app.path import STATIC_DIR
|
||||
|
||||
from msgpack_lazer_api import APIMod as MsgpackAPIMod
|
||||
|
||||
|
||||
class APIMod(TypedDict):
|
||||
acronym: str
|
||||
settings: NotRequired[dict[str, bool | float | str]]
|
||||
settings: NotRequired[dict[str, bool | float | str | int]]
|
||||
|
||||
|
||||
# https://github.com/ppy/osu-api/wiki#mods
|
||||
@@ -167,3 +169,13 @@ def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool:
|
||||
if expected_value != NO_CHECK and value != expected_value:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def msgpack_to_apimod(mod: MsgpackAPIMod) -> APIMod:
|
||||
"""
|
||||
Convert a MsgpackAPIMod to an APIMod.
|
||||
"""
|
||||
return APIMod(
|
||||
acronym=mod.acronym,
|
||||
settings=mod.settings,
|
||||
)
|
||||
|
||||
168
app/models/multiplayer_hub.py
Normal file
168
app/models/multiplayer_hub.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from .room import (
|
||||
DownloadState,
|
||||
MatchType,
|
||||
MultiplayerRoomState,
|
||||
MultiplayerUserState,
|
||||
QueueMode,
|
||||
RoomCategory,
|
||||
RoomStatus,
|
||||
)
|
||||
from .signalr import (
|
||||
EnumByIndex,
|
||||
MessagePackArrayModel,
|
||||
UserState,
|
||||
msgpack_union,
|
||||
msgpack_union_dump,
|
||||
)
|
||||
|
||||
from msgpack_lazer_api import APIMod
|
||||
from pydantic import BaseModel, Field, field_serializer, field_validator
|
||||
|
||||
|
||||
class MultiplayerClientState(UserState):
|
||||
room_id: int = 0
|
||||
|
||||
|
||||
class MultiplayerRoomSettings(MessagePackArrayModel):
|
||||
name: str = "Unnamed Room"
|
||||
playlist_item_id: int = 0
|
||||
password: str = ""
|
||||
match_type: Annotated[MatchType, EnumByIndex(MatchType)] = MatchType.HEAD_TO_HEAD
|
||||
queue_mode: Annotated[QueueMode, EnumByIndex(QueueMode)] = QueueMode.HOST_ONLY
|
||||
auto_start_duration: int = 0
|
||||
auto_skip: bool = False
|
||||
|
||||
|
||||
class BeatmapAvailability(MessagePackArrayModel):
|
||||
state: Annotated[DownloadState, EnumByIndex(DownloadState)] = DownloadState.UNKNOWN
|
||||
progress: float | None = None
|
||||
|
||||
|
||||
class _MatchUserState(MessagePackArrayModel): ...
|
||||
|
||||
|
||||
class TeamVersusUserState(_MatchUserState):
|
||||
team_id: int
|
||||
|
||||
type: Literal[0] = Field(0, exclude=True)
|
||||
|
||||
|
||||
MatchUserState = TeamVersusUserState
|
||||
|
||||
|
||||
class _MatchRoomState(MessagePackArrayModel): ...
|
||||
|
||||
|
||||
class MultiplayerTeam(MessagePackArrayModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class TeamVersusRoomState(_MatchRoomState):
|
||||
teams: list[MultiplayerTeam] = Field(
|
||||
default_factory=lambda: [
|
||||
MultiplayerTeam(id=0, name="Team Red"),
|
||||
MultiplayerTeam(id=1, name="Team Blue"),
|
||||
]
|
||||
)
|
||||
|
||||
type: Literal[0] = Field(0, exclude=True)
|
||||
|
||||
|
||||
MatchRoomState = TeamVersusRoomState
|
||||
|
||||
|
||||
class PlaylistItem(MessagePackArrayModel):
|
||||
id: int
|
||||
owner_id: int
|
||||
beatmap_id: int
|
||||
checksum: str
|
||||
ruleset_id: int
|
||||
required_mods: list[APIMod] = Field(default_factory=list)
|
||||
allowed_mods: list[APIMod] = Field(default_factory=list)
|
||||
expired: bool
|
||||
order: int
|
||||
played_at: datetime.datetime | None = None
|
||||
star: float
|
||||
freestyle: bool
|
||||
|
||||
|
||||
class _MultiplayerCountdown(MessagePackArrayModel):
|
||||
id: int
|
||||
remaining: int
|
||||
is_exclusive: bool
|
||||
|
||||
|
||||
class MatchStartCountdown(_MultiplayerCountdown):
|
||||
type: Literal[0] = Field(0, exclude=True)
|
||||
|
||||
|
||||
class ForceGameplayStartCountdown(_MultiplayerCountdown):
|
||||
type: Literal[1] = Field(1, exclude=True)
|
||||
|
||||
|
||||
class ServerShuttingDownCountdown(_MultiplayerCountdown):
|
||||
type: Literal[2] = Field(2, exclude=True)
|
||||
|
||||
|
||||
MultiplayerCountdown = (
|
||||
MatchStartCountdown | ForceGameplayStartCountdown | ServerShuttingDownCountdown
|
||||
)
|
||||
|
||||
|
||||
class MultiplayerRoomUser(MessagePackArrayModel):
|
||||
user_id: int
|
||||
state: Annotated[MultiplayerUserState, EnumByIndex(MultiplayerUserState)] = (
|
||||
MultiplayerUserState.IDLE
|
||||
)
|
||||
availability: BeatmapAvailability = BeatmapAvailability(
|
||||
state=DownloadState.UNKNOWN, progress=None
|
||||
)
|
||||
mods: list[APIMod] = Field(default_factory=list)
|
||||
match_state: MatchUserState | None = None
|
||||
ruleset_id: int | None = None # freestyle
|
||||
beatmap_id: int | None = None # freestyle
|
||||
|
||||
@field_validator("match_state", mode="before")
|
||||
def union_validate(v: Any):
|
||||
if isinstance(v, list):
|
||||
return msgpack_union(v)
|
||||
return v
|
||||
|
||||
@field_serializer("match_state")
|
||||
def union_serialize(v: Any):
|
||||
return msgpack_union_dump(v)
|
||||
|
||||
|
||||
class MultiplayerRoom(MessagePackArrayModel):
|
||||
room_id: int
|
||||
state: Annotated[MultiplayerRoomState, EnumByIndex(MultiplayerRoomState)]
|
||||
settings: MultiplayerRoomSettings
|
||||
users: list[MultiplayerRoomUser] = Field(default_factory=list)
|
||||
host: MultiplayerRoomUser | None = None
|
||||
match_state: MatchRoomState | None = None
|
||||
playlist: list[PlaylistItem] = Field(default_factory=list)
|
||||
active_cooldowns: list[MultiplayerCountdown] = Field(default_factory=list)
|
||||
channel_id: int
|
||||
|
||||
@field_validator("match_state", mode="before")
|
||||
def union_validate(v: Any):
|
||||
if isinstance(v, list):
|
||||
return msgpack_union(v)
|
||||
return v
|
||||
|
||||
@field_serializer("match_state")
|
||||
def union_serialize(v: Any):
|
||||
return msgpack_union_dump(v)
|
||||
|
||||
|
||||
class ServerMultiplayerRoom(BaseModel):
|
||||
room: MultiplayerRoom
|
||||
category: RoomCategory
|
||||
status: RoomStatus
|
||||
start_at: datetime.datetime
|
||||
@@ -1,17 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
from app.database.beatmap import Beatmap, BeatmapResp
|
||||
from app.database.user import User as DBUser
|
||||
from app.fetcher import Fetcher
|
||||
from app.models.mods import APIMod
|
||||
from app.models.user import User
|
||||
from app.utils import convert_db_user_to_api_user
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RoomCategory(str, Enum):
|
||||
@@ -64,51 +55,13 @@ class MultiplayerUserState(str, Enum):
|
||||
|
||||
|
||||
class DownloadState(str, Enum):
|
||||
UNKOWN = "unkown"
|
||||
UNKNOWN = "unknown"
|
||||
NOT_DOWNLOADED = "not_downloaded"
|
||||
DOWNLOADING = "downloading"
|
||||
IMPORTING = "importing"
|
||||
LOCALLY_AVAILABLE = "locally_available"
|
||||
|
||||
|
||||
class PlaylistItem(BaseModel):
|
||||
id: int
|
||||
owner_id: int
|
||||
ruleset_id: int
|
||||
expired: bool
|
||||
playlist_order: int | None
|
||||
played_at: datetime | None
|
||||
allowed_mods: list[APIMod] = []
|
||||
required_mods: list[APIMod] = []
|
||||
beatmap_id: int
|
||||
beatmap: BeatmapResp | None
|
||||
freestyle: bool
|
||||
|
||||
class Config:
|
||||
exclude_none = True
|
||||
|
||||
@classmethod
|
||||
async def from_mpListItem(
|
||||
cls, item: MultiPlayerListItem, db: AsyncSession, fetcher: Fetcher
|
||||
):
|
||||
s = cls.model_validate(item.model_dump())
|
||||
s.id = item.id
|
||||
s.owner_id = item.OwnerID
|
||||
s.ruleset_id = item.RulesetID
|
||||
s.expired = item.Expired
|
||||
s.playlist_order = item.PlaylistOrder
|
||||
s.played_at = item.PlayedAt
|
||||
s.required_mods = item.RequierdMods
|
||||
s.allowed_mods = item.AllowedMods
|
||||
s.freestyle = item.Freestyle
|
||||
cur_beatmap = await Beatmap.get_or_fetch(
|
||||
db, fetcher=fetcher, bid=item.BeatmapID
|
||||
)
|
||||
s.beatmap = BeatmapResp.from_db(cur_beatmap)
|
||||
s.beatmap_id = item.BeatmapID
|
||||
return s
|
||||
|
||||
|
||||
class RoomPlaylistItemStats(BaseModel):
|
||||
count_active: int
|
||||
count_total: int
|
||||
@@ -120,269 +73,7 @@ class RoomDifficultyRange(BaseModel):
|
||||
max: float
|
||||
|
||||
|
||||
class ItemAttemptsCount(BaseModel):
|
||||
id: int
|
||||
attempts: int
|
||||
passed: bool
|
||||
|
||||
|
||||
class PlaylistAggregateScore(BaseModel):
|
||||
playlist_item_attempts: list[ItemAttemptsCount]
|
||||
|
||||
|
||||
class MultiplayerRoomSettings(BaseModel):
|
||||
Name: str = "Unnamed Room"
|
||||
PlaylistItemId: int
|
||||
Password: str = ""
|
||||
MatchType: MatchType
|
||||
QueueMode: QueueMode
|
||||
AutoStartDuration: timedelta
|
||||
AutoSkip: bool
|
||||
|
||||
@classmethod
|
||||
def from_apiRoom(cls, room: Room):
|
||||
s = cls.model_validate(room.model_dump())
|
||||
s.Name = room.name
|
||||
s.Password = room.password if room.password is not None else ""
|
||||
s.MatchType = room.type
|
||||
s.QueueMode = room.queue_mode
|
||||
s.AutoStartDuration = timedelta(seconds=room.auto_start_duration)
|
||||
s.AutoSkip = room.auto_skip
|
||||
return s
|
||||
|
||||
|
||||
class BeatmapAvailability(BaseModel):
|
||||
State: DownloadState
|
||||
DownloadProgress: float | None
|
||||
|
||||
|
||||
class MatchUserState(BaseModel):
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
|
||||
class TeamVersusState(MatchUserState):
|
||||
TeamId: int
|
||||
|
||||
|
||||
MatchUserStateType = TeamVersusState | MatchUserState
|
||||
|
||||
|
||||
class MultiplayerRoomUser(BaseModel):
|
||||
UserID: int
|
||||
State: MultiplayerUserState = MultiplayerUserState.IDLE
|
||||
BeatmapAvailability: BeatmapAvailability
|
||||
Mods: list[APIMod] = []
|
||||
MatchUserState: MatchUserStateType | None
|
||||
RulesetId: int | None
|
||||
BeatmapId: int | None
|
||||
User: User | None
|
||||
|
||||
@classmethod
|
||||
async def from_id(cls, id: int, db: AsyncSession):
|
||||
actualUser = (
|
||||
await db.exec(
|
||||
DBUser.all_select_clause().where(
|
||||
DBUser.id == id,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
user = (
|
||||
await convert_db_user_to_api_user(actualUser)
|
||||
if actualUser is not None
|
||||
else None
|
||||
)
|
||||
return MultiplayerRoomUser(
|
||||
UserID=id,
|
||||
MatchUserState=None,
|
||||
BeatmapAvailability=BeatmapAvailability(
|
||||
State=DownloadState.UNKOWN, DownloadProgress=None
|
||||
),
|
||||
RulesetId=None,
|
||||
BeatmapId=None,
|
||||
User=user,
|
||||
)
|
||||
|
||||
|
||||
class MatchRoomState(BaseModel):
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
|
||||
class MultiPlayerTeam(BaseModel):
|
||||
id: int = 0
|
||||
name: str = ""
|
||||
|
||||
|
||||
class TeamVersusRoomState(BaseModel):
|
||||
teams: list[MultiPlayerTeam] = []
|
||||
|
||||
class Config:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def create_default(cls):
|
||||
return cls(
|
||||
teams=[
|
||||
MultiPlayerTeam(id=0, name="Team Red"),
|
||||
MultiPlayerTeam(id=1, name="Team Blue"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
MatchRoomStateType = TeamVersusRoomState | MatchRoomState
|
||||
|
||||
|
||||
class MultiPlayerListItem(BaseModel):
|
||||
id: int
|
||||
OwnerID: int
|
||||
BeatmapID: int
|
||||
BeatmapChecksum: str = ""
|
||||
RulesetID: int
|
||||
RequierdMods: list[APIMod]
|
||||
AllowedMods: list[APIMod]
|
||||
Expired: bool
|
||||
PlaylistOrder: int | None
|
||||
PlayedAt: datetime | None
|
||||
StarRating: float
|
||||
Freestyle: bool
|
||||
|
||||
@classmethod
|
||||
async def from_apiItem(cls, item: PlaylistItem, db: AsyncSession, fetcher: Fetcher):
|
||||
s = cls.model_validate(item.model_dump())
|
||||
s.id = item.id
|
||||
s.OwnerID = item.owner_id
|
||||
if item.beatmap is None: # 从客户端接受的一定没有这字段
|
||||
cur_beatmap = await Beatmap.get_or_fetch(
|
||||
db, fetcher=fetcher, bid=item.beatmap_id
|
||||
)
|
||||
s.BeatmapID = cur_beatmap.id if cur_beatmap.id is not None else 0
|
||||
s.BeatmapChecksum = cur_beatmap.checksum
|
||||
s.StarRating = cur_beatmap.difficulty_rating
|
||||
s.RulesetID = item.ruleset_id
|
||||
s.RequierdMods = item.required_mods
|
||||
s.AllowedMods = item.allowed_mods
|
||||
s.Expired = item.expired
|
||||
s.PlaylistOrder = item.playlist_order if item.playlist_order is not None else 0
|
||||
s.PlayedAt = item.played_at
|
||||
s.Freestyle = item.freestyle
|
||||
return s
|
||||
|
||||
|
||||
class MultiplayerCountdown(BaseModel):
|
||||
id: int = 0
|
||||
time_remaining: timedelta = timedelta(seconds=0)
|
||||
is_exclusive: bool = True
|
||||
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
|
||||
class MatchStartCountdown(MultiplayerCountdown):
|
||||
pass
|
||||
|
||||
|
||||
class ForceGameplayStartCountdown(MultiplayerCountdown):
|
||||
pass
|
||||
|
||||
|
||||
class ServerShuttingCountdown(MultiplayerCountdown):
|
||||
pass
|
||||
|
||||
|
||||
MultiplayerCountdownType = (
|
||||
MatchStartCountdown
|
||||
| ForceGameplayStartCountdown
|
||||
| ServerShuttingCountdown
|
||||
| MultiplayerCountdown
|
||||
)
|
||||
|
||||
|
||||
class PlaylistStatus(BaseModel):
|
||||
count_active: int
|
||||
count_total: int
|
||||
ruleset_ids: list[int]
|
||||
|
||||
|
||||
class MultiplayerRoom(BaseModel):
|
||||
RoomId: int
|
||||
State: MultiplayerRoomState
|
||||
Settings: MultiplayerRoomSettings = MultiplayerRoomSettings(
|
||||
PlaylistItemId=0,
|
||||
MatchType=MatchType.HEAD_TO_HEAD,
|
||||
QueueMode=QueueMode.HOST_ONLY,
|
||||
AutoStartDuration=timedelta(0),
|
||||
AutoSkip=False,
|
||||
)
|
||||
Users: list[MultiplayerRoomUser]
|
||||
Host: MultiplayerRoomUser
|
||||
MatchState: MatchRoomState | None
|
||||
Playlist: list[MultiPlayerListItem]
|
||||
ActivecCountDowns: list[MultiplayerCountdownType]
|
||||
ChannelID: int
|
||||
|
||||
@classmethod
|
||||
def CanAddPlayistItem(cls, user: MultiplayerRoomUser) -> bool:
|
||||
return user == cls.Host or cls.Settings.QueueMode != QueueMode.HOST_ONLY
|
||||
|
||||
@classmethod
|
||||
async def from_apiRoom(cls, room: Room, db: AsyncSession, fetcher: Fetcher):
|
||||
s = cls.model_validate(room.model_dump())
|
||||
s.RoomId = room.room_id if room.room_id is not None else 0
|
||||
s.ChannelID = room.channel_id
|
||||
s.Settings = MultiplayerRoomSettings.from_apiRoom(room)
|
||||
s.Host = await MultiplayerRoomUser.from_id(room.host.id if room.host else 0, db)
|
||||
s.Playlist = [
|
||||
await MultiPlayerListItem.from_apiItem(item, db, fetcher)
|
||||
for item in room.playlist
|
||||
]
|
||||
return s
|
||||
|
||||
|
||||
class Room(BaseModel):
|
||||
room_id: int
|
||||
name: str
|
||||
password: str | None
|
||||
has_password: bool = Field(exclude=True)
|
||||
host: User | None
|
||||
category: RoomCategory
|
||||
duration: int | None
|
||||
starts_at: datetime | None
|
||||
ends_at: datetime | None
|
||||
max_particapants: int | None = Field(exclude=True)
|
||||
particapant_count: int
|
||||
recent_particapants: list[User]
|
||||
type: MatchType
|
||||
max_attempts: int | None
|
||||
playlist: list[PlaylistItem]
|
||||
playlist_item_status: list[RoomPlaylistItemStats]
|
||||
difficulity_range: RoomDifficultyRange
|
||||
queue_mode: QueueMode
|
||||
auto_skip: bool
|
||||
auto_start_duration: int
|
||||
current_user_score: PlaylistAggregateScore | None
|
||||
current_playlist_item: PlaylistItem | None
|
||||
channel_id: int
|
||||
status: RoomStatus
|
||||
availability: RoomAvailability = Field(exclude=True)
|
||||
|
||||
class Config:
|
||||
exclude_none = True
|
||||
|
||||
@classmethod
|
||||
async def from_mpRoom(
|
||||
cls, room: MultiplayerRoom, db: AsyncSession, fetcher: Fetcher
|
||||
):
|
||||
s = cls.model_validate(room.model_dump())
|
||||
s.room_id = room.RoomId
|
||||
s.name = room.Settings.Name
|
||||
s.password = room.Settings.Password
|
||||
s.type = room.Settings.MatchType
|
||||
s.queue_mode = room.Settings.QueueMode
|
||||
s.auto_skip = room.Settings.AutoSkip
|
||||
s.host = room.Host.User
|
||||
s.playlist = [
|
||||
await PlaylistItem.from_mpListItem(item, db, fetcher)
|
||||
for item in room.Playlist
|
||||
]
|
||||
return s
|
||||
|
||||
@@ -7,6 +7,7 @@ from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401
|
||||
beatmapset,
|
||||
me,
|
||||
relationship,
|
||||
room,
|
||||
score,
|
||||
user,
|
||||
)
|
||||
@@ -14,4 +15,9 @@ from .api_router import router as api_router
|
||||
from .auth import router as auth_router
|
||||
from .fetcher import fetcher_router as fetcher_router
|
||||
|
||||
__all__ = ["api_router", "auth_router", "fetcher_router", "signalr_router"]
|
||||
__all__ = [
|
||||
"api_router",
|
||||
"auth_router",
|
||||
"fetcher_router",
|
||||
"signalr_router",
|
||||
]
|
||||
|
||||
@@ -1,109 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.database.room import RoomIndex
|
||||
from typing import Literal
|
||||
|
||||
from app.database.room import RoomResp
|
||||
from app.dependencies.database import get_db, get_redis
|
||||
from app.dependencies.fetcher import get_fetcher
|
||||
from app.fetcher import Fetcher
|
||||
from app.models.room import MultiplayerRoom, MultiplayerRoomState, Room
|
||||
from app.models.room import RoomStatus
|
||||
from app.signalr.hub import MultiplayerHubs
|
||||
|
||||
from .api_router import router
|
||||
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from fastapi import Depends, Query
|
||||
from redis.asyncio import Redis
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
@router.get("/rooms", tags=["rooms"], response_model=list[Room])
|
||||
@router.get("/rooms", tags=["rooms"], response_model=list[RoomResp])
|
||||
async def get_all_rooms(
|
||||
mode: str | None = Query(None), # TODO: 对房间根据状态进行筛选
|
||||
status: str | None = Query(None),
|
||||
category: str | None = Query(
|
||||
mode: Literal["open", "ended", "participated", "owned", None] = Query(
|
||||
None
|
||||
), # TODO: 对房间根据分类进行筛选(真的有人用这功能吗)
|
||||
), # TODO: 对房间根据状态进行筛选
|
||||
category: str = Query(default="realtime"), # TODO
|
||||
status: RoomStatus | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
fetcher: Fetcher = Depends(get_fetcher),
|
||||
redis: Redis = Depends(get_redis),
|
||||
):
|
||||
all_roomID = (await db.exec(select(RoomIndex))).all()
|
||||
redis = get_redis()
|
||||
if redis is not None:
|
||||
resp: list[Room] = []
|
||||
for id in all_roomID:
|
||||
dumped_room = redis.get(str(id))
|
||||
validated_room = MultiplayerRoom.model_validate_json(str(dumped_room))
|
||||
flag: bool = False
|
||||
if status is not None:
|
||||
if (
|
||||
validated_room.State == MultiplayerRoomState.OPEN
|
||||
and status == "idle"
|
||||
):
|
||||
flag = True
|
||||
elif validated_room != MultiplayerRoomState.CLOSED:
|
||||
flag = True
|
||||
if flag:
|
||||
resp.append(
|
||||
await Room.from_mpRoom(
|
||||
MultiplayerRoom.model_validate_json(str(dumped_room)),
|
||||
db,
|
||||
fetcher,
|
||||
)
|
||||
)
|
||||
return resp
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Redis Error")
|
||||
rooms = MultiplayerHubs.rooms.values()
|
||||
return [await RoomResp.from_hub(room) for room in rooms]
|
||||
|
||||
|
||||
@router.get("/rooms/{room}", tags=["room"], response_model=Room)
|
||||
async def get_room(
|
||||
room: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
fetcher: Fetcher = Depends(get_fetcher),
|
||||
):
|
||||
redis = get_redis()
|
||||
if redis:
|
||||
dumped_room = str(redis.get(str(room)))
|
||||
if dumped_room is not None:
|
||||
resp = await Room.from_mpRoom(
|
||||
MultiplayerRoom.model_validate_json(str(dumped_room)), db, fetcher
|
||||
)
|
||||
return resp
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Room Not Found")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Redis error")
|
||||
# @router.get("/rooms/{room}", tags=["room"], response_model=Room)
|
||||
# async def get_room(
|
||||
# room: int,
|
||||
# db: AsyncSession = Depends(get_db),
|
||||
# fetcher: Fetcher = Depends(get_fetcher),
|
||||
# ):
|
||||
# redis = get_redis()
|
||||
# if redis:
|
||||
# dumped_room = str(redis.get(str(room)))
|
||||
# if dumped_room is not None:
|
||||
# resp = await Room.from_mpRoom(
|
||||
# MultiplayerRoom.model_validate_json(str(dumped_room)), db, fetcher
|
||||
# )
|
||||
# return resp
|
||||
# else:
|
||||
# raise HTTPException(status_code=404, detail="Room Not Found")
|
||||
# else:
|
||||
# raise HTTPException(status_code=500, detail="Redis error")
|
||||
|
||||
|
||||
class APICreatedRoom(Room):
|
||||
error: str | None
|
||||
# class APICreatedRoom(Room):
|
||||
# error: str | None
|
||||
|
||||
|
||||
@router.post("/rooms", tags=["beatmap"], response_model=APICreatedRoom)
|
||||
async def create_room(
|
||||
room: Room,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
fetcher: Fetcher = Depends(get_fetcher),
|
||||
):
|
||||
redis = get_redis()
|
||||
if redis:
|
||||
room_index = RoomIndex()
|
||||
db.add(room_index)
|
||||
await db.commit()
|
||||
await db.refresh(room_index)
|
||||
server_room = await MultiplayerRoom.from_apiRoom(room, db, fetcher)
|
||||
redis.set(str(room_index.id), server_room.model_dump_json())
|
||||
room.room_id = room_index.id
|
||||
return APICreatedRoom(**room.model_dump(), error=None)
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="redis error")
|
||||
# @router.post("/rooms", tags=["beatmap"], response_model=APICreatedRoom)
|
||||
# async def create_room(
|
||||
# room: Room,
|
||||
# db: AsyncSession = Depends(get_db),
|
||||
# fetcher: Fetcher = Depends(get_fetcher),
|
||||
# ):
|
||||
# redis = get_redis()
|
||||
# if redis:
|
||||
# room_index = RoomIndex()
|
||||
# db.add(room_index)
|
||||
# await db.commit()
|
||||
# await db.refresh(room_index)
|
||||
# server_room = await MultiplayerRoom.from_apiRoom(room, db, fetcher)
|
||||
# redis.set(str(room_index.id), server_room.model_dump_json())
|
||||
# room.room_id = room_index.id
|
||||
# return APICreatedRoom(**room.model_dump(), error=None)
|
||||
# else:
|
||||
# raise HTTPException(status_code=500, detail="redis error")
|
||||
|
||||
|
||||
@router.delete("/rooms/{room}", tags=["room"])
|
||||
async def remove_room(room: int, db: AsyncSession = Depends(get_db)):
|
||||
redis = get_redis()
|
||||
if redis:
|
||||
redis.delete(str(room))
|
||||
room_index = await db.get(RoomIndex, room)
|
||||
if room_index:
|
||||
await db.delete(room_index)
|
||||
await db.commit()
|
||||
# @router.delete("/rooms/{room}", tags=["room"])
|
||||
# async def remove_room(room: int, db: AsyncSession = Depends(get_db)):
|
||||
# redis = get_redis()
|
||||
# if redis:
|
||||
# redis.delete(str(room))
|
||||
# room_index = await db.get(RoomIndex, room)
|
||||
# if room_index:
|
||||
# await db.delete(room_index)
|
||||
# await db.commit()
|
||||
|
||||
@@ -1,6 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .hub import Hub
|
||||
from typing import override
|
||||
|
||||
from app.database import Room
|
||||
from app.database.playlists import Playlist
|
||||
from app.dependencies.database import engine
|
||||
from app.log import logger
|
||||
from app.models.multiplayer_hub import (
|
||||
MultiplayerClientState,
|
||||
MultiplayerRoom,
|
||||
MultiplayerRoomUser,
|
||||
ServerMultiplayerRoom,
|
||||
)
|
||||
from app.models.room import RoomCategory, RoomStatus
|
||||
from app.models.signalr import serialize_to_list
|
||||
from app.signalr.exception import InvokeException
|
||||
|
||||
from .hub import Client, Hub
|
||||
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
class MultiplayerHub(Hub): ...
|
||||
class MultiplayerHub(Hub[MultiplayerClientState]):
|
||||
@override
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.rooms: dict[int, ServerMultiplayerRoom] = {}
|
||||
|
||||
@staticmethod
|
||||
def group_id(room: int) -> str:
|
||||
return f"room:{room}"
|
||||
|
||||
@override
|
||||
def create_state(self, client: Client) -> MultiplayerClientState:
|
||||
return MultiplayerClientState(
|
||||
connection_id=client.connection_id,
|
||||
connection_token=client.connection_token,
|
||||
)
|
||||
|
||||
async def CreateRoom(self, client: Client, room: MultiplayerRoom):
|
||||
logger.info(f"[MultiplayerHub] {client.user_id} creating room")
|
||||
async with AsyncSession(engine) as session:
|
||||
async with session:
|
||||
db_room = Room(
|
||||
name=room.settings.name,
|
||||
category=RoomCategory.NORMAL,
|
||||
type=room.settings.match_type,
|
||||
queue_mode=room.settings.queue_mode,
|
||||
auto_skip=room.settings.auto_skip,
|
||||
auto_start_duration=room.settings.auto_start_duration,
|
||||
host_id=client.user_id,
|
||||
status=RoomStatus.IDLE,
|
||||
)
|
||||
session.add(db_room)
|
||||
await session.commit()
|
||||
await session.refresh(db_room)
|
||||
playitem = room.playlist[0]
|
||||
playitem.owner_id = client.user_id
|
||||
playitem.order = 1
|
||||
db_playlist = await Playlist.from_hub(playitem, db_room.id)
|
||||
session.add(db_playlist)
|
||||
room.room_id = db_room.id
|
||||
starts_at = db_room.starts_at
|
||||
await session.commit()
|
||||
await session.refresh(db_playlist)
|
||||
# room.playlist.append()
|
||||
server_room = ServerMultiplayerRoom(
|
||||
room=room,
|
||||
category=RoomCategory.NORMAL,
|
||||
status=RoomStatus.IDLE,
|
||||
start_at=starts_at,
|
||||
)
|
||||
self.rooms[room.room_id] = server_room
|
||||
return await self.JoinRoomWithPassword(
|
||||
client, room.room_id, room.settings.password
|
||||
)
|
||||
|
||||
async def JoinRoomWithPassword(self, client: Client, room_id: int, password: str):
|
||||
logger.info(f"[MultiplayerHub] {client.user_id} joining room {room_id}")
|
||||
store = self.get_or_create_state(client)
|
||||
if store.room_id != 0:
|
||||
raise InvokeException("You are already in a room")
|
||||
user = MultiplayerRoomUser(user_id=client.user_id)
|
||||
if room_id not in self.rooms:
|
||||
raise InvokeException("Room does not exist")
|
||||
server_room = self.rooms[room_id]
|
||||
room = server_room.room
|
||||
for u in room.users:
|
||||
if u.user_id == client.user_id:
|
||||
raise InvokeException("You are already in this room")
|
||||
if room.settings.password != password:
|
||||
raise InvokeException("Incorrect password")
|
||||
if room.host is None:
|
||||
# from CreateRoom
|
||||
room.host = user
|
||||
store.room_id = room_id
|
||||
await self.broadcast_group_call(
|
||||
self.group_id(room_id), "UserJoined", serialize_to_list(user)
|
||||
)
|
||||
room.users.append(user)
|
||||
self.add_to_group(client, self.group_id(room_id))
|
||||
return serialize_to_list(room)
|
||||
|
||||
7
main.py
7
main.py
@@ -6,7 +6,12 @@ from datetime import datetime
|
||||
from app.config import settings
|
||||
from app.dependencies.database import create_tables, engine, redis_client
|
||||
from app.dependencies.fetcher import get_fetcher
|
||||
from app.router import api_router, auth_router, fetcher_router, signalr_router
|
||||
from app.router import (
|
||||
api_router,
|
||||
auth_router,
|
||||
fetcher_router,
|
||||
signalr_router,
|
||||
)
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
Reference in New Issue
Block a user