From a4dbb9a167f6f41ec757967ff42cd08f88d91575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=92=95=E8=B0=B7=E9=85=B1?= <74496778+GooGuJiang@users.noreply.github.com> Date: Sun, 19 Oct 2025 00:05:06 +0800 Subject: [PATCH] feat(matchmaking): support matchmaking (#48) --- app/database/__init__.py | 8 ++ app/database/matchmaking.py | 106 ++++++++++++++++++ app/database/user.py | 2 + app/models/room.py | 1 + app/router/lio.py | 13 ++- app/router/v2/room.py | 4 +- ...-18_ceabe941b207_matchmaking_add_tables.py | 103 +++++++++++++++++ 7 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 app/database/matchmaking.py create mode 100644 migrations/versions/2025-10-18_ceabe941b207_matchmaking_add_tables.py diff --git a/app/database/__init__.py b/app/database/__init__.py index da18f70..6922184 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -33,6 +33,11 @@ from .item_attempts_count import ( ItemAttemptsResp, PlaylistAggregateScore, ) +from .matchmaking import ( + MatchmakingPool, + MatchmakingPoolBeatmap, + MatchmakingUserStats, +) from .multiplayer_event import MultiplayerEvent, MultiplayerEventResp from .notification import Notification, UserNotification from .password_reset import PasswordReset @@ -98,6 +103,9 @@ __all__ = [ "ItemAttemptsResp", "LoginSession", "LoginSessionResp", + "MatchmakingPool", + "MatchmakingPoolBeatmap", + "MatchmakingUserStats", "MeResp", "MonthlyPlaycounts", "MultiplayerEvent", diff --git a/app/database/matchmaking.py b/app/database/matchmaking.py new file mode 100644 index 0000000..3d55d8f --- /dev/null +++ b/app/database/matchmaking.py @@ -0,0 +1,106 @@ +from datetime import datetime +from typing import TYPE_CHECKING, Any, Optional + +from app.models.model import UTCBaseModel +from app.models.mods import APIMod + +from sqlalchemy import Column, DateTime, ForeignKey, Index, SmallInteger +from sqlmodel import ( + JSON, + BigInteger, + Field, + Relationship, + SQLModel, + func, +) + +if TYPE_CHECKING: + from .beatmap import Beatmap + from .user import User + + +class MatchmakingUserStatsBase(SQLModel, UTCBaseModel): + user_id: int = Field( + default=None, + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), primary_key=True), + ) + ruleset_id: int = Field( + default=None, + sa_column=Column(SmallInteger, primary_key=True), + ) + first_placements: int = Field(default=0, ge=0) + total_points: int = Field(default=0, ge=0) + elo_data: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + created_at: datetime | None = Field( + default=None, + sa_column=Column(DateTime(timezone=True), server_default=func.now()), + ) + updated_at: datetime | None = Field( + default=None, + sa_column=Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()), + ) + + +class MatchmakingUserStats(MatchmakingUserStatsBase, table=True): + __tablename__: str = "matchmaking_user_stats" + + user: "User" = Relationship(back_populates="matchmaking_stats", sa_relationship_kwargs={"lazy": "joined"}) + + +class MatchmakingPoolBase(SQLModel, UTCBaseModel): + id: int | None = Field(default=None, primary_key=True) + ruleset_id: int = Field( + default=0, + sa_column=Column(SmallInteger, nullable=False), + ) + variant_id: int = Field( + default=0, + ge=0, + sa_column=Column(SmallInteger, nullable=False, server_default="0"), + ) + name: str = Field(max_length=255) + active: bool = Field(default=True) + created_at: datetime | None = Field( + default=None, + sa_column=Column(DateTime(timezone=True), server_default=func.now()), + ) + updated_at: datetime | None = Field( + default=None, + sa_column=Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()), + ) + + +class MatchmakingPool(MatchmakingPoolBase, table=True): + __tablename__: str = "matchmaking_pools" + __table_args__ = (Index("matchmaking_pools_ruleset_variant_active_idx", "ruleset_id", "variant_id", "active"),) + + beatmaps: list["MatchmakingPoolBeatmap"] = Relationship( + back_populates="pool", + # sa_relationship_kwargs={ + # "lazy": "selectin", + # }, + ) + + +class MatchmakingPoolBeatmapBase(SQLModel, UTCBaseModel): + id: int | None = Field(default=None, primary_key=True) + pool_id: int = Field( + default=None, + sa_column=Column(ForeignKey("matchmaking_pools.id"), nullable=False, index=True), + ) + beatmap_id: int = Field( + default=None, + sa_column=Column(ForeignKey("beatmaps.id"), nullable=False), + ) + mods: list[APIMod] | None = Field(default=None, sa_column=Column(JSON)) + rating: int = Field(default=1500) + selection_count: int = Field(default=0) + + +class MatchmakingPoolBeatmap(MatchmakingPoolBeatmapBase, table=True): + __tablename__: str = "matchmaking_pool_beatmaps" + + pool: MatchmakingPool = Relationship(back_populates="beatmaps") + beatmap: Optional["Beatmap"] = Relationship( + # sa_relationship_kwargs={"lazy": "joined"}, + ) diff --git a/app/database/user.py b/app/database/user.py index 2474d31..fd40c35 100644 --- a/app/database/user.py +++ b/app/database/user.py @@ -42,6 +42,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: from .favourite_beatmapset import FavouriteBeatmapset + from .matchmaking import MatchmakingUserStats from .relationship import RelationshipResp @@ -154,6 +155,7 @@ class User(AsyncAttrs, UserBase, table=True): achievement: list[UserAchievement] = Relationship(back_populates="user") team_membership: TeamMember | None = Relationship(back_populates="user") daily_challenge_stats: DailyChallengeStats | None = Relationship(back_populates="user") + matchmaking_stats: list["MatchmakingUserStats"] = Relationship(back_populates="user") monthly_playcounts: list[MonthlyPlaycounts] = Relationship(back_populates="user") replays_watched_counts: list[ReplayWatchedCount] = Relationship(back_populates="user") favourite_beatmapsets: list["FavouriteBeatmapset"] = Relationship(back_populates="user") diff --git a/app/models/room.py b/app/models/room.py index 9b332a2..b2e8d13 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -15,6 +15,7 @@ class MatchType(str, Enum): PLAYLISTS = "playlists" HEAD_TO_HEAD = "head_to_head" TEAM_VERSUS = "team_versus" + MATCHMAKING = "matchmaking" class QueueMode(str, Enum): diff --git a/app/router/lio.py b/app/router/lio.py index 096a883..ec0ab7e 100644 --- a/app/router/lio.py +++ b/app/router/lio.py @@ -4,6 +4,7 @@ import base64 import json from typing import Any +from app.const import BANCHOBOT_ID from app.database.chat import ChannelType, ChatChannel # ChatChannel 模型 & 枚举 from app.database.playlists import Playlist as DBPlaylist from app.database.room import Room @@ -15,7 +16,7 @@ from app.dependencies.storage import StorageService from app.log import log from app.models.playlist import PlaylistItem from app.models.room import MatchType, QueueMode, RoomCategory, RoomStatus -from app.utils import utcnow +from app.utils import camel_to_snake, utcnow from .notification.server import server @@ -91,12 +92,12 @@ async def _validate_user_exists(db: Database, user_id: int) -> User: def _parse_room_enums(match_type: str, queue_mode: str) -> tuple[MatchType, QueueMode]: """Parse and validate room type enums.""" try: - match_type_enum = MatchType(match_type.lower()) + match_type_enum = MatchType(camel_to_snake(match_type)) except ValueError: match_type_enum = MatchType.HEAD_TO_HEAD try: - queue_mode_enum = QueueMode(queue_mode.lower()) + queue_mode_enum = QueueMode(camel_to_snake(queue_mode)) except ValueError: queue_mode_enum = QueueMode.HOST_ONLY @@ -157,10 +158,10 @@ def _validate_playlist_items(items: list[dict[str, Any]]) -> None: async def _create_room(db: Database, room_data: dict[str, Any]) -> tuple[Room, int]: - host_user_id = room_data.get("user_id") - room_name = room_data.get("name", "Unnamed Room") + host_user_id = room_data.get("user_id", BANCHOBOT_ID) + match_type = room_data.get("match_type", "HeadToHead" if host_user_id != BANCHOBOT_ID else "Matchmaking") + room_name = room_data.get("name", f"{match_type} room: {utcnow().isoformat()}") password = room_data.get("password") - match_type = room_data.get("match_type", "HeadToHead") queue_mode = room_data.get("queue_mode", "HostOnly") if not host_user_id or not isinstance(host_user_id, int): diff --git a/app/router/v2/room.py b/app/router/v2/room.py index 45fc74e..b8d06ed 100644 --- a/app/router/v2/room.py +++ b/app/router/v2/room.py @@ -12,7 +12,7 @@ from app.database.score import Score from app.database.user import User, UserResp from app.dependencies.database import Database, Redis from app.dependencies.user import ClientUser, get_current_user -from app.models.room import RoomCategory, RoomStatus +from app.models.room import MatchType, RoomCategory, RoomStatus from app.service.room import create_playlist_room_from_api from app.utils import utcnow @@ -50,7 +50,7 @@ async def get_all_rooms( status: Annotated[RoomStatus | None, Query(description="房间状态(可选)")] = None, ): resp_list: list[RoomResp] = [] - where_clauses: list[ColumnElement[bool]] = [col(Room.category) == category] + where_clauses: list[ColumnElement[bool]] = [col(Room.category) == category, col(Room.type) != MatchType.MATCHMAKING] now = utcnow() if status is not None: diff --git a/migrations/versions/2025-10-18_ceabe941b207_matchmaking_add_tables.py b/migrations/versions/2025-10-18_ceabe941b207_matchmaking_add_tables.py new file mode 100644 index 0000000..0e9040d --- /dev/null +++ b/migrations/versions/2025-10-18_ceabe941b207_matchmaking_add_tables.py @@ -0,0 +1,103 @@ +"""matchmaking: add tables + +Revision ID: ceabe941b207 +Revises: 48fb754416de +Create Date: 2025-10-18 13:52:28.005667 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = "ceabe941b207" +down_revision: str | Sequence[str] | None = "48fb754416de" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "matchmaking_pools", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("ruleset_id", sa.SmallInteger(), nullable=False), + sa.Column("variant_id", sa.SmallInteger(), server_default="0", nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "matchmaking_pools_ruleset_variant_active_idx", + "matchmaking_pools", + ["ruleset_id", "variant_id", "active"], + unique=False, + ) + op.create_table( + "matchmaking_user_stats", + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("ruleset_id", sa.SmallInteger(), nullable=False), + sa.Column("first_placements", sa.Integer(), nullable=False), + sa.Column("total_points", sa.Integer(), nullable=False), + sa.Column("elo_data", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("user_id", "ruleset_id"), + ) + op.create_table( + "matchmaking_pool_beatmaps", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("pool_id", sa.Integer(), nullable=False), + sa.Column("beatmap_id", sa.Integer(), nullable=False), + sa.Column("mods", sa.JSON(), nullable=True), + sa.Column("rating", sa.Integer(), nullable=False), + sa.Column("selection_count", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["beatmap_id"], + ["beatmaps.id"], + ), + sa.ForeignKeyConstraint( + ["pool_id"], + ["matchmaking_pools.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_matchmaking_pool_beatmaps_pool_id"), "matchmaking_pool_beatmaps", ["pool_id"], unique=False + ) + op.alter_column( + "rooms", + "type", + existing_type=mysql.ENUM("PLAYLISTS", "HEAD_TO_HEAD", "TEAM_VERSUS", name="matchtype"), + type_=mysql.ENUM("PLAYLISTS", "HEAD_TO_HEAD", "TEAM_VERSUS", "MATCHMAKING", name="matchtype"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.execute("UPDATE rooms SET type='TEAM_VERSUS' WHERE type='MATCHMAKING'") + op.alter_column( + "rooms", + "type", + existing_type=mysql.ENUM("PLAYLISTS", "HEAD_TO_HEAD", "TEAM_VERSUS", "MATCHMAKING", name="matchtype"), + type_=mysql.ENUM("PLAYLISTS", "HEAD_TO_HEAD", "TEAM_VERSUS", name="matchtype"), + ) + op.drop_index(op.f("ix_matchmaking_pool_beatmaps_pool_id"), table_name="matchmaking_pool_beatmaps") + op.drop_table("matchmaking_pool_beatmaps") + op.drop_table("matchmaking_user_stats") + op.drop_index("matchmaking_pools_ruleset_variant_active_idx", table_name="matchmaking_pools") + op.drop_table("matchmaking_pools") + # ### end Alembic commands ###