diff --git a/app/database/__init__.py b/app/database/__init__.py index 6e2e8c5..2c01f7a 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -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", diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py new file mode 100644 index 0000000..5b4710a --- /dev/null +++ b/app/database/playlist_attempts.py @@ -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) diff --git a/app/database/playlists.py b/app/database/playlists.py new file mode 100644 index 0000000..42567b6 --- /dev/null +++ b/app/database/playlists.py @@ -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 diff --git a/app/database/room.py b/app/database/room.py index 0b79ee6..8eb882d 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -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 diff --git a/app/models/mods.py b/app/models/mods.py index abcd2cd..4b20138 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -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, + ) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py new file mode 100644 index 0000000..fa5e935 --- /dev/null +++ b/app/models/multiplayer_hub.py @@ -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 diff --git a/app/models/room.py b/app/models/room.py index 2d01a26..42f897c 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -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 diff --git a/app/router/__init__.py b/app/router/__init__.py index 1e87343..22f6c70 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -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", +] diff --git a/app/router/room.py b/app/router/room.py index a2347ec..ba909c6 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -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() diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 72b4a52..23ca69b 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -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) diff --git a/main.py b/main.py index 72444ef..b12f543 100644 --- a/main.py +++ b/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