feat(matchmaking): support matchmaking (#48)
This commit is contained in:
@@ -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
106
app/database/matchmaking.py
Normal 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"},
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ###
|
||||
Reference in New Issue
Block a user