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,
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",

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:
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")

View File

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

View File

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

View File

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

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