feat(matchmaking): support matchmaking (#48)

This commit is contained in:
咕谷酱
2025-10-19 00:05:06 +08:00
committed by GitHub
parent b180d3f39d
commit a4dbb9a167
7 changed files with 229 additions and 8 deletions

View File

@@ -33,6 +33,11 @@ from .item_attempts_count import (
ItemAttemptsResp, ItemAttemptsResp,
PlaylistAggregateScore, PlaylistAggregateScore,
) )
from .matchmaking import (
MatchmakingPool,
MatchmakingPoolBeatmap,
MatchmakingUserStats,
)
from .multiplayer_event import MultiplayerEvent, MultiplayerEventResp from .multiplayer_event import MultiplayerEvent, MultiplayerEventResp
from .notification import Notification, UserNotification from .notification import Notification, UserNotification
from .password_reset import PasswordReset from .password_reset import PasswordReset
@@ -98,6 +103,9 @@ __all__ = [
"ItemAttemptsResp", "ItemAttemptsResp",
"LoginSession", "LoginSession",
"LoginSessionResp", "LoginSessionResp",
"MatchmakingPool",
"MatchmakingPoolBeatmap",
"MatchmakingUserStats",
"MeResp", "MeResp",
"MonthlyPlaycounts", "MonthlyPlaycounts",
"MultiplayerEvent", "MultiplayerEvent",

106
app/database/matchmaking.py Normal file
View File

@@ -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"},
)

View File

@@ -42,6 +42,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
if TYPE_CHECKING: if TYPE_CHECKING:
from .favourite_beatmapset import FavouriteBeatmapset from .favourite_beatmapset import FavouriteBeatmapset
from .matchmaking import MatchmakingUserStats
from .relationship import RelationshipResp from .relationship import RelationshipResp
@@ -154,6 +155,7 @@ class User(AsyncAttrs, UserBase, table=True):
achievement: list[UserAchievement] = Relationship(back_populates="user") achievement: list[UserAchievement] = Relationship(back_populates="user")
team_membership: TeamMember | None = Relationship(back_populates="user") team_membership: TeamMember | None = Relationship(back_populates="user")
daily_challenge_stats: DailyChallengeStats | 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") monthly_playcounts: list[MonthlyPlaycounts] = Relationship(back_populates="user")
replays_watched_counts: list[ReplayWatchedCount] = Relationship(back_populates="user") replays_watched_counts: list[ReplayWatchedCount] = Relationship(back_populates="user")
favourite_beatmapsets: list["FavouriteBeatmapset"] = Relationship(back_populates="user") favourite_beatmapsets: list["FavouriteBeatmapset"] = Relationship(back_populates="user")

View File

@@ -15,6 +15,7 @@ class MatchType(str, Enum):
PLAYLISTS = "playlists" PLAYLISTS = "playlists"
HEAD_TO_HEAD = "head_to_head" HEAD_TO_HEAD = "head_to_head"
TEAM_VERSUS = "team_versus" TEAM_VERSUS = "team_versus"
MATCHMAKING = "matchmaking"
class QueueMode(str, Enum): class QueueMode(str, Enum):

View File

@@ -4,6 +4,7 @@ import base64
import json import json
from typing import Any from typing import Any
from app.const import BANCHOBOT_ID
from app.database.chat import ChannelType, ChatChannel # ChatChannel 模型 & 枚举 from app.database.chat import ChannelType, ChatChannel # ChatChannel 模型 & 枚举
from app.database.playlists import Playlist as DBPlaylist from app.database.playlists import Playlist as DBPlaylist
from app.database.room import Room from app.database.room import Room
@@ -15,7 +16,7 @@ from app.dependencies.storage import StorageService
from app.log import log from app.log import log
from app.models.playlist import PlaylistItem from app.models.playlist import PlaylistItem
from app.models.room import MatchType, QueueMode, RoomCategory, RoomStatus 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 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]: def _parse_room_enums(match_type: str, queue_mode: str) -> tuple[MatchType, QueueMode]:
"""Parse and validate room type enums.""" """Parse and validate room type enums."""
try: try:
match_type_enum = MatchType(match_type.lower()) match_type_enum = MatchType(camel_to_snake(match_type))
except ValueError: except ValueError:
match_type_enum = MatchType.HEAD_TO_HEAD match_type_enum = MatchType.HEAD_TO_HEAD
try: try:
queue_mode_enum = QueueMode(queue_mode.lower()) queue_mode_enum = QueueMode(camel_to_snake(queue_mode))
except ValueError: except ValueError:
queue_mode_enum = QueueMode.HOST_ONLY 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]: async def _create_room(db: Database, room_data: dict[str, Any]) -> tuple[Room, int]:
host_user_id = room_data.get("user_id") host_user_id = room_data.get("user_id", BANCHOBOT_ID)
room_name = room_data.get("name", "Unnamed Room") 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") password = room_data.get("password")
match_type = room_data.get("match_type", "HeadToHead")
queue_mode = room_data.get("queue_mode", "HostOnly") queue_mode = room_data.get("queue_mode", "HostOnly")
if not host_user_id or not isinstance(host_user_id, int): if not host_user_id or not isinstance(host_user_id, int):

View File

@@ -12,7 +12,7 @@ from app.database.score import Score
from app.database.user import User, UserResp from app.database.user import User, UserResp
from app.dependencies.database import Database, Redis from app.dependencies.database import Database, Redis
from app.dependencies.user import ClientUser, get_current_user 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.service.room import create_playlist_room_from_api
from app.utils import utcnow from app.utils import utcnow
@@ -50,7 +50,7 @@ async def get_all_rooms(
status: Annotated[RoomStatus | None, Query(description="房间状态(可选)")] = None, status: Annotated[RoomStatus | None, Query(description="房间状态(可选)")] = None,
): ):
resp_list: list[RoomResp] = [] 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() now = utcnow()
if status is not None: if status is not None:

View File

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