From b359be363746cfa4ff920836734c74cc6baa2626 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 02:33:42 +0000 Subject: [PATCH 01/16] feat(solo-score): support submit solo scores --- app/database/__init__.py | 13 + app/database/beatmap.py | 4 + app/database/beatmapset.py | 3 +- app/database/score.py | 259 ++- app/database/score_token.py | 47 + app/database/team.py | 7 +- app/database/user.py | 10 +- app/dependencies/user.py | 2 +- app/models/score.py | 209 +- app/path.py | 7 + app/router/beatmap.py | 5 +- app/utils.py | 7 +- static/README.md | 5 + static/mods.json | 3656 +++++++++++++++++++++++++++++++++++ 14 files changed, 4170 insertions(+), 64 deletions(-) create mode 100644 app/database/score_token.py create mode 100644 app/path.py create mode 100644 static/README.md create mode 100644 static/mods.json diff --git a/app/database/__init__.py b/app/database/__init__.py index b7df7d6..65ca463 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -9,6 +9,13 @@ from .beatmapset import ( ) from .legacy import LegacyOAuthToken, LegacyUserStatistics from .relationship import Relationship, RelationshipResp, RelationshipType +from .score import ( + Score, + ScoreBase, + ScoreResp, + ScoreStatistics, +) +from .score_token import ScoreToken, ScoreTokenResp from .team import Team, TeamMember from .user import ( DailyChallengeStats, @@ -57,6 +64,12 @@ __all__ = [ "Relationship", "RelationshipResp", "RelationshipType", + "Score", + "ScoreBase", + "ScoreResp", + "ScoreStatistics", + "ScoreToken", + "ScoreTokenResp", "Team", "TeamMember", "User", diff --git a/app/database/beatmap.py b/app/database/beatmap.py index e5b230b..ea15799 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -65,6 +65,10 @@ class Beatmap(BeatmapBase, table=True): # optional beatmapset: Beatmapset = Relationship(back_populates="beatmaps") + @property + def can_ranked(self) -> bool: + return self.beatmap_status > BeatmapRankStatus.PENDING + @classmethod async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap": d = resp.model_dump() diff --git a/app/database/beatmapset.py b/app/database/beatmapset.py index f978814..1e6ba27 100644 --- a/app/database/beatmapset.py +++ b/app/database/beatmapset.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import TYPE_CHECKING, TypedDict, cast from app.models.beatmap import BeatmapRankStatus, Genre, Language +from app.models.score import GameMode from pydantic import BaseModel, model_serializer from sqlalchemy import DECIMAL, JSON, Column, DateTime, Text @@ -68,7 +69,7 @@ class BeatmapNomination(TypedDict): beatmapset_id: int reset: bool user_id: int - rulesets: list[str] | None + rulesets: list[GameMode] | None class BeatmapDescription(SQLModel): diff --git a/app/database/score.py b/app/database/score.py index 82e9f34..f82a813 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -2,15 +2,34 @@ from datetime import datetime import math from app.database.user import User +from app.models.beatmap import BeatmapRankStatus from app.models.mods import APIMod -from app.models.score import MODE_TO_INT, GameMode, Rank +from app.models.score import ( + MODE_TO_INT, + GameMode, + HitResult, + LeaderboardType, + Rank, + ScoreStatistics, +) from .beatmap import Beatmap, BeatmapResp from .beatmapset import BeatmapsetResp -from pydantic import BaseModel from sqlalchemy import Column, DateTime -from sqlmodel import JSON, BigInteger, Field, Relationship, SQLModel +from sqlalchemy.orm import joinedload +from sqlmodel import ( + JSON, + BigInteger, + Field, + Relationship, + SQLModel, + col, + false, + func, + select, +) +from sqlmodel.ext.asyncio.session import AsyncSession class ScoreBase(SQLModel): @@ -34,6 +53,9 @@ class ScoreBase(SQLModel): room_id: int | None = Field(default=None) # multiplayer started_at: datetime = Field(sa_column=Column(DateTime)) total_score: int = Field(default=0, sa_column=Column(BigInteger)) + total_score_without_mods: int = Field( + default=0, sa_column=Column(BigInteger), exclude=True + ) type: str # optional @@ -41,20 +63,11 @@ class ScoreBase(SQLModel): position: int | None = Field(default=None) # multiplayer -class ScoreStatistics(BaseModel): - count_miss: int - count_50: int - count_100: int - count_300: int - count_geki: int - count_katu: int - count_large_tick_miss: int | None = None - count_slider_tail_hit: int | None = None - - class Score(ScoreBase, table=True): __tablename__ = "scores" # pyright: ignore[reportAssignmentType] - id: int = Field(primary_key=True) + id: int | None = Field( + default=None, sa_column=Column(BigInteger, autoincrement=True, primary_key=True) + ) beatmap_id: int = Field(index=True, foreign_key="beatmaps.id") user_id: int = Field(foreign_key="users.id", index=True) # ScoreStatistics @@ -72,6 +85,10 @@ class Score(ScoreBase, table=True): beatmap: "Beatmap" = Relationship() user: "User" = Relationship() + @property + def is_perfect_combo(self) -> bool: + return self.max_combo == self.beatmap.max_combo + class ScoreResp(ScoreBase): id: int @@ -85,10 +102,13 @@ class ScoreResp(ScoreBase): beatmapset: BeatmapsetResp | None = None # FIXME: user: APIUser | None = None statistics: ScoreStatistics | None = None + rank_global: int | None = None + rank_country: int | None = None @classmethod - def from_db(cls, score: Score) -> "ScoreResp": + async def from_db(cls, session: AsyncSession, score: Score) -> "ScoreResp": s = cls.model_validate(score.model_dump()) + assert score.id s.beatmap = BeatmapResp.from_db(score.beatmap) s.beatmapset = BeatmapsetResp.from_db(score.beatmap.beatmapset) s.is_perfect_combo = s.max_combo == s.beatmap.max_combo @@ -97,14 +117,203 @@ class ScoreResp(ScoreBase): if score.best_id: # https://osu.ppy.sh/wiki/Performance_points/Weighting_system s.weight = math.pow(0.95, score.best_id) - s.statistics = ScoreStatistics( - count_miss=score.nmiss, - count_50=score.n50, - count_100=score.n100, - count_300=score.n300, - count_geki=score.ngeki, - count_katu=score.nkatu, - count_large_tick_miss=score.nlarge_tick_miss, - count_slider_tail_hit=score.nslider_tail_hit, + s.statistics = { + HitResult.MISS: score.nmiss, + HitResult.MEH: score.n50, + HitResult.OK: score.n100, + HitResult.GREAT: score.n300, + HitResult.PERFECT: score.ngeki, + HitResult.GOOD: score.nkatu, + } + if score.nlarge_tick_miss is not None: + s.statistics[HitResult.LARGE_TICK_MISS] = score.nlarge_tick_miss + if score.nslider_tail_hit is not None: + s.statistics[HitResult.SLIDER_TAIL_HIT] = score.nslider_tail_hit + # s.user = await convert_db_user_to_api_user(score.user) + s.rank_global = ( + await get_score_position_by_id( + session, + score.map_md5, + score.id, + mode=score.gamemode, + user=score.user, + ) + or None + ) + s.rank_country = ( + await get_score_position_by_id( + session, + score.map_md5, + score.id, + score.gamemode, + score.user, + ) + or None ) return s + + +async def get_leaderboard( + session: AsyncSession, + beatmap_md5: str, + mode: GameMode, + type: LeaderboardType = LeaderboardType.GLOBAL, + mods: list[APIMod] | None = None, + user: User | None = None, + limit: int = 50, +) -> list[Score]: + scores = [] + if type == LeaderboardType.GLOBAL: + query = ( + select(Score) + .where( + col(Beatmap.beatmap_status).in_( + [ + BeatmapRankStatus.RANKED, + BeatmapRankStatus.LOVED, + BeatmapRankStatus.QUALIFIED, + BeatmapRankStatus.APPROVED, + ] + ), + Score.map_md5 == beatmap_md5, + Score.gamemode == mode, + col(Score.passed).is_(True), + Score.mods == mods if user and user.is_supporter else false(), + ) + .limit(limit) + .order_by( + col(Score.total_score).desc(), + ) + ) + result = await session.exec(query) + scores = list[Score](result.all()) + elif type == LeaderboardType.FRIENDS and user and user.is_supporter: + # TODO + ... + elif type == LeaderboardType.TEAM and user and user.team_membership: + team_id = user.team_membership.team_id + query = ( + select(Score) + .join(Beatmap) + .options(joinedload(Score.user)) # pyright: ignore[reportArgumentType] + .where( + Score.map_md5 == beatmap_md5, + Score.gamemode == mode, + col(Score.passed).is_(True), + col(Score.user.team_membership).is_not(None), + Score.user.team_membership.team_id == team_id, # pyright: ignore[reportOptionalMemberAccess] + Score.mods == mods if user and user.is_supporter else false(), + ) + .limit(limit) + .order_by( + col(Score.total_score).desc(), + ) + ) + result = await session.exec(query) + scores = list[Score](result.all()) + if user: + user_score = ( + await session.exec( + select(Score).where( + Score.map_md5 == beatmap_md5, + Score.gamemode == mode, + Score.user_id == user.id, + col(Score.passed).is_(True), + ) + ) + ).first() + if user_score and user_score not in scores: + scores.append(user_score) + return scores + + +async def get_score_position_by_user( + session: AsyncSession, + beatmap_md5: str, + user: User, + mode: GameMode, + type: LeaderboardType = LeaderboardType.GLOBAL, + mods: list[APIMod] | None = None, +) -> int: + where_clause = [ + Score.map_md5 == beatmap_md5, + Score.gamemode == mode, + col(Score.passed).is_(True), + col(Beatmap.beatmap_status).in_( + [ + BeatmapRankStatus.RANKED, + BeatmapRankStatus.LOVED, + BeatmapRankStatus.QUALIFIED, + BeatmapRankStatus.APPROVED, + ] + ), + ] + if mods and user.is_supporter: + where_clause.append(Score.mods == mods) + else: + where_clause.append(false()) + if type == LeaderboardType.FRIENDS and user.is_supporter: + # TODO + ... + elif type == LeaderboardType.TEAM and user.team_membership: + team_id = user.team_membership.team_id + where_clause.append( + col(Score.user.team_membership).is_not(None), + ) + where_clause.append( + Score.user.team_membership.team_id == team_id, # pyright: ignore[reportOptionalMemberAccess] + ) + rownum = ( + func.row_number() + .over( + partition_by=Score.map_md5, + order_by=col(Score.total_score).desc(), + ) + .label("row_number") + ) + subq = select(Score, rownum).join(Beatmap).where(*where_clause).subquery() + stmt = select(subq.c.row_number).where(subq.c.user == user) + result = await session.exec(stmt) + s = result.one_or_none() + return s if s else 0 + + +async def get_score_position_by_id( + session: AsyncSession, + beatmap_md5: str, + score_id: int, + mode: GameMode, + user: User | None = None, + type: LeaderboardType = LeaderboardType.GLOBAL, + mods: list[APIMod] | None = None, +) -> int: + where_clause = [ + Score.map_md5 == beatmap_md5, + Score.id == score_id, + Score.gamemode == mode, + col(Beatmap.beatmap_status).in_( + [ + BeatmapRankStatus.RANKED, + BeatmapRankStatus.LOVED, + BeatmapRankStatus.QUALIFIED, + BeatmapRankStatus.APPROVED, + ] + ), + ] + if mods and user and user.is_supporter: + where_clause.append(Score.mods == mods) + elif mods: + where_clause.append(false()) + rownum = ( + func.row_number() + .over( + partition_by=Score.map_md5, + order_by=col(Score.total_score).desc(), + ) + .label("row_number") + ) + subq = select(Score, rownum).join(Beatmap).where(*where_clause).subquery() + stmt = select(subq.c.row_number).where(subq.c.id == score_id) + result = await session.exec(stmt) + s = result.one_or_none() + return s if s else 0 diff --git a/app/database/score_token.py b/app/database/score_token.py new file mode 100644 index 0000000..195a174 --- /dev/null +++ b/app/database/score_token.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from app.models.score import GameMode + +from .beatmap import Beatmap +from .user import User + +from sqlalchemy import Column, DateTime, Index +from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel + + +class ScoreTokenBase(SQLModel): + score_id: int | None = Field(sa_column=Column(BigInteger), default=None) + ruleset_id: GameMode + playlist_item_id: int | None = Field(default=None) # playlist + created_at: datetime = Field( + default_factory=datetime.utcnow, sa_column=Column(DateTime) + ) + updated_at: datetime = Field( + default_factory=datetime.utcnow, sa_column=Column(DateTime) + ) + + +class ScoreToken(ScoreTokenBase, table=True): + __tablename__ = "score_tokens" # pyright: ignore[reportAssignmentType] + __table_args__ = (Index("idx_user_playlist", "user_id", "playlist_item_id"),) + + id: int | None = Field( + default=None, + primary_key=True, + index=True, + sa_column_kwargs={"autoincrement": True}, + ) + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) + beatmap_id: int = Field(sa_column=Column(BigInteger, ForeignKey("beatmaps.id"))) + user: "User" = Relationship() + beatmap: "Beatmap" = Relationship() + + +class ScoreTokenResp(ScoreTokenBase): + id: int + user_id: int + beatmap_id: int + + @classmethod + def from_db(cls, obj: ScoreToken) -> "ScoreTokenResp": + return cls.model_validate(obj) diff --git a/app/database/team.py b/app/database/team.py index 5dabf71..2722319 100644 --- a/app/database/team.py +++ b/app/database/team.py @@ -2,7 +2,6 @@ from datetime import datetime from typing import TYPE_CHECKING from sqlalchemy import Column, DateTime -from sqlalchemy.orm import Mapped from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: @@ -20,7 +19,7 @@ class Team(SQLModel, table=True): default_factory=datetime.utcnow, sa_column=Column(DateTime) ) - members: Mapped[list["TeamMember"]] = Relationship(back_populates="team") + members: list["TeamMember"] = Relationship(back_populates="team") class TeamMember(SQLModel, table=True): @@ -33,5 +32,5 @@ class TeamMember(SQLModel, table=True): default_factory=datetime.utcnow, sa_column=Column(DateTime) ) - user: Mapped["User"] = Relationship(back_populates="team_membership") - team: Mapped["Team"] = Relationship(back_populates="members") + user: "User" = Relationship(back_populates="team_membership") + team: "Team" = Relationship(back_populates="members") diff --git a/app/database/user.py b/app/database/user.py index 8b6fe02..71a6eda 100644 --- a/app/database/user.py +++ b/app/database/user.py @@ -14,7 +14,9 @@ class User(SQLModel, table=True): __tablename__ = "users" # pyright: ignore[reportAssignmentType] # 主键 - id: int = Field(default=None, primary_key=True, index=True, nullable=False) + id: int = Field( + default=None, sa_column=Column(BigInteger, primary_key=True, index=True) + ) # 基本信息(匹配 migrations 中的结构) name: str = Field(max_length=32, unique=True, index=True) # 用户名 @@ -65,6 +67,10 @@ class User(SQLModel, table=True): latest_activity = getattr(self, "latest_activity", 0) return datetime.fromtimestamp(latest_activity) if latest_activity > 0 else None + @property + def is_supporter(self): + return self.lazer_profile.is_supporter if self.lazer_profile else False + # 关联关系 lazer_profile: Optional["LazerUserProfile"] = Relationship(back_populates="user") lazer_statistics: list["LazerUserStatistics"] = Relationship(back_populates="user") @@ -76,7 +82,7 @@ class User(SQLModel, table=True): back_populates="user" ) statistics: list["LegacyUserStatistics"] = Relationship(back_populates="user") - team_membership: list["TeamMember"] = Relationship(back_populates="user") + team_membership: Optional["TeamMember"] = Relationship(back_populates="user") daily_challenge_stats: Optional["DailyChallengeStats"] = Relationship( back_populates="user" ) diff --git a/app/dependencies/user.py b/app/dependencies/user.py index 5c3b396..8d3bb74 100644 --- a/app/dependencies/user.py +++ b/app/dependencies/user.py @@ -45,7 +45,7 @@ async def get_current_user_by_token(token: str, db: AsyncSession) -> DBUser | No selectinload(DBUser.lazer_achievements), # pyright: ignore[reportArgumentType] selectinload(DBUser.lazer_profile_sections), # pyright: ignore[reportArgumentType] selectinload(DBUser.statistics), # pyright: ignore[reportArgumentType] - selectinload(DBUser.team_membership), # pyright: ignore[reportArgumentType] + joinedload(DBUser.team_membership), # pyright: ignore[reportArgumentType] selectinload(DBUser.rank_history), # pyright: ignore[reportArgumentType] selectinload(DBUser.active_banners), # pyright: ignore[reportArgumentType] selectinload(DBUser.lazer_badges), # pyright: ignore[reportArgumentType] diff --git a/app/models/score.py b/app/models/score.py index 50c80f7..680afd6 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -1,7 +1,12 @@ from __future__ import annotations -from enum import Enum, IntEnum +from enum import Enum +import json +from typing import Any, Literal, TypedDict +from app.path import STATIC_DIR + +from pydantic import BaseModel, Field, ValidationInfo, field_validator import rosu_pp_py as rosu @@ -30,40 +35,186 @@ INT_TO_MODE = {v: k for k, v in MODE_TO_INT.items()} class Rank(str, Enum): - X = "ss" - XH = "ssh" - S = "s" - SH = "sh" - A = "a" - B = "b" - C = "c" - D = "d" - F = "f" + X = "X" + XH = "XH" + S = "S" + SH = "SH" + A = "A" + B = "B" + C = "C" + D = "D" + F = "F" + + +class APIMod(TypedDict, total=False): + acronym: str + settings: dict[str, Any] + + +legacy_mod: dict[str, int] = { + "NF": 1 << 0, # No Fail + "EZ": 1 << 1, # Easy + "TD": 1 << 2, # Touch Device + "HD": 1 << 3, # Hidden + "HR": 1 << 4, # Hard Rock + "SD": 1 << 5, # Sudden Death + "DT": 1 << 6, # Double Time + "RX": 1 << 7, # Relax + "HT": 1 << 8, # Half Time + "NC": 1 << 9, # Nightcore + "FL": 1 << 10, # Flashlight + "AT": 1 << 11, # Autoplay + "SO": 1 << 12, # Spun Out + "AP": 1 << 13, # Auto Pilot + "PF": 1 << 14, # Perfect + "4K": 1 << 15, # 4K + "5K": 1 << 16, # 5K + "6K": 1 << 17, # 6K + "7K": 1 << 18, # 7K + "8K": 1 << 19, # 8K + "FI": 1 << 20, # Fade In + "RD": 1 << 21, # Random + "CN": 1 << 22, # Cinema + "TP": 1 << 23, # Target Practice + "9K": 1 << 24, # 9K + "CO": 1 << 25, # Key Co-op + "1K": 1 << 26, # 1K + "3K": 1 << 27, # 3K + "2K": 1 << 28, # 2K + "SV2": 1 << 29, # ScoreV2 + "MR": 1 << 30, # Mirror +} +legacy_mod["NC"] |= legacy_mod["DT"] +legacy_mod["PF"] |= legacy_mod["SD"] + + +def api_mod_to_int(mods: list[APIMod]) -> int: + sum_ = 0 + for mod in mods: + sum_ |= legacy_mod.get(mod["acronym"], 0) + return sum_ # https://github.com/ppy/osu/blob/master/osu.Game/Rulesets/Scoring/HitResult.cs -class HitResult(IntEnum): - PERFECT = 0 # [Order(0)] - GREAT = 1 # [Order(1)] - GOOD = 2 # [Order(2)] - OK = 3 # [Order(3)] - MEH = 4 # [Order(4)] - MISS = 5 # [Order(5)] +class HitResult(str, Enum): + PERFECT = "perfect" # [Order(0)] + GREAT = "great" # [Order(1)] + GOOD = "good" # [Order(2)] + OK = "ok" # [Order(3)] + MEH = "meh" # [Order(4)] + MISS = "miss" # [Order(5)] - LARGE_TICK_HIT = 6 # [Order(6)] - SMALL_TICK_HIT = 7 # [Order(7)] - SLIDER_TAIL_HIT = 8 # [Order(8)] + LARGE_TICK_HIT = "large_tick_hit" # [Order(6)] + SMALL_TICK_HIT = "small_tick_hit" # [Order(7)] + SLIDER_TAIL_HIT = "slider_tail_hit" # [Order(8)] - LARGE_BONUS = 9 # [Order(9)] - SMALL_BONUS = 10 # [Order(10)] + LARGE_BONUS = "large_bonus" # [Order(9)] + SMALL_BONUS = "small_bonus" # [Order(10)] - LARGE_TICK_MISS = 11 # [Order(11)] - SMALL_TICK_MISS = 12 # [Order(12)] + LARGE_TICK_MISS = "large_tick_miss" # [Order(11)] + SMALL_TICK_MISS = "small_tick_miss" # [Order(12)] - IGNORE_HIT = 13 # [Order(13)] - IGNORE_MISS = 14 # [Order(14)] + IGNORE_HIT = "ignore_hit" # [Order(13)] + IGNORE_MISS = "ignore_miss" # [Order(14)] - NONE = 15 # [Order(15)] - COMBO_BREAK = 16 # [Order(16)] + NONE = "none" # [Order(15)] + COMBO_BREAK = "combo_break" # [Order(16)] - LEGACY_COMBO_INCREASE = 99 # [Order(99)] @deprecated + LEGACY_COMBO_INCREASE = "legacy_combo_increase" # [Order(99)] @deprecated + + def is_hit(self) -> bool: + return self not in ( + HitResult.NONE, + HitResult.IGNORE_MISS, + HitResult.COMBO_BREAK, + HitResult.LARGE_TICK_MISS, + HitResult.SMALL_TICK_MISS, + HitResult.MISS, + ) + + +class LeaderboardType(Enum): + GLOBAL = "global" + FRIENDS = "friends" + COUNTRY = "country" + TEAM = "team" + + +# see static/mods.json +class Settings(TypedDict): + Name: str + Type: str + Label: str + Description: str + + +class Mod(TypedDict): + Acronym: str + Name: str + Description: str + Type: str + Settings: list[Settings] + IncompatibleMods: list[str] + RequiresConfiguration: bool + UserPlayable: bool + ValidForMultiplayer: bool + ValidForFreestyleAsRequiredMod: bool + ValidForMultiplayerAsFreeMod: bool + AlwaysValidForSubmission: bool + + +MODS: dict[int, dict[str, Mod]] = {} + +ScoreStatistics = dict[HitResult, int] + + +def _init_mods(): + mods_file = STATIC_DIR / "mods.json" + raw_mods = json.loads(mods_file.read_text()) + for ruleset in raw_mods: + ruleset_mods = {} + for mod in ruleset["Mods"]: + ruleset_mods[mod["Acronym"]] = mod + MODS[ruleset["RulesetID"]] = ruleset_mods + + +class SoloScoreSubmissionInfo(BaseModel): + rank: Rank + total_score: int = Field(ge=0, le=2**31 - 1) + total_score_without_mods: int = Field(ge=0, le=2**31 - 1) + accuracy: float = Field(ge=0, le=1) + pp: float = Field(default=0, ge=0, le=2**31 - 1) + max_combo: int = 0 + ruleset_id: Literal[0, 1, 2, 3] + passed: bool = False + mods: list[APIMod] = Field(default_factory=list) + statistics: ScoreStatistics = Field(default_factory=dict) + maximum_statistics: ScoreStatistics = Field(default_factory=dict) + + @field_validator("mods", mode="after") + @classmethod + def validate_mods(cls, mods: list[APIMod], info: ValidationInfo): + if not MODS: + _init_mods() + incompatible_mods = set() + # check incompatible mods + for mod in mods: + if mod["acronym"] in incompatible_mods: + raise ValueError( + f"Mod {mod['acronym']} is incompatible with other mods" + ) + setting_mods = MODS[info.data["ruleset_id"]].get(mod["acronym"]) + if not setting_mods: + raise ValueError(f"Invalid mod: {mod['acronym']}") + incompatible_mods.update(setting_mods["IncompatibleMods"]) + + +class LegacyReplaySoloScoreInfo(TypedDict): + online_id: int + mods: list[APIMod] + statistics: ScoreStatistics + maximum_statistics: ScoreStatistics + client_version: str + rank: Rank + user_id: int + total_score_without_mods: int diff --git a/app/path.py b/app/path.py new file mode 100644 index 0000000..b61309c --- /dev/null +++ b/app/path.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from pathlib import Path + +STATIC_DIR = Path(__file__).parent.parent / "static" + +REPLAY_DIR = Path(__file__).parent.parent / "replays" diff --git a/app/router/beatmap.py b/app/router/beatmap.py index 47a1137..4cf717e 100644 --- a/app/router/beatmap.py +++ b/app/router/beatmap.py @@ -16,7 +16,10 @@ from app.dependencies.user import get_current_user from app.fetcher import Fetcher from app.models.beatmap import BeatmapAttributes from app.models.mods import APIMod, int_to_mods -from app.models.score import INT_TO_MODE, GameMode +from app.models.score import ( + INT_TO_MODE, + GameMode, +) from app.utils import calculate_beatmap_attribute from .api_router import router diff --git a/app/utils.py b/app/utils.py index fc5020a..fe0c3fc 100644 --- a/app/utils.py +++ b/app/utils.py @@ -28,6 +28,11 @@ from app.models.user import ( import rosu_pp_py as rosu +def unix_timestamp_to_windows(timestamp: int) -> int: + """Convert a Unix timestamp to a Windows timestamp.""" + return timestamp * 10_000 + 11_644_473_600_000_000 + + async def convert_db_user_to_api_user(db_user: DBUser, ruleset: str = "osu") -> User: """将数据库用户模型转换为API用户模型(使用 Lazer 表)""" @@ -205,7 +210,7 @@ async def convert_db_user_to_api_user(db_user: DBUser, ruleset: str = "osu") -> # 转换团队信息 team = None if db_user.team_membership: - team_member = db_user.team_membership[0] # 假设用户只属于一个团队 + team_member = db_user.team_membership # 假设用户只属于一个团队 team = team_member.team # 创建用户对象 diff --git a/static/README.md b/static/README.md new file mode 100644 index 0000000..16ece63 --- /dev/null +++ b/static/README.md @@ -0,0 +1,5 @@ +# 静态文件 + +- `mods.json`: 包含了游戏中的所有可用mod的详细信息。 + - Origin: https://github.com/ppy/osu-web/blob/master/database/mods.json + - Version: 2025/6/10 `b68c920b1db3d443b9302fdc3f86010c875fe380` diff --git a/static/mods.json b/static/mods.json new file mode 100644 index 0000000..defb57f --- /dev/null +++ b/static/mods.json @@ -0,0 +1,3656 @@ +[ + { + "Name": "osu", + "RulesetID": 0, + "Mods": [ + { + "Acronym": "EZ", + "Name": "Easy", + "Description": "Larger circles, more forgiving HP drain, less accuracy required, and extra lives!", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "retries", + "Type": "number", + "Label": "Extra Lives", + "Description": "Number of extra lives" + } + ], + "IncompatibleMods": [ + "HR", + "AC", + "DA" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NF", + "Name": "No Fail", + "Description": "You can't fail, no matter what.", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [ + "SD", + "PF", + "AC", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HT", + "Name": "Half Time", + "Description": "Less zoom...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DC", + "Name": "Daycore", + "Description": "Whoaaaaa...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HR", + "Name": "Hard Rock", + "Description": "Everything just got a bit harder...", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "EZ", + "DA", + "MR" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SD", + "Name": "Sudden Death", + "Description": "Miss and fail.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "fail_on_slider_tail", + "Type": "boolean", + "Label": "Also fail when missing a slider tail", + "Description": "" + }, + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "PF", + "TP", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "PF", + "Name": "Perfect", + "Description": "SS or quit.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "SD", + "AC", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DT", + "Name": "Double Time", + "Description": "Zoooooooooom...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NC", + "Name": "Nightcore", + "Description": "Uguuuuuuuu...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HD", + "Name": "Hidden", + "Description": "Play with no approach circles and fading circles/sliders.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "only_fade_approach_circles", + "Type": "boolean", + "Label": "Only fade approach circles", + "Description": "The main object body will not fade when enabled." + } + ], + "IncompatibleMods": [ + "SI", + "TC", + "AD", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "FL", + "Name": "Flashlight", + "Description": "Restricted view area.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "follow_delay", + "Type": "number", + "Label": "Follow delay", + "Description": "Milliseconds until the flashlight reaches the cursor" + }, + { + "Name": "size_multiplier", + "Type": "number", + "Label": "Flashlight size", + "Description": "Multiplier applied to the default flashlight size." + }, + { + "Name": "combo_based_size", + "Type": "boolean", + "Label": "Change size based on combo", + "Description": "Decrease the flashlight size as combo increases." + } + ], + "IncompatibleMods": [ + "BL", + "BM" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "BL", + "Name": "Blinds", + "Description": "Play with blinds on your screen.", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "FL" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "ST", + "Name": "Strict Tracking", + "Description": "Once you start a slider, follow precisely or get a miss.", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "TP", + "CL" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AC", + "Name": "Accuracy Challenge", + "Description": "Fail if your accuracy drops too low!", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "minimum_accuracy", + "Type": "number", + "Label": "Minimum accuracy", + "Description": "Trigger a failure if your accuracy goes below this value." + }, + { + "Name": "accuracy_judge_mode", + "Type": "string", + "Label": "Accuracy mode", + "Description": "The mode of accuracy that will trigger failure." + }, + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "EZ", + "NF", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "TP", + "Name": "Target Practice", + "Description": "Practice keeping up with the beat of the song.", + "Type": "Conversion", + "Settings": [ + { + "Name": "seed", + "Type": "number", + "Label": "Seed", + "Description": "Use a custom seed instead of a random one" + }, + { + "Name": "metronome", + "Type": "boolean", + "Label": "Metronome ticks", + "Description": "Whether a metronome beat should play in the background" + } + ], + "IncompatibleMods": [ + "SD", + "ST", + "RD", + "SO", + "TC", + "AD", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DA", + "Name": "Difficulty Adjust", + "Description": "Override a beatmap's difficulty settings.", + "Type": "Conversion", + "Settings": [ + { + "Name": "circle_size", + "Type": "number", + "Label": "Circle Size", + "Description": "Override a beatmap's set CS." + }, + { + "Name": "approach_rate", + "Type": "number", + "Label": "Approach Rate", + "Description": "Override a beatmap's set AR." + }, + { + "Name": "drain_rate", + "Type": "number", + "Label": "HP Drain", + "Description": "Override a beatmap's set HP." + }, + { + "Name": "overall_difficulty", + "Type": "number", + "Label": "Accuracy", + "Description": "Override a beatmap's set OD." + }, + { + "Name": "extended_limits", + "Type": "boolean", + "Label": "Extended Limits", + "Description": "Adjust difficulty beyond sane limits." + } + ], + "IncompatibleMods": [ + "EZ", + "HR" + ], + "RequiresConfiguration": true, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CL", + "Name": "Classic", + "Description": "Feeling nostalgic?", + "Type": "Conversion", + "Settings": [ + { + "Name": "no_slider_head_accuracy", + "Type": "boolean", + "Label": "No slider head accuracy requirement", + "Description": "Scores sliders proportionally to the number of ticks hit." + }, + { + "Name": "classic_note_lock", + "Type": "boolean", + "Label": "Apply classic note lock", + "Description": "Applies note lock to the full hit window." + }, + { + "Name": "always_play_tail_sample", + "Type": "boolean", + "Label": "Always play a slider's tail sample", + "Description": "Always plays a slider's tail sample regardless of whether it was hit or not." + }, + { + "Name": "fade_hit_circle_early", + "Type": "boolean", + "Label": "Fade out hit circles earlier", + "Description": "Make hit circles fade out into a miss, rather than after it." + }, + { + "Name": "classic_health", + "Type": "boolean", + "Label": "Classic health", + "Description": "More closely resembles the original HP drain mechanics." + } + ], + "IncompatibleMods": [ + "ST" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "RD", + "Name": "Random", + "Description": "It never gets boring!", + "Type": "Conversion", + "Settings": [ + { + "Name": "angle_sharpness", + "Type": "number", + "Label": "Angle sharpness", + "Description": "How sharp angles should be" + }, + { + "Name": "seed", + "Type": "number", + "Label": "Seed", + "Description": "Use a custom seed instead of a random one" + } + ], + "IncompatibleMods": [ + "TP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "MR", + "Name": "Mirror", + "Description": "Flip objects on the chosen axes.", + "Type": "Conversion", + "Settings": [ + { + "Name": "reflection", + "Type": "string", + "Label": "Flipped axes", + "Description": "" + } + ], + "IncompatibleMods": [ + "HR" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AL", + "Name": "Alternate", + "Description": "Don't use the same key twice in a row!", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "SG", + "AT", + "CN", + "RX" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SG", + "Name": "Single Tap", + "Description": "You must only use one key!", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "AL", + "AT", + "CN", + "RX" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AT", + "Name": "Autoplay", + "Description": "Watch a perfect automated play through the song.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "AL", + "SG", + "CN", + "RX", + "AP", + "SO", + "MG", + "RP", + "AS", + "TD" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CN", + "Name": "Cinema", + "Description": "Watch the video without visual distractions.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "NF", + "SD", + "PF", + "AC", + "AL", + "SG", + "AT", + "CN", + "RX", + "AP", + "SO", + "MG", + "RP", + "AS", + "TD" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "RX", + "Name": "Relax", + "Description": "You don't need to click. Give your clicking/tapping fingers a break from the heat of things.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "AL", + "SG", + "AT", + "CN", + "AP", + "MG" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AP", + "Name": "Autopilot", + "Description": "Automatic cursor movement - just follow the rhythm.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "AT", + "CN", + "RX", + "SO", + "MG", + "RP", + "TD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SO", + "Name": "Spun Out", + "Description": "Spinners will be automatically completed.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "TP", + "AT", + "CN", + "AP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "TR", + "Name": "Transform", + "Description": "Everything rotates. EVERYTHING.", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [ + "WG", + "MG", + "RP", + "FR", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WG", + "Name": "Wiggle", + "Description": "They just won't stay still...", + "Type": "Fun", + "Settings": [ + { + "Name": "strength", + "Type": "number", + "Label": "Strength", + "Description": "Multiplier applied to the wiggling strength." + } + ], + "IncompatibleMods": [ + "TR", + "MG", + "RP", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SI", + "Name": "Spin In", + "Description": "Circles spin in. No approach circles.", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [ + "HD", + "GR", + "DF", + "TC", + "AD", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "GR", + "Name": "Grow", + "Description": "Hit them at the right size!", + "Type": "Fun", + "Settings": [ + { + "Name": "start_scale", + "Type": "number", + "Label": "Starting Size", + "Description": "The initial size multiplier applied to all objects." + } + ], + "IncompatibleMods": [ + "SI", + "GR", + "DF", + "TC", + "AD", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DF", + "Name": "Deflate", + "Description": "Hit them at the right size!", + "Type": "Fun", + "Settings": [ + { + "Name": "start_scale", + "Type": "number", + "Label": "Starting Size", + "Description": "The initial size multiplier applied to all objects." + } + ], + "IncompatibleMods": [ + "SI", + "GR", + "DF", + "TC", + "AD", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WU", + "Name": "Wind Up", + "Description": "Can you keep up?", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WD", + "Name": "Wind Down", + "Description": "Sloooow doooown...", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "TC", + "Name": "Traceable", + "Description": "Put your faith in the approach circles...", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [ + "HD", + "TP", + "SI", + "GR", + "DF", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "BR", + "Name": "Barrel Roll", + "Description": "The whole playfield is on a wheel!", + "Type": "Fun", + "Settings": [ + { + "Name": "spin_speed", + "Type": "number", + "Label": "Roll speed", + "Description": "Rotations per minute" + }, + { + "Name": "direction", + "Type": "string", + "Label": "Direction", + "Description": "The direction of rotation" + } + ], + "IncompatibleMods": [ + "BU" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AD", + "Name": "Approach Different", + "Description": "Never trust the approach circles...", + "Type": "Fun", + "Settings": [ + { + "Name": "scale", + "Type": "number", + "Label": "Initial size", + "Description": "Change the initial size of the approach circle, relative to hit circles." + }, + { + "Name": "style", + "Type": "string", + "Label": "Style", + "Description": "Change the animation style of the approach circles." + } + ], + "IncompatibleMods": [ + "HD", + "TP", + "SI", + "GR", + "DF", + "FR" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "MU", + "Name": "Muted", + "Description": "Can you still feel the rhythm without music?", + "Type": "Fun", + "Settings": [ + { + "Name": "inverse_muting", + "Type": "boolean", + "Label": "Start muted", + "Description": "Increase volume as combo builds." + }, + { + "Name": "enable_metronome", + "Type": "boolean", + "Label": "Enable metronome", + "Description": "Add a metronome beat to help you keep track of the rhythm." + }, + { + "Name": "mute_combo_count", + "Type": "number", + "Label": "Final volume at combo", + "Description": "The combo count at which point the track reaches its final volume." + }, + { + "Name": "affects_hit_sounds", + "Type": "boolean", + "Label": "Mute hit sounds", + "Description": "Hit sounds are also muted alongside the track." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NS", + "Name": "No Scope", + "Description": "Where's the cursor?", + "Type": "Fun", + "Settings": [ + { + "Name": "hidden_combo_count", + "Type": "number", + "Label": "Hidden at combo", + "Description": "The combo count at which the cursor becomes completely hidden" + } + ], + "IncompatibleMods": [ + "BM" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "MG", + "Name": "Magnetised", + "Description": "No need to chase the circles – your cursor is a magnet!", + "Type": "Fun", + "Settings": [ + { + "Name": "attraction_strength", + "Type": "number", + "Label": "Attraction strength", + "Description": "How strong the pull is." + } + ], + "IncompatibleMods": [ + "AT", + "CN", + "RX", + "AP", + "TR", + "WG", + "RP", + "BU", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "RP", + "Name": "Repel", + "Description": "Hit objects run away!", + "Type": "Fun", + "Settings": [ + { + "Name": "repulsion_strength", + "Type": "number", + "Label": "Repulsion strength", + "Description": "How strong the repulsion is." + } + ], + "IncompatibleMods": [ + "AT", + "CN", + "AP", + "TR", + "WG", + "MG", + "BU", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AS", + "Name": "Adaptive Speed", + "Description": "Let track speed adapt to you.", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "AT", + "CN", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "FR", + "Name": "Freeze Frame", + "Description": "Burn the notes into your memory.", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [ + "TR", + "AD", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "BU", + "Name": "Bubbles", + "Description": "Don't let their popping distract you!", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [ + "BR", + "MG", + "RP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SY", + "Name": "Synesthesia", + "Description": "Colours hit objects based on the rhythm.", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DP", + "Name": "Depth", + "Description": "3D. Almost.", + "Type": "Fun", + "Settings": [ + { + "Name": "max_depth", + "Type": "number", + "Label": "Maximum depth", + "Description": "How far away objects appear." + }, + { + "Name": "show_approach_circles", + "Type": "boolean", + "Label": "Show Approach Circles", + "Description": "Whether approach circles should be visible." + } + ], + "IncompatibleMods": [ + "HD", + "TP", + "TR", + "WG", + "SI", + "GR", + "DF", + "TC", + "MG", + "RP", + "FR", + "DP" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "BM", + "Name": "Bloom", + "Description": "The cursor blooms into.. a larger cursor!", + "Type": "Fun", + "Settings": [ + { + "Name": "max_size_combo_count", + "Type": "number", + "Label": "Max size at combo", + "Description": "The combo count at which the cursor reaches its maximum size" + }, + { + "Name": "max_cursor_size", + "Type": "number", + "Label": "Final size multiplier", + "Description": "The multiplier applied to cursor size when combo reaches maximum" + } + ], + "IncompatibleMods": [ + "FL", + "NS", + "TD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "TD", + "Name": "Touch Device", + "Description": "Automatically applied to plays on devices with a touchscreen.", + "Type": "System", + "Settings": [], + "IncompatibleMods": [ + "AT", + "CN", + "AP", + "BM" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": true + }, + { + "Acronym": "SV2", + "Name": "Score V2", + "Description": "Score set on earlier osu! versions with the V2 scoring algorithm active.", + "Type": "System", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + } + ] + }, + { + "Name": "taiko", + "RulesetID": 1, + "Mods": [ + { + "Acronym": "EZ", + "Name": "Easy", + "Description": "Beats move slower, and less accuracy required!", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [ + "HR", + "DA" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NF", + "Name": "No Fail", + "Description": "You can't fail, no matter what.", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [ + "SD", + "PF", + "AC", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HT", + "Name": "Half Time", + "Description": "Less zoom...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DC", + "Name": "Daycore", + "Description": "Whoaaaaa...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SR", + "Name": "Simplified Rhythm", + "Description": "Simplify tricky rhythms!", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "one_third_conversion", + "Type": "boolean", + "Label": "1/3 to 1/2 conversion", + "Description": "Converts 1/3 patterns to 1/2 rhythm." + }, + { + "Name": "one_sixth_conversion", + "Type": "boolean", + "Label": "1/6 to 1/4 conversion", + "Description": "Converts 1/6 patterns to 1/4 rhythm." + }, + { + "Name": "one_eighth_conversion", + "Type": "boolean", + "Label": "1/8 to 1/4 conversion", + "Description": "Converts 1/8 patterns to 1/4 rhythm." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HR", + "Name": "Hard Rock", + "Description": "Everything just got a bit harder...", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "EZ", + "DA" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SD", + "Name": "Sudden Death", + "Description": "Miss and fail.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "PF", + "Name": "Perfect", + "Description": "SS or quit.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "SD", + "AC", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DT", + "Name": "Double Time", + "Description": "Zoooooooooom...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NC", + "Name": "Nightcore", + "Description": "Uguuuuuuuu...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HD", + "Name": "Hidden", + "Description": "Beats fade out before you hit them!", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "FL", + "Name": "Flashlight", + "Description": "Restricted view area.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "size_multiplier", + "Type": "number", + "Label": "Flashlight size", + "Description": "Multiplier applied to the default flashlight size." + }, + { + "Name": "combo_based_size", + "Type": "boolean", + "Label": "Change size based on combo", + "Description": "Decrease the flashlight size as combo increases." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AC", + "Name": "Accuracy Challenge", + "Description": "Fail if your accuracy drops too low!", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "minimum_accuracy", + "Type": "number", + "Label": "Minimum accuracy", + "Description": "Trigger a failure if your accuracy goes below this value." + }, + { + "Name": "accuracy_judge_mode", + "Type": "string", + "Label": "Accuracy mode", + "Description": "The mode of accuracy that will trigger failure." + }, + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "RD", + "Name": "Random", + "Description": "Shuffle around the colours!", + "Type": "Conversion", + "Settings": [ + { + "Name": "seed", + "Type": "number", + "Label": "Seed", + "Description": "Use a custom seed instead of a random one" + } + ], + "IncompatibleMods": [ + "SW" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DA", + "Name": "Difficulty Adjust", + "Description": "Override a beatmap's difficulty settings.", + "Type": "Conversion", + "Settings": [ + { + "Name": "scroll_speed", + "Type": "number", + "Label": "Scroll Speed", + "Description": "Adjust a beatmap's set scroll speed" + }, + { + "Name": "drain_rate", + "Type": "number", + "Label": "HP Drain", + "Description": "Override a beatmap's set HP." + }, + { + "Name": "overall_difficulty", + "Type": "number", + "Label": "Accuracy", + "Description": "Override a beatmap's set OD." + }, + { + "Name": "extended_limits", + "Type": "boolean", + "Label": "Extended Limits", + "Description": "Adjust difficulty beyond sane limits." + } + ], + "IncompatibleMods": [ + "EZ", + "HR" + ], + "RequiresConfiguration": true, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CL", + "Name": "Classic", + "Description": "Feeling nostalgic?", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SW", + "Name": "Swap", + "Description": "Dons become kats, kats become dons", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "RD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SG", + "Name": "Single Tap", + "Description": "One key for dons, one key for kats.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "AT", + "CN", + "RX" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CS", + "Name": "Constant Speed", + "Description": "No more tricky speed changes!", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AT", + "Name": "Autoplay", + "Description": "Watch a perfect automated play through the song.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "SG", + "CN", + "RX", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CN", + "Name": "Cinema", + "Description": "Watch the video without visual distractions.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "NF", + "SD", + "PF", + "AC", + "SG", + "AT", + "CN", + "RX", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "RX", + "Name": "Relax", + "Description": "No need to remember which key is correct anymore!", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "SG", + "AT", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WU", + "Name": "Wind Up", + "Description": "Can you keep up?", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WD", + "Name": "Wind Down", + "Description": "Sloooow doooown...", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "MU", + "Name": "Muted", + "Description": "Can you still feel the rhythm without music?", + "Type": "Fun", + "Settings": [ + { + "Name": "inverse_muting", + "Type": "boolean", + "Label": "Start muted", + "Description": "Increase volume as combo builds." + }, + { + "Name": "enable_metronome", + "Type": "boolean", + "Label": "Enable metronome", + "Description": "Add a metronome beat to help you keep track of the rhythm." + }, + { + "Name": "mute_combo_count", + "Type": "number", + "Label": "Final volume at combo", + "Description": "The combo count at which point the track reaches its final volume." + }, + { + "Name": "affects_hit_sounds", + "Type": "boolean", + "Label": "Mute hit sounds", + "Description": "Hit sounds are also muted alongside the track." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AS", + "Name": "Adaptive Speed", + "Description": "Let track speed adapt to you.", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "AT", + "CN", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SV2", + "Name": "Score V2", + "Description": "Score set on earlier osu! versions with the V2 scoring algorithm active.", + "Type": "System", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + } + ] + }, + { + "Name": "fruits", + "RulesetID": 2, + "Mods": [ + { + "Acronym": "EZ", + "Name": "Easy", + "Description": "Larger fruits, more forgiving HP drain, less accuracy required, and extra lives!", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "retries", + "Type": "number", + "Label": "Extra Lives", + "Description": "Number of extra lives" + } + ], + "IncompatibleMods": [ + "HR", + "AC", + "DA" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NF", + "Name": "No Fail", + "Description": "You can't fail, no matter what.", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [ + "SD", + "PF", + "AC", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HT", + "Name": "Half Time", + "Description": "Less zoom...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DC", + "Name": "Daycore", + "Description": "Whoaaaaa...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HR", + "Name": "Hard Rock", + "Description": "Everything just got a bit harder...", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "EZ", + "DA" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SD", + "Name": "Sudden Death", + "Description": "Miss and fail.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "PF", + "Name": "Perfect", + "Description": "SS or quit.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "SD", + "AC", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DT", + "Name": "Double Time", + "Description": "Zoooooooooom...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NC", + "Name": "Nightcore", + "Description": "Uguuuuuuuu...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HD", + "Name": "Hidden", + "Description": "Play with fading fruits.", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "FL", + "Name": "Flashlight", + "Description": "Restricted view area.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "size_multiplier", + "Type": "number", + "Label": "Flashlight size", + "Description": "Multiplier applied to the default flashlight size." + }, + { + "Name": "combo_based_size", + "Type": "boolean", + "Label": "Change size based on combo", + "Description": "Decrease the flashlight size as combo increases." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AC", + "Name": "Accuracy Challenge", + "Description": "Fail if your accuracy drops too low!", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "minimum_accuracy", + "Type": "number", + "Label": "Minimum accuracy", + "Description": "Trigger a failure if your accuracy goes below this value." + }, + { + "Name": "accuracy_judge_mode", + "Type": "string", + "Label": "Accuracy mode", + "Description": "The mode of accuracy that will trigger failure." + }, + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "EZ", + "NF", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DA", + "Name": "Difficulty Adjust", + "Description": "Override a beatmap's difficulty settings.", + "Type": "Conversion", + "Settings": [ + { + "Name": "circle_size", + "Type": "number", + "Label": "Circle Size", + "Description": "Override a beatmap's set CS." + }, + { + "Name": "approach_rate", + "Type": "number", + "Label": "Approach Rate", + "Description": "Override a beatmap's set AR." + }, + { + "Name": "hard_rock_offsets", + "Type": "boolean", + "Label": "Spicy Patterns", + "Description": "Adjust the patterns as if Hard Rock is enabled." + }, + { + "Name": "drain_rate", + "Type": "number", + "Label": "HP Drain", + "Description": "Override a beatmap's set HP." + }, + { + "Name": "overall_difficulty", + "Type": "number", + "Label": "Accuracy", + "Description": "Override a beatmap's set OD." + }, + { + "Name": "extended_limits", + "Type": "boolean", + "Label": "Extended Limits", + "Description": "Adjust difficulty beyond sane limits." + } + ], + "IncompatibleMods": [ + "EZ", + "HR" + ], + "RequiresConfiguration": true, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CL", + "Name": "Classic", + "Description": "Feeling nostalgic?", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "MR", + "Name": "Mirror", + "Description": "Fruits are flipped horizontally.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AT", + "Name": "Autoplay", + "Description": "Watch a perfect automated play through the song.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "CN", + "RX" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CN", + "Name": "Cinema", + "Description": "Watch the video without visual distractions.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "NF", + "SD", + "PF", + "AC", + "AT", + "CN", + "RX" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "RX", + "Name": "Relax", + "Description": "Use the mouse to control the catcher.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "AT", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WU", + "Name": "Wind Up", + "Description": "Can you keep up?", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WD", + "Name": "Wind Down", + "Description": "Sloooow doooown...", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "FF", + "Name": "Floating Fruits", + "Description": "The fruits are... floating?", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "MU", + "Name": "Muted", + "Description": "Can you still feel the rhythm without music?", + "Type": "Fun", + "Settings": [ + { + "Name": "inverse_muting", + "Type": "boolean", + "Label": "Start muted", + "Description": "Increase volume as combo builds." + }, + { + "Name": "enable_metronome", + "Type": "boolean", + "Label": "Enable metronome", + "Description": "Add a metronome beat to help you keep track of the rhythm." + }, + { + "Name": "mute_combo_count", + "Type": "number", + "Label": "Final volume at combo", + "Description": "The combo count at which point the track reaches its final volume." + }, + { + "Name": "affects_hit_sounds", + "Type": "boolean", + "Label": "Mute hit sounds", + "Description": "Hit sounds are also muted alongside the track." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NS", + "Name": "No Scope", + "Description": "Where's the catcher?", + "Type": "Fun", + "Settings": [ + { + "Name": "hidden_combo_count", + "Type": "number", + "Label": "Hidden at combo", + "Description": "The combo count at which the cursor becomes completely hidden" + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SV2", + "Name": "Score V2", + "Description": "Score set on earlier osu! versions with the V2 scoring algorithm active.", + "Type": "System", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + } + ] + }, + { + "Name": "mania", + "RulesetID": 3, + "Mods": [ + { + "Acronym": "EZ", + "Name": "Easy", + "Description": "More forgiving HP drain, less accuracy required, and extra lives!", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "retries", + "Type": "number", + "Label": "Extra Lives", + "Description": "Number of extra lives" + } + ], + "IncompatibleMods": [ + "HR", + "AC", + "DA" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NF", + "Name": "No Fail", + "Description": "You can't fail, no matter what.", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [ + "SD", + "PF", + "AC", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HT", + "Name": "Half Time", + "Description": "Less zoom...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DC", + "Name": "Daycore", + "Description": "Whoaaaaa...", + "Type": "DifficultyReduction", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed decrease", + "Description": "The actual decrease to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NR", + "Name": "No Release", + "Description": "No more timing the end of hold notes.", + "Type": "DifficultyReduction", + "Settings": [], + "IncompatibleMods": [ + "HO" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HR", + "Name": "Hard Rock", + "Description": "Everything just got a bit harder...", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "EZ", + "DA" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SD", + "Name": "Sudden Death", + "Description": "Miss and fail.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "PF", + "Name": "Perfect", + "Description": "SS or quit.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "require_perfect_hits", + "Type": "boolean", + "Label": "Require perfect hits", + "Description": "" + }, + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "NF", + "SD", + "AC", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DT", + "Name": "Double Time", + "Description": "Zoooooooooom...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "NC", + "Name": "Nightcore", + "Description": "Uguuuuuuuu...", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "speed_change", + "Type": "number", + "Label": "Speed increase", + "Description": "The actual increase to apply" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "FI", + "Name": "Fade In", + "Description": "Keys appear out of nowhere!", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "FI", + "HD", + "CO", + "FL" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HD", + "Name": "Hidden", + "Description": "Keys fade out before you hit them!", + "Type": "DifficultyIncrease", + "Settings": [], + "IncompatibleMods": [ + "FI", + "CO", + "FL" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CO", + "Name": "Cover", + "Description": "Decrease the playfield's viewing area.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "coverage", + "Type": "number", + "Label": "Coverage", + "Description": "The proportion of playfield height that notes will be hidden for." + }, + { + "Name": "direction", + "Type": "string", + "Label": "Direction", + "Description": "The direction on which the cover is applied" + } + ], + "IncompatibleMods": [ + "FI", + "HD", + "FL" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "FL", + "Name": "Flashlight", + "Description": "Restricted view area.", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "size_multiplier", + "Type": "number", + "Label": "Flashlight size", + "Description": "Multiplier applied to the default flashlight size." + }, + { + "Name": "combo_based_size", + "Type": "boolean", + "Label": "Change size based on combo", + "Description": "Decrease the flashlight size as combo increases." + } + ], + "IncompatibleMods": [ + "FI", + "HD", + "CO" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AC", + "Name": "Accuracy Challenge", + "Description": "Fail if your accuracy drops too low!", + "Type": "DifficultyIncrease", + "Settings": [ + { + "Name": "minimum_accuracy", + "Type": "number", + "Label": "Minimum accuracy", + "Description": "Trigger a failure if your accuracy goes below this value." + }, + { + "Name": "accuracy_judge_mode", + "Type": "string", + "Label": "Accuracy mode", + "Description": "The mode of accuracy that will trigger failure." + }, + { + "Name": "restart", + "Type": "boolean", + "Label": "Restart on fail", + "Description": "Automatically restarts when failed." + } + ], + "IncompatibleMods": [ + "EZ", + "NF", + "PF", + "CN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "RD", + "Name": "Random", + "Description": "Shuffle around the keys!", + "Type": "Conversion", + "Settings": [ + { + "Name": "seed", + "Type": "number", + "Label": "Seed", + "Description": "Use a custom seed instead of a random one" + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DS", + "Name": "Dual Stages", + "Description": "Double the stages, double the fun!", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "MR", + "Name": "Mirror", + "Description": "Notes are flipped horizontally.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "DA", + "Name": "Difficulty Adjust", + "Description": "Override a beatmap's difficulty settings.", + "Type": "Conversion", + "Settings": [ + { + "Name": "drain_rate", + "Type": "number", + "Label": "HP Drain", + "Description": "Override a beatmap's set HP." + }, + { + "Name": "overall_difficulty", + "Type": "number", + "Label": "Accuracy", + "Description": "Override a beatmap's set OD." + }, + { + "Name": "extended_limits", + "Type": "boolean", + "Label": "Extended Limits", + "Description": "Adjust difficulty beyond sane limits." + } + ], + "IncompatibleMods": [ + "EZ", + "HR" + ], + "RequiresConfiguration": true, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CL", + "Name": "Classic", + "Description": "Feeling nostalgic?", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "IN", + "Name": "Invert", + "Description": "Hold the keys. To the beat.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "HO" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CS", + "Name": "Constant Speed", + "Description": "No more tricky speed changes!", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "HO", + "Name": "Hold Off", + "Description": "Replaces all hold notes with normal notes.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "NR", + "IN" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "1K", + "Name": "One Key", + "Description": "Play with one key.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "2K", + "3K", + "4K", + "5K", + "6K", + "7K", + "8K", + "9K", + "10K" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "2K", + "Name": "Two Keys", + "Description": "Play with two keys.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "1K", + "3K", + "4K", + "5K", + "6K", + "7K", + "8K", + "9K", + "10K" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "3K", + "Name": "Three Keys", + "Description": "Play with three keys.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "1K", + "2K", + "4K", + "5K", + "6K", + "7K", + "8K", + "9K", + "10K" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "4K", + "Name": "Four Keys", + "Description": "Play with four keys.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "1K", + "2K", + "3K", + "5K", + "6K", + "7K", + "8K", + "9K", + "10K" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "5K", + "Name": "Five Keys", + "Description": "Play with five keys.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "1K", + "2K", + "3K", + "4K", + "6K", + "7K", + "8K", + "9K", + "10K" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "6K", + "Name": "Six Keys", + "Description": "Play with six keys.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "1K", + "2K", + "3K", + "4K", + "5K", + "7K", + "8K", + "9K", + "10K" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "7K", + "Name": "Seven Keys", + "Description": "Play with seven keys.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "1K", + "2K", + "3K", + "4K", + "5K", + "6K", + "8K", + "9K", + "10K" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "8K", + "Name": "Eight Keys", + "Description": "Play with eight keys.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "1K", + "2K", + "3K", + "4K", + "5K", + "6K", + "7K", + "9K", + "10K" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "9K", + "Name": "Nine Keys", + "Description": "Play with nine keys.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "1K", + "2K", + "3K", + "4K", + "5K", + "6K", + "7K", + "8K", + "10K" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "10K", + "Name": "Ten Keys", + "Description": "Play with ten keys.", + "Type": "Conversion", + "Settings": [], + "IncompatibleMods": [ + "1K", + "2K", + "3K", + "4K", + "5K", + "6K", + "7K", + "8K", + "9K" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AT", + "Name": "Autoplay", + "Description": "Watch a perfect automated play through the song.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "CN", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "CN", + "Name": "Cinema", + "Description": "Watch the video without visual distractions.", + "Type": "Automation", + "Settings": [], + "IncompatibleMods": [ + "NF", + "SD", + "PF", + "AC", + "AT", + "CN", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WU", + "Name": "Wind Up", + "Description": "Can you keep up?", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WD", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "WD", + "Name": "Wind Down", + "Description": "Sloooow doooown...", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "final_rate", + "Type": "number", + "Label": "Final rate", + "Description": "The final speed to ramp to" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "WU", + "AS" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "MU", + "Name": "Muted", + "Description": "Can you still feel the rhythm without music?", + "Type": "Fun", + "Settings": [ + { + "Name": "inverse_muting", + "Type": "boolean", + "Label": "Start muted", + "Description": "Increase volume as combo builds." + }, + { + "Name": "enable_metronome", + "Type": "boolean", + "Label": "Enable metronome", + "Description": "Add a metronome beat to help you keep track of the rhythm." + }, + { + "Name": "mute_combo_count", + "Type": "number", + "Label": "Final volume at combo", + "Description": "The combo count at which point the track reaches its final volume." + }, + { + "Name": "affects_hit_sounds", + "Type": "boolean", + "Label": "Mute hit sounds", + "Description": "Hit sounds are also muted alongside the track." + } + ], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": true, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "AS", + "Name": "Adaptive Speed", + "Description": "Let track speed adapt to you.", + "Type": "Fun", + "Settings": [ + { + "Name": "initial_rate", + "Type": "number", + "Label": "Initial rate", + "Description": "The starting speed of the track" + }, + { + "Name": "adjust_pitch", + "Type": "boolean", + "Label": "Adjust pitch", + "Description": "Should pitch be adjusted with speed" + } + ], + "IncompatibleMods": [ + "HT", + "DC", + "DT", + "NC", + "AT", + "CN", + "WU", + "WD" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + }, + { + "Acronym": "SV2", + "Name": "Score V2", + "Description": "Score set on earlier osu! versions with the V2 scoring algorithm active.", + "Type": "System", + "Settings": [], + "IncompatibleMods": [], + "RequiresConfiguration": false, + "UserPlayable": false, + "ValidForMultiplayer": false, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": false, + "AlwaysValidForSubmission": false + } + ] + } +] From 0d684a12888697bed69b7200196ded27ec1f83b2 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 02:42:14 +0000 Subject: [PATCH 02/16] chore(signalr): move to app/ --- app/router/__init__.py | 3 ++- app/{router => }/signalr/__init__.py | 0 app/{router => }/signalr/exception.py | 0 app/{router => }/signalr/hub/__init__.py | 0 app/{router => }/signalr/hub/hub.py | 8 ++++---- app/{router => }/signalr/hub/metadata.py | 0 app/{router => }/signalr/hub/multiplayer.py | 0 app/{router => }/signalr/hub/spectator.py | 0 app/{router => }/signalr/packet.py | 0 app/{router => }/signalr/router.py | 2 +- app/{router => }/signalr/store.py | 2 +- app/{router => }/signalr/utils.py | 0 12 files changed, 8 insertions(+), 7 deletions(-) rename app/{router => }/signalr/__init__.py (100%) rename app/{router => }/signalr/exception.py (100%) rename app/{router => }/signalr/hub/__init__.py (100%) rename app/{router => }/signalr/hub/hub.py (97%) rename app/{router => }/signalr/hub/metadata.py (100%) rename app/{router => }/signalr/hub/multiplayer.py (100%) rename app/{router => }/signalr/hub/spectator.py (100%) rename app/{router => }/signalr/packet.py (100%) rename app/{router => }/signalr/router.py (98%) rename app/{router => }/signalr/store.py (96%) rename app/{router => }/signalr/utils.py (100%) diff --git a/app/router/__init__.py b/app/router/__init__.py index 71f7fa7..ad54ba3 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +from app.signalr import signalr_router as signalr_router + from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401 beatmap, beatmapset, @@ -10,6 +12,5 @@ from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401 from .api_router import router as api_router from .auth import router as auth_router from .fetcher import fetcher_router as fetcher_router -from .signalr import signalr_router as signalr_router __all__ = ["api_router", "auth_router", "fetcher_router", "signalr_router"] diff --git a/app/router/signalr/__init__.py b/app/signalr/__init__.py similarity index 100% rename from app/router/signalr/__init__.py rename to app/signalr/__init__.py diff --git a/app/router/signalr/exception.py b/app/signalr/exception.py similarity index 100% rename from app/router/signalr/exception.py rename to app/signalr/exception.py diff --git a/app/router/signalr/hub/__init__.py b/app/signalr/hub/__init__.py similarity index 100% rename from app/router/signalr/hub/__init__.py rename to app/signalr/hub/__init__.py diff --git a/app/router/signalr/hub/hub.py b/app/signalr/hub/hub.py similarity index 97% rename from app/router/signalr/hub/hub.py rename to app/signalr/hub/hub.py index f025aa2..1e5e123 100644 --- a/app/router/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -5,15 +5,15 @@ import time from typing import Any from app.config import settings -from app.router.signalr.exception import InvokeException -from app.router.signalr.packet import ( +from app.signalr.exception import InvokeException +from app.signalr.packet import ( PacketType, ResultKind, encode_varint, parse_packet, ) -from app.router.signalr.store import ResultStore -from app.router.signalr.utils import get_signature +from app.signalr.store import ResultStore +from app.signalr.utils import get_signature from fastapi import WebSocket import msgpack diff --git a/app/router/signalr/hub/metadata.py b/app/signalr/hub/metadata.py similarity index 100% rename from app/router/signalr/hub/metadata.py rename to app/signalr/hub/metadata.py diff --git a/app/router/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py similarity index 100% rename from app/router/signalr/hub/multiplayer.py rename to app/signalr/hub/multiplayer.py diff --git a/app/router/signalr/hub/spectator.py b/app/signalr/hub/spectator.py similarity index 100% rename from app/router/signalr/hub/spectator.py rename to app/signalr/hub/spectator.py diff --git a/app/router/signalr/packet.py b/app/signalr/packet.py similarity index 100% rename from app/router/signalr/packet.py rename to app/signalr/packet.py diff --git a/app/router/signalr/router.py b/app/signalr/router.py similarity index 98% rename from app/router/signalr/router.py rename to app/signalr/router.py index bce1611..49934b7 100644 --- a/app/router/signalr/router.py +++ b/app/signalr/router.py @@ -10,9 +10,9 @@ from app.dependencies import get_current_user from app.dependencies.database import get_db from app.dependencies.user import get_current_user_by_token from app.models.signalr import NegotiateResponse, Transport -from app.router.signalr.packet import SEP from .hub import Hubs +from .packet import SEP from fastapi import APIRouter, Depends, Header, Query, WebSocket from sqlmodel.ext.asyncio.session import AsyncSession diff --git a/app/router/signalr/store.py b/app/signalr/store.py similarity index 96% rename from app/router/signalr/store.py rename to app/signalr/store.py index 3347676..5258293 100644 --- a/app/router/signalr/store.py +++ b/app/signalr/store.py @@ -4,7 +4,7 @@ import asyncio import sys from typing import Any, Literal -from app.router.signalr.packet import ResultKind +from .packet import ResultKind class ResultStore: diff --git a/app/router/signalr/utils.py b/app/signalr/utils.py similarity index 100% rename from app/router/signalr/utils.py rename to app/signalr/utils.py From 0b8beade5d0fd79042d4e9f11d6c8f75caeb1204 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 03:00:22 +0000 Subject: [PATCH 03/16] refactor(mods): move models from `app.models.score` to `app.models.mods` --- app/models/mods.py | 119 +++++++++++++++++++++++++++++++------------- app/models/score.py | 95 ++--------------------------------- 2 files changed, 90 insertions(+), 124 deletions(-) diff --git a/app/models/mods.py b/app/models/mods.py index 529b89a..7b5e78d 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -1,47 +1,91 @@ from __future__ import annotations -from typing import TypedDict +import json +from typing import Literal, NotRequired, TypedDict + +from app.path import STATIC_DIR class APIMod(TypedDict): acronym: str - settings: dict[str, bool | float | str] + settings: NotRequired[dict[str, bool | float | str]] # https://github.com/ppy/osu-api/wiki#mods -LEGACY_MOD_TO_API_MOD = { - (1 << 0): APIMod(acronym="NF", settings={}), # No Fail - (1 << 1): APIMod(acronym="EZ", settings={}), - (1 << 2): APIMod(acronym="TD", settings={}), # Touch Device - (1 << 3): APIMod(acronym="HD", settings={}), # Hidden - (1 << 4): APIMod(acronym="HR", settings={}), # Hard Rock - (1 << 5): APIMod(acronym="SD", settings={}), # Sudden Death - (1 << 6): APIMod(acronym="DT", settings={}), # Double Time - (1 << 7): APIMod(acronym="RX", settings={}), # Relax - (1 << 8): APIMod(acronym="HT", settings={}), # Half Time - (1 << 9): APIMod(acronym="NC", settings={}), # Nightcore - (1 << 10): APIMod(acronym="FL", settings={}), # Flashlight - (1 << 11): APIMod(acronym="AT", settings={}), # Auto Play - (1 << 12): APIMod(acronym="SO", settings={}), # Spun Out - (1 << 13): APIMod(acronym="AP", settings={}), # Autopilot - (1 << 14): APIMod(acronym="PF", settings={}), # Perfect - (1 << 15): APIMod(acronym="4K", settings={}), # 4K - (1 << 16): APIMod(acronym="5K", settings={}), # 5K - (1 << 17): APIMod(acronym="6K", settings={}), # 6K - (1 << 18): APIMod(acronym="7K", settings={}), # 7K - (1 << 19): APIMod(acronym="8K", settings={}), # 8K - (1 << 20): APIMod(acronym="FI", settings={}), # Fade In - (1 << 21): APIMod(acronym="RD", settings={}), # Random - (1 << 22): APIMod(acronym="CN", settings={}), # Cinema - (1 << 23): APIMod(acronym="TP", settings={}), # Target Practice - (1 << 24): APIMod(acronym="9K", settings={}), # 9K - (1 << 25): APIMod(acronym="CO", settings={}), # Key Co-op - (1 << 26): APIMod(acronym="1K", settings={}), # 1K - (1 << 27): APIMod(acronym="2K", settings={}), # 2K - (1 << 28): APIMod(acronym="3K", settings={}), # 3K - (1 << 29): APIMod(acronym="SV2", settings={}), # Score V2 - (1 << 30): APIMod(acronym="MR", settings={}), # Mirror +API_MOD_TO_LEGACY: dict[str, int] = { + "NF": 1 << 0, # No Fail + "EZ": 1 << 1, # Easy + "TD": 1 << 2, # Touch Device + "HD": 1 << 3, # Hidden + "HR": 1 << 4, # Hard Rock + "SD": 1 << 5, # Sudden Death + "DT": 1 << 6, # Double Time + "RX": 1 << 7, # Relax + "HT": 1 << 8, # Half Time + "NC": 1 << 9, # Nightcore + "FL": 1 << 10, # Flashlight + "AT": 1 << 11, # Autoplay + "SO": 1 << 12, # Spun Out + "AP": 1 << 13, # Auto Pilot + "PF": 1 << 14, # Perfect + "4K": 1 << 15, # 4K + "5K": 1 << 16, # 5K + "6K": 1 << 17, # 6K + "7K": 1 << 18, # 7K + "8K": 1 << 19, # 8K + "FI": 1 << 20, # Fade In + "RD": 1 << 21, # Random + "CN": 1 << 22, # Cinema + "TP": 1 << 23, # Target Practice + "9K": 1 << 24, # 9K + "CO": 1 << 25, # Key Co-op + "1K": 1 << 26, # 1K + "3K": 1 << 27, # 3K + "2K": 1 << 28, # 2K + "SV2": 1 << 29, # ScoreV2 + "MR": 1 << 30, # Mirror } +LEGACY_MOD_TO_API_MOD = {} +for k, v in API_MOD_TO_LEGACY.items(): + LEGACY_MOD_TO_API_MOD[v] = APIMod(acronym=k, settings={}) +API_MOD_TO_LEGACY["NC"] |= API_MOD_TO_LEGACY["DT"] +API_MOD_TO_LEGACY["PF"] |= API_MOD_TO_LEGACY["SD"] + + +# see static/mods.json +class Settings(TypedDict): + Name: str + Type: str + Label: str + Description: str + + +class Mod(TypedDict): + Acronym: str + Name: str + Description: str + Type: str + Settings: list[Settings] + IncompatibleMods: list[str] + RequiresConfiguration: bool + UserPlayable: bool + ValidForMultiplayer: bool + ValidForFreestyleAsRequiredMod: bool + ValidForMultiplayerAsFreeMod: bool + AlwaysValidForSubmission: bool + + +API_MODS: dict[Literal[0, 1, 2, 3], dict[str, Mod]] = {} + + +def init_mods(): + mods_file = STATIC_DIR / "mods.json" + raw_mods = json.loads(mods_file.read_text()) + for ruleset in raw_mods: + ruleset_mods = {} + for mod in ruleset["Mods"]: + ruleset_mods[mod["Acronym"]] = mod + API_MODS[ruleset["RulesetID"]] = ruleset_mods def int_to_mods(mods: int) -> list[APIMod]: @@ -54,3 +98,10 @@ def int_to_mods(mods: int) -> list[APIMod]: if mods & (1 << 9): mod_list.remove(LEGACY_MOD_TO_API_MOD[(1 << 6)]) return mod_list + + +def mods_to_int(mods: list[APIMod]) -> int: + sum_ = 0 + for mod in mods: + sum_ |= API_MOD_TO_LEGACY.get(mod["acronym"], 0) + return sum_ diff --git a/app/models/score.py b/app/models/score.py index 680afd6..f038988 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -1,10 +1,9 @@ from __future__ import annotations from enum import Enum -import json -from typing import Any, Literal, TypedDict +from typing import Literal, TypedDict -from app.path import STATIC_DIR +from .mods import API_MOD_TO_LEGACY, API_MODS, APIMod, init_mods from pydantic import BaseModel, Field, ValidationInfo, field_validator import rosu_pp_py as rosu @@ -46,55 +45,6 @@ class Rank(str, Enum): F = "F" -class APIMod(TypedDict, total=False): - acronym: str - settings: dict[str, Any] - - -legacy_mod: dict[str, int] = { - "NF": 1 << 0, # No Fail - "EZ": 1 << 1, # Easy - "TD": 1 << 2, # Touch Device - "HD": 1 << 3, # Hidden - "HR": 1 << 4, # Hard Rock - "SD": 1 << 5, # Sudden Death - "DT": 1 << 6, # Double Time - "RX": 1 << 7, # Relax - "HT": 1 << 8, # Half Time - "NC": 1 << 9, # Nightcore - "FL": 1 << 10, # Flashlight - "AT": 1 << 11, # Autoplay - "SO": 1 << 12, # Spun Out - "AP": 1 << 13, # Auto Pilot - "PF": 1 << 14, # Perfect - "4K": 1 << 15, # 4K - "5K": 1 << 16, # 5K - "6K": 1 << 17, # 6K - "7K": 1 << 18, # 7K - "8K": 1 << 19, # 8K - "FI": 1 << 20, # Fade In - "RD": 1 << 21, # Random - "CN": 1 << 22, # Cinema - "TP": 1 << 23, # Target Practice - "9K": 1 << 24, # 9K - "CO": 1 << 25, # Key Co-op - "1K": 1 << 26, # 1K - "3K": 1 << 27, # 3K - "2K": 1 << 28, # 2K - "SV2": 1 << 29, # ScoreV2 - "MR": 1 << 30, # Mirror -} -legacy_mod["NC"] |= legacy_mod["DT"] -legacy_mod["PF"] |= legacy_mod["SD"] - - -def api_mod_to_int(mods: list[APIMod]) -> int: - sum_ = 0 - for mod in mods: - sum_ |= legacy_mod.get(mod["acronym"], 0) - return sum_ - - # https://github.com/ppy/osu/blob/master/osu.Game/Rulesets/Scoring/HitResult.cs class HitResult(str, Enum): PERFECT = "perfect" # [Order(0)] @@ -140,44 +90,9 @@ class LeaderboardType(Enum): TEAM = "team" -# see static/mods.json -class Settings(TypedDict): - Name: str - Type: str - Label: str - Description: str - - -class Mod(TypedDict): - Acronym: str - Name: str - Description: str - Type: str - Settings: list[Settings] - IncompatibleMods: list[str] - RequiresConfiguration: bool - UserPlayable: bool - ValidForMultiplayer: bool - ValidForFreestyleAsRequiredMod: bool - ValidForMultiplayerAsFreeMod: bool - AlwaysValidForSubmission: bool - - -MODS: dict[int, dict[str, Mod]] = {} - ScoreStatistics = dict[HitResult, int] -def _init_mods(): - mods_file = STATIC_DIR / "mods.json" - raw_mods = json.loads(mods_file.read_text()) - for ruleset in raw_mods: - ruleset_mods = {} - for mod in ruleset["Mods"]: - ruleset_mods[mod["Acronym"]] = mod - MODS[ruleset["RulesetID"]] = ruleset_mods - - class SoloScoreSubmissionInfo(BaseModel): rank: Rank total_score: int = Field(ge=0, le=2**31 - 1) @@ -194,8 +109,8 @@ class SoloScoreSubmissionInfo(BaseModel): @field_validator("mods", mode="after") @classmethod def validate_mods(cls, mods: list[APIMod], info: ValidationInfo): - if not MODS: - _init_mods() + if not API_MOD_TO_LEGACY: + init_mods() incompatible_mods = set() # check incompatible mods for mod in mods: @@ -203,7 +118,7 @@ class SoloScoreSubmissionInfo(BaseModel): raise ValueError( f"Mod {mod['acronym']} is incompatible with other mods" ) - setting_mods = MODS[info.data["ruleset_id"]].get(mod["acronym"]) + setting_mods = API_MODS[info.data["ruleset_id"]].get(mod["acronym"]) if not setting_mods: raise ValueError(f"Invalid mod: {mod['acronym']}") incompatible_mods.update(setting_mods["IncompatibleMods"]) From ec241ac200741c8c0d41376d009ffe482a22137c Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 04:11:41 +0000 Subject: [PATCH 04/16] fix(solo-score): fix submit solo-score & Bigint --- app/database/auth.py | 6 +- app/database/beatmap.py | 7 +- app/database/legacy.py | 6 +- app/database/relationship.py | 19 +++++- app/database/score.py | 11 +++- app/database/score_token.py | 11 ++-- app/database/team.py | 4 +- app/database/user.py | 69 ++++++++++++++----- app/fetcher/beatmap.py | 9 +-- app/models/score.py | 5 +- app/router/score.py | 124 +++++++++++++++++++++++++++++++++-- 11 files changed, 225 insertions(+), 46 deletions(-) diff --git a/app/database/auth.py b/app/database/auth.py index 8e9032b..ae49676 100644 --- a/app/database/auth.py +++ b/app/database/auth.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import TYPE_CHECKING from sqlalchemy import Column, DateTime -from sqlmodel import Field, Relationship, SQLModel +from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel if TYPE_CHECKING: from .user import User @@ -12,7 +12,9 @@ class OAuthToken(SQLModel, table=True): __tablename__ = "oauth_tokens" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True, index=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("users.id"), index=True) + ) access_token: str = Field(max_length=500, unique=True) refresh_token: str = Field(max_length=500, unique=True) token_type: str = Field(default="Bearer", max_length=20) diff --git a/app/database/beatmap.py b/app/database/beatmap.py index ea15799..46fdd96 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -1,6 +1,6 @@ from datetime import datetime +from typing import TYPE_CHECKING -from app.fetcher import Fetcher from app.models.beatmap import BeatmapRankStatus from app.models.score import MODE_TO_INT, GameMode @@ -11,6 +11,9 @@ from sqlalchemy.orm import joinedload from sqlmodel import VARCHAR, Field, Relationship, SQLModel, select from sqlmodel.ext.asyncio.session import AsyncSession +if TYPE_CHECKING: + from app.fetcher import Fetcher + class BeatmapOwner(SQLModel): id: int @@ -111,7 +114,7 @@ class Beatmap(BeatmapBase, table=True): @classmethod async def get_or_fetch( - cls, session: AsyncSession, bid: int, fetcher: Fetcher + cls, session: AsyncSession, bid: int, fetcher: "Fetcher" ) -> "Beatmap": beatmap = ( await session.exec( diff --git a/app/database/legacy.py b/app/database/legacy.py index c0db405..ff1e957 100644 --- a/app/database/legacy.py +++ b/app/database/legacy.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from sqlalchemy import JSON, Column, DateTime from sqlalchemy.orm import Mapped -from sqlmodel import Field, Relationship, SQLModel +from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel if TYPE_CHECKING: from .user import User @@ -16,7 +16,7 @@ class LegacyUserStatistics(SQLModel, table=True): __tablename__ = "user_statistics" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True, index=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) mode: str = Field(max_length=10) # osu, taiko, fruits, mania # 基本统计 @@ -77,7 +77,7 @@ class LegacyOAuthToken(SQLModel, table=True): __tablename__ = "legacy_oauth_tokens" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) access_token: str = Field(max_length=255, index=True) refresh_token: str = Field(max_length=255, index=True) expires_at: datetime = Field(sa_column=Column(DateTime)) diff --git a/app/database/relationship.py b/app/database/relationship.py index e352b81..cbf7643 100644 --- a/app/database/relationship.py +++ b/app/database/relationship.py @@ -4,7 +4,10 @@ from .user import User from pydantic import BaseModel from sqlmodel import ( + BigInteger, + Column, Field, + ForeignKey, Relationship as SQLRelationship, SQLModel, select, @@ -20,10 +23,22 @@ class RelationshipType(str, Enum): class Relationship(SQLModel, table=True): __tablename__ = "relationship" # pyright: ignore[reportAssignmentType] user_id: int = Field( - default=None, foreign_key="users.id", primary_key=True, index=True + default=None, + sa_column=Column( + BigInteger, + ForeignKey("users.id"), + primary_key=True, + index=True, + ), ) target_id: int = Field( - default=None, foreign_key="users.id", primary_key=True, index=True + default=None, + sa_column=Column( + BigInteger, + ForeignKey("users.id"), + primary_key=True, + index=True, + ), ) type: RelationshipType = Field(default=RelationshipType.FOLLOW, nullable=False) target: "User" = SQLRelationship( diff --git a/app/database/score.py b/app/database/score.py index f82a813..1bc2e58 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -22,6 +22,7 @@ from sqlmodel import ( JSON, BigInteger, Field, + ForeignKey, Relationship, SQLModel, col, @@ -69,7 +70,14 @@ class Score(ScoreBase, table=True): default=None, sa_column=Column(BigInteger, autoincrement=True, primary_key=True) ) beatmap_id: int = Field(index=True, foreign_key="beatmaps.id") - user_id: int = Field(foreign_key="users.id", index=True) + user_id: int = Field( + default=None, + sa_column=Column( + BigInteger, + ForeignKey("users.id"), + index=True, + ), + ) # ScoreStatistics n300: int = Field(exclude=True) n100: int = Field(exclude=True) @@ -92,6 +100,7 @@ class Score(ScoreBase, table=True): class ScoreResp(ScoreBase): id: int + user_id: int is_perfect_combo: bool = False legacy_perfect: bool = False legacy_total_score: int = 0 # FIXME diff --git a/app/database/score_token.py b/app/database/score_token.py index 195a174..6a6edb3 100644 --- a/app/database/score_token.py +++ b/app/database/score_token.py @@ -27,12 +27,15 @@ class ScoreToken(ScoreTokenBase, table=True): id: int | None = Field( default=None, - primary_key=True, - index=True, - sa_column_kwargs={"autoincrement": True}, + sa_column=Column( + BigInteger, + primary_key=True, + index=True, + autoincrement=True, + ), ) user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) - beatmap_id: int = Field(sa_column=Column(BigInteger, ForeignKey("beatmaps.id"))) + beatmap_id: int = Field(foreign_key="beatmaps.id") user: "User" = Relationship() beatmap: "Beatmap" = Relationship() diff --git a/app/database/team.py b/app/database/team.py index 2722319..360e805 100644 --- a/app/database/team.py +++ b/app/database/team.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import TYPE_CHECKING from sqlalchemy import Column, DateTime -from sqlmodel import Field, Relationship, SQLModel +from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel if TYPE_CHECKING: from .user import User @@ -26,7 +26,7 @@ class TeamMember(SQLModel, table=True): __tablename__ = "team_members" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True, index=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) team_id: int = Field(foreign_key="teams.id") joined_at: datetime = Field( default_factory=datetime.utcnow, sa_column=Column(DateTime) diff --git a/app/database/user.py b/app/database/user.py index 71a6eda..6c70ce0 100644 --- a/app/database/user.py +++ b/app/database/user.py @@ -7,7 +7,7 @@ from .team import TeamMember from sqlalchemy import DECIMAL, JSON, Column, Date, DateTime, Text from sqlalchemy.dialects.mysql import VARCHAR -from sqlmodel import BigInteger, Field, Relationship, SQLModel +from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel class User(SQLModel, table=True): @@ -109,7 +109,14 @@ class User(SQLModel, table=True): class LazerUserProfile(SQLModel, table=True): __tablename__ = "lazer_user_profiles" # pyright: ignore[reportAssignmentType] - user_id: int = Field(foreign_key="users.id", primary_key=True) + user_id: int = Field( + default=None, + sa_column=Column( + BigInteger, + ForeignKey("users.id"), + primary_key=True, + ), + ) # 基本状态字段 is_active: bool = Field(default=True) @@ -165,7 +172,7 @@ class LazerUserProfileSections(SQLModel, table=True): __tablename__ = "lazer_user_profile_sections" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) section_name: str = Field(sa_column=Column(VARCHAR(50))) display_order: int | None = Field(default=None) @@ -182,7 +189,14 @@ class LazerUserProfileSections(SQLModel, table=True): class LazerUserCountry(SQLModel, table=True): __tablename__ = "lazer_user_countries" # pyright: ignore[reportAssignmentType] - user_id: int = Field(foreign_key="users.id", primary_key=True) + user_id: int = Field( + default=None, + sa_column=Column( + BigInteger, + ForeignKey("users.id"), + primary_key=True, + ), + ) code: str = Field(max_length=2) name: str = Field(max_length=100) @@ -197,7 +211,14 @@ class LazerUserCountry(SQLModel, table=True): class LazerUserKudosu(SQLModel, table=True): __tablename__ = "lazer_user_kudosu" # pyright: ignore[reportAssignmentType] - user_id: int = Field(foreign_key="users.id", primary_key=True) + user_id: int = Field( + default=None, + sa_column=Column( + BigInteger, + ForeignKey("users.id"), + primary_key=True, + ), + ) available: int = Field(default=0) total: int = Field(default=0) @@ -212,7 +233,14 @@ class LazerUserKudosu(SQLModel, table=True): class LazerUserCounts(SQLModel, table=True): __tablename__ = "lazer_user_counts" # pyright: ignore[reportAssignmentType] - user_id: int = Field(foreign_key="users.id", primary_key=True) + user_id: int = Field( + default=None, + sa_column=Column( + BigInteger, + ForeignKey("users.id"), + primary_key=True, + ), + ) # 统计计数字段 beatmap_playcounts_count: int = Field(default=0) @@ -247,7 +275,14 @@ class LazerUserCounts(SQLModel, table=True): class LazerUserStatistics(SQLModel, table=True): __tablename__ = "lazer_user_statistics" # pyright: ignore[reportAssignmentType] - user_id: int = Field(foreign_key="users.id", primary_key=True) + user_id: int = Field( + default=None, + sa_column=Column( + BigInteger, + ForeignKey("users.id"), + primary_key=True, + ), + ) mode: str = Field(default="osu", max_length=10, primary_key=True) # 基本命中统计 @@ -308,7 +343,7 @@ class LazerUserBanners(SQLModel, table=True): __tablename__ = "lazer_user_tournament_banners" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) tournament_id: int image_url: str = Field(sa_column=Column(VARCHAR(500))) is_active: bool | None = Field(default=None) @@ -321,7 +356,7 @@ class LazerUserAchievement(SQLModel, table=True): __tablename__ = "lazer_user_achievements" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True, index=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) achievement_id: int achieved_at: datetime = Field( default_factory=datetime.utcnow, sa_column=Column(DateTime) @@ -334,7 +369,7 @@ class LazerUserBadge(SQLModel, table=True): __tablename__ = "lazer_user_badges" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True, index=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) badge_id: int awarded_at: datetime | None = Field(default=None, sa_column=Column(DateTime)) description: str | None = Field(default=None, sa_column=Column(Text)) @@ -355,7 +390,7 @@ class LazerUserMonthlyPlaycounts(SQLModel, table=True): __tablename__ = "lazer_user_monthly_playcounts" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True, index=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) start_date: datetime = Field(sa_column=Column(Date)) play_count: int = Field(default=0) @@ -373,7 +408,7 @@ class LazerUserPreviousUsername(SQLModel, table=True): __tablename__ = "lazer_user_previous_usernames" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True, index=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) username: str = Field(max_length=32) changed_at: datetime = Field(sa_column=Column(DateTime)) @@ -391,7 +426,7 @@ class LazerUserReplaysWatched(SQLModel, table=True): __tablename__ = "lazer_user_replays_watched" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True, index=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) start_date: datetime = Field(sa_column=Column(Date)) count: int = Field(default=0) @@ -416,7 +451,9 @@ class DailyChallengeStats(SQLModel, table=True): __tablename__ = "daily_challenge_stats" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True, index=True) - user_id: int = Field(foreign_key="users.id", unique=True) + user_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("users.id"), unique=True) + ) daily_streak_best: int = Field(default=0) daily_streak_current: int = Field(default=0) @@ -437,7 +474,7 @@ class RankHistory(SQLModel, table=True): __tablename__ = "rank_history" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True, index=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) mode: str = Field(max_length=10) rank_data: list = Field(sa_column=Column(JSON)) # Array of ranks date_recorded: datetime = Field( @@ -451,7 +488,7 @@ class UserAvatar(SQLModel, table=True): __tablename__ = "user_avatars" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True, index=True) - user_id: int = Field(foreign_key="users.id") + user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id"))) filename: str = Field(max_length=255) original_filename: str = Field(max_length=255) file_size: int diff --git a/app/fetcher/beatmap.py b/app/fetcher/beatmap.py index d9da207..8e770f1 100644 --- a/app/fetcher/beatmap.py +++ b/app/fetcher/beatmap.py @@ -1,19 +1,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from app.database.beatmap import BeatmapResp from ._base import BaseFetcher from httpx import AsyncClient -if TYPE_CHECKING: - from app.database.beatmap import BeatmapResp - class BeatmapFetcher(BaseFetcher): - async def get_beatmap(self, beatmap_id: int) -> "BeatmapResp": - from app.database.beatmap import BeatmapResp - + async def get_beatmap(self, beatmap_id: int) -> BeatmapResp: async with AsyncClient() as client: response = await client.get( f"https://osu.ppy.sh/api/v2/beatmaps/{beatmap_id}", diff --git a/app/models/score.py b/app/models/score.py index f038988..d1e391a 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum from typing import Literal, TypedDict -from .mods import API_MOD_TO_LEGACY, API_MODS, APIMod, init_mods +from .mods import API_MODS, APIMod, init_mods from pydantic import BaseModel, Field, ValidationInfo, field_validator import rosu_pp_py as rosu @@ -109,7 +109,7 @@ class SoloScoreSubmissionInfo(BaseModel): @field_validator("mods", mode="after") @classmethod def validate_mods(cls, mods: list[APIMod], info: ValidationInfo): - if not API_MOD_TO_LEGACY: + if not API_MODS: init_mods() incompatible_mods = set() # check incompatible mods @@ -122,6 +122,7 @@ class SoloScoreSubmissionInfo(BaseModel): if not setting_mods: raise ValueError(f"Invalid mod: {mod['acronym']}") incompatible_mods.update(setting_mods["IncompatibleMods"]) + return mods class LegacyReplaySoloScoreInfo(TypedDict): diff --git a/app/router/score.py b/app/router/score.py index 9cc57f1..2bf9519 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -1,17 +1,22 @@ from __future__ import annotations +import datetime + from app.database import ( Beatmap, User as DBUser, ) from app.database.beatmapset import Beatmapset from app.database.score import Score, ScoreResp +from app.database.score_token import ScoreToken, ScoreTokenResp +from app.database.user import User from app.dependencies.database import get_db from app.dependencies.user import get_current_user +from app.models.score import INT_TO_MODE, HitResult, Rank, SoloScoreSubmissionInfo from .api_router import router -from fastapi import Depends, HTTPException, Query +from fastapi import Depends, Form, HTTPException, Query from pydantic import BaseModel from sqlalchemy.orm import joinedload from sqlmodel import col, select @@ -63,8 +68,8 @@ async def get_beatmap_scores( ).first() return BeatmapScores( - scores=[ScoreResp.from_db(score) for score in all_scores], - userScore=ScoreResp.from_db(user_score) if user_score else None, + scores=[await ScoreResp.from_db(db, score) for score in all_scores], + userScore=await ScoreResp.from_db(db, user_score) if user_score else None, ) @@ -115,7 +120,7 @@ async def get_user_beatmap_score( else: return BeatmapUserScore( position=user_score.position if user_score.position is not None else 0, - score=ScoreResp.from_db(user_score), + score=await ScoreResp.from_db(db, user_score), ) @@ -153,4 +158,113 @@ async def get_user_all_beatmap_scores( ) ).all() - return [ScoreResp.from_db(score) for score in all_user_scores] + return [await ScoreResp.from_db(db, score) for score in all_user_scores] + + +@router.post( + "/beatmaps/{beatmap}/solo/scores", tags=["beatmap"], response_model=ScoreTokenResp +) +async def create_solo_score( + beatmap: int, + version_hash: str = Form(""), + beatmap_hash: str = Form(), + ruleset_id: int = Form(..., ge=0, le=3), + current_user: DBUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + async with db: + score_token = ScoreToken( + user_id=current_user.id, + beatmap_id=beatmap, + ruleset_id=INT_TO_MODE[ruleset_id], + ) + db.add(score_token) + await db.commit() + await db.refresh(score_token) + return ScoreTokenResp.from_db(score_token) + + +@router.put( + "/beatmaps/{beatmap}/solo/scores/{token}", + tags=["beatmap"], + response_model=ScoreResp, +) +async def submit_solo_score( + beatmap: int, + token: int, + info: SoloScoreSubmissionInfo, + current_user: DBUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if not info.passed: + info.rank = Rank.F + async with db: + score_token = ( + await db.exec( + select(ScoreToken) + .options(joinedload(ScoreToken.beatmap)) # pyright: ignore[reportArgumentType] + .where(ScoreToken.id == token, ScoreToken.user_id == current_user.id) + ) + ).first() + if not score_token or score_token.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Score token not found") + if score_token.score_id: + score = ( + await db.exec( + select(Score) + .options(joinedload(Score.beatmap)) # pyright: ignore[reportArgumentType] + .where( + Score.id == score_token.score_id, + Score.user_id == current_user.id, + ) + ) + ).first() + if not score: + raise HTTPException(status_code=404, detail="Score not found") + else: + score = Score( + accuracy=info.accuracy, + max_combo=info.max_combo, + # maximum_statistics=info.maximum_statistics, + mods=info.mods, + passed=info.passed, + rank=info.rank, + total_score=info.total_score, + total_score_without_mods=info.total_score_without_mods, + beatmap_id=beatmap, + ended_at=datetime.datetime.now(datetime.UTC), + gamemode=INT_TO_MODE[info.ruleset_id], + started_at=score_token.created_at, + user_id=current_user.id, + preserve=info.passed, + map_md5=score_token.beatmap.checksum, + has_replay=False, + pp=info.pp, + type="solo", + n300=info.statistics.get(HitResult.GREAT, 0), + n100=info.statistics.get(HitResult.OK, 0), + n50=info.statistics.get(HitResult.MEH, 0), + nmiss=info.statistics.get(HitResult.MISS, 0), + ngeki=info.statistics.get(HitResult.PERFECT, 0), + nkatu=info.statistics.get(HitResult.GOOD, 0), + ) + db.add(score) + await db.commit() + await db.refresh(score) + score_id = score.id + score_token.score_id = score_id + await db.commit() + score = ( + await db.exec( + select(Score) + .options( + joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] + .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] + .selectinload(Beatmapset.beatmaps), # pyright: ignore[reportArgumentType] + joinedload(Score.user).joinedload(User.lazer_profile), # pyright: ignore[reportArgumentType] + ) + .where(Score.id == score_id) + ) + ).first() + assert score is not None + return await ScoreResp.from_db(db, score) From a8b05f157448721f1b2fee2877b73f77f00ed878 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 05:10:33 +0000 Subject: [PATCH 05/16] fix(leaderboard): fix failed & duplicated scores in leaderboard --- app/database/score.py | 69 +++++++++++++++++++++++++++++++++---- app/router/score.py | 79 +++++++++++++++++-------------------------- 2 files changed, 93 insertions(+), 55 deletions(-) diff --git a/app/database/score.py b/app/database/score.py index 1bc2e58..180096d 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -14,10 +14,10 @@ from app.models.score import ( ) from .beatmap import Beatmap, BeatmapResp -from .beatmapset import BeatmapsetResp +from .beatmapset import Beatmapset, BeatmapsetResp -from sqlalchemy import Column, DateTime -from sqlalchemy.orm import joinedload +from sqlalchemy import Column, ColumnExpressionArgument, DateTime +from sqlalchemy.orm import aliased, joinedload from sqlmodel import ( JSON, BigInteger, @@ -31,6 +31,7 @@ from sqlmodel import ( select, ) from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel.sql._expression_select_cls import SelectOfScalar class ScoreBase(SQLModel): @@ -97,6 +98,43 @@ class Score(ScoreBase, table=True): def is_perfect_combo(self) -> bool: return self.max_combo == self.beatmap.max_combo + @staticmethod + def select_clause() -> SelectOfScalar["Score"]: + return select(Score).options( + joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] + .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] + .selectinload( + Beatmapset.beatmaps # pyright: ignore[reportArgumentType] + ), + joinedload(Score.user).joinedload(User.lazer_profile), # pyright: ignore[reportArgumentType] + ) + + @staticmethod + def select_clause_unique( + *where_clauses: ColumnExpressionArgument[bool] | bool, + ) -> SelectOfScalar["Score"]: + rownum = ( + func.row_number() + .over( + partition_by=col(Score.user_id), order_by=col(Score.total_score).desc() + ) + .label("rn") + ) + subq = select(Score, rownum).where(*where_clauses).subquery() + best = aliased(Score, subq, adapt_on_names=True) + return ( + select(best) + .where(subq.c.rn == 1) + .options( + joinedload(best.beatmap) # pyright: ignore[reportArgumentType] + .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] + .selectinload( + Beatmapset.beatmaps # pyright: ignore[reportArgumentType] + ), + joinedload(best.user).joinedload(User.lazer_profile), # pyright: ignore[reportArgumentType] + ) + ) + class ScoreResp(ScoreBase): id: int @@ -300,6 +338,7 @@ async def get_score_position_by_id( Score.map_md5 == beatmap_md5, Score.id == score_id, Score.gamemode == mode, + col(Score.passed).is_(True), col(Beatmap.beatmap_status).in_( [ BeatmapRankStatus.RANKED, @@ -316,13 +355,29 @@ async def get_score_position_by_id( rownum = ( func.row_number() .over( - partition_by=Score.map_md5, + partition_by=[col(Score.user_id), col(Score.map_md5)], order_by=col(Score.total_score).desc(), ) - .label("row_number") + .label("rownum") ) - subq = select(Score, rownum).join(Beatmap).where(*where_clause).subquery() - stmt = select(subq.c.row_number).where(subq.c.id == score_id) + subq = ( + select(Score.user_id, Score.id, Score.total_score, rownum) + .join(Beatmap) + .where(*where_clause) + .subquery() + ) + best_scores = aliased(subq) + overall_rank = ( + func.rank().over(order_by=best_scores.c.total_score.desc()).label("global_rank") + ) + final_q = ( + select(best_scores.c.id, overall_rank) + .select_from(best_scores) + .where(best_scores.c.rownum == 1) + .subquery() + ) + + stmt = select(final_q.c.global_rank).where(final_q.c.id == score_id) result = await session.exec(stmt) s = result.one_or_none() return s if s else 0 diff --git a/app/router/score.py b/app/router/score.py index 2bf9519..cc1629a 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -3,23 +3,26 @@ from __future__ import annotations import datetime from app.database import ( - Beatmap, User as DBUser, ) -from app.database.beatmapset import Beatmapset from app.database.score import Score, ScoreResp from app.database.score_token import ScoreToken, ScoreTokenResp -from app.database.user import User from app.dependencies.database import get_db from app.dependencies.user import get_current_user -from app.models.score import INT_TO_MODE, HitResult, Rank, SoloScoreSubmissionInfo +from app.models.score import ( + INT_TO_MODE, + GameMode, + HitResult, + Rank, + SoloScoreSubmissionInfo, +) from .api_router import router from fastapi import Depends, Form, HTTPException, Query from pydantic import BaseModel from sqlalchemy.orm import joinedload -from sqlmodel import col, select +from sqlmodel import col, select, true from sqlmodel.ext.asyncio.session import AsyncSession @@ -34,7 +37,7 @@ class BeatmapScores(BaseModel): async def get_beatmap_scores( beatmap: int, legacy_only: bool = Query(None), # TODO:加入对这个参数的查询 - mode: str = Query(None), + mode: GameMode | None = Query(None), # mods: List[APIMod] = Query(None), # TODO:加入指定MOD的查询 type: str = Query(None), current_user: DBUser = Depends(get_current_user), @@ -47,23 +50,22 @@ async def get_beatmap_scores( all_scores = ( await db.exec( - select(Score).where(Score.beatmap_id == beatmap) - # .where(Score.mods == mods if mods else True) + Score.select_clause_unique( + Score.beatmap_id == beatmap, + col(Score.passed).is_(True), + Score.gamemode == mode if mode is not None else true(), + ) ) ).all() user_score = ( await db.exec( - select(Score) - .options( - joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] - .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] - .selectinload( - Beatmapset.beatmaps # pyright: ignore[reportArgumentType] - ) + Score.select_clause_unique( + Score.beatmap_id == beatmap, + Score.user_id == current_user.id, + col(Score.passed).is_(True), + Score.gamemode == mode if mode is not None else true(), ) - .where(Score.beatmap_id == beatmap) - .where(Score.user_id == current_user.id) ) ).first() @@ -98,18 +100,13 @@ async def get_user_beatmap_score( ) user_score = ( await db.exec( - select(Score) - .options( - joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] - .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] - .selectinload( - Beatmapset.beatmaps # pyright: ignore[reportArgumentType] - ) + Score.select_clause() + .where( + Score.gamemode == mode if mode is not None else True, + Score.beatmap_id == beatmap, + Score.user_id == user, ) - .where(Score.gamemode == mode if mode is not None else True) - .where(Score.beatmap_id == beatmap) - .where(Score.user_id == user) - .order_by(col(Score.classic_total_score).desc()) + .order_by(col(Score.total_score).desc()) ) ).first() @@ -143,17 +140,12 @@ async def get_user_all_beatmap_scores( ) all_user_scores = ( await db.exec( - select(Score) - .options( - joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] - .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] - .selectinload( - Beatmapset.beatmaps # pyright: ignore[reportArgumentType] - ) + Score.select_clause() + .where( + Score.gamemode == ruleset if ruleset is not None else True, + Score.beatmap_id == beatmap, + Score.user_id == user, ) - .where(Score.gamemode == ruleset if ruleset is not None else True) - .where(Score.beatmap_id == beatmap) - .where(Score.user_id == user) .order_by(col(Score.classic_total_score).desc()) ) ).all() @@ -255,16 +247,7 @@ async def submit_solo_score( score_token.score_id = score_id await db.commit() score = ( - await db.exec( - select(Score) - .options( - joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] - .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] - .selectinload(Beatmapset.beatmaps), # pyright: ignore[reportArgumentType] - joinedload(Score.user).joinedload(User.lazer_profile), # pyright: ignore[reportArgumentType] - ) - .where(Score.id == score_id) - ) + await db.exec(Score.select_clause().where(Score.id == score_id)) ).first() assert score is not None return await ScoreResp.from_db(db, score) From 19895789acc07d472bbe2637a65cc0894b9d18fc Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 09:01:33 +0000 Subject: [PATCH 06/16] feat(beatmap): support lookup api --- app/database/beatmap.py | 12 +++++++++--- app/fetcher/beatmap.py | 13 +++++++++++-- app/router/beatmap.py | 29 +++++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/app/database/beatmap.py b/app/database/beatmap.py index 46fdd96..d821e27 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -114,19 +114,25 @@ class Beatmap(BeatmapBase, table=True): @classmethod async def get_or_fetch( - cls, session: AsyncSession, bid: int, fetcher: "Fetcher" + cls, + session: AsyncSession, + fetcher: "Fetcher", + bid: int | None = None, + md5: str | None = None, ) -> "Beatmap": beatmap = ( await session.exec( select(Beatmap) - .where(Beatmap.id == bid) + .where( + Beatmap.id == bid if bid is not None else Beatmap.checksum == md5 + ) .options( joinedload(Beatmap.beatmapset).selectinload(Beatmapset.beatmaps) # pyright: ignore[reportArgumentType] ) ) ).first() if not beatmap: - resp = await fetcher.get_beatmap(bid) + resp = await fetcher.get_beatmap(bid, md5) r = await session.exec( select(Beatmapset.id).where(Beatmapset.id == resp.beatmapset_id) ) diff --git a/app/fetcher/beatmap.py b/app/fetcher/beatmap.py index 8e770f1..dabfb68 100644 --- a/app/fetcher/beatmap.py +++ b/app/fetcher/beatmap.py @@ -8,11 +8,20 @@ from httpx import AsyncClient class BeatmapFetcher(BaseFetcher): - async def get_beatmap(self, beatmap_id: int) -> BeatmapResp: + async def get_beatmap( + self, beatmap_id: int | None = None, beatmap_checksum: str | None = None + ) -> BeatmapResp: + if beatmap_id: + params = {"id": beatmap_id} + elif beatmap_checksum: + params = {"checksum": beatmap_checksum} + else: + raise ValueError("Either beatmap_id or beatmap_checksum must be provided.") async with AsyncClient() as client: response = await client.get( - f"https://osu.ppy.sh/api/v2/beatmaps/{beatmap_id}", + "https://osu.ppy.sh/api/v2/beatmaps/lookup", headers=self.header, + params=params, ) response.raise_for_status() return BeatmapResp.model_validate(response.json()) diff --git a/app/router/beatmap.py b/app/router/beatmap.py index 4cf717e..cf59148 100644 --- a/app/router/beatmap.py +++ b/app/router/beatmap.py @@ -34,6 +34,31 @@ from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession +@router.get("/beatmaps/lookup", tags=["beatmap"], response_model=BeatmapResp) +async def lookup_beatmap( + id: int | None = Query(default=None, alias="id"), + md5: str | None = Query(default=None, alias="checksum"), + filename: str | None = Query(default=None, alias="filename"), + current_user: DBUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + fetcher: Fetcher = Depends(get_fetcher), +): + if id is None and md5 is None and filename is None: + raise HTTPException( + status_code=400, + detail="At least one of 'id', 'checksum', or 'filename' must be provided.", + ) + try: + beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=id, md5=md5) + except HTTPError: + raise HTTPException(status_code=404, detail="Beatmap not found") + + if beatmap is None: + raise HTTPException(status_code=404, detail="Beatmap not found") + + return BeatmapResp.from_db(beatmap) + + @router.get("/beatmaps/{bid}", tags=["beatmap"], response_model=BeatmapResp) async def get_beatmap( bid: int, @@ -42,7 +67,7 @@ async def get_beatmap( fetcher: Fetcher = Depends(get_fetcher), ): try: - beatmap = await Beatmap.get_or_fetch(db, bid, fetcher) + beatmap = await Beatmap.get_or_fetch(db, fetcher, bid) return BeatmapResp.from_db(beatmap) except HTTPError: raise HTTPException(status_code=404, detail="Beatmap not found") @@ -122,7 +147,7 @@ async def get_beatmap_attributes( if ruleset_id is not None and ruleset is None: ruleset = INT_TO_MODE[ruleset_id] if ruleset is None: - beatmap_db = await Beatmap.get_or_fetch(db, beatmap, fetcher) + beatmap_db = await Beatmap.get_or_fetch(db, fetcher, beatmap) ruleset = beatmap_db.mode key = ( f"beatmap:{beatmap}:{ruleset}:" From 3ee95b0e7c0ecbb439ad12f5041c89b2efb98d0e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 09:03:23 +0000 Subject: [PATCH 07/16] feat(spectator): support save replays --- .gitignore | 421 ++++++++++++++++++----------------- app/config.py | 2 +- app/models/score.py | 44 +++- app/models/spectator_hub.py | 57 ++++- app/path.py | 1 + app/signalr/hub/hub.py | 11 +- app/signalr/hub/spectator.py | 273 ++++++++++++++++++++++- app/signalr/packet.py | 6 +- app/signalr/utils.py | 26 +-- app/utils.py | 2 +- 10 files changed, 600 insertions(+), 243 deletions(-) diff --git a/.gitignore b/.gitignore index 14d64a2..6923d04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,209 +1,212 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ -bancho.py-master/* -.vscode/settings.json +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ +bancho.py-master/* +.vscode/settings.json + +# runtime file +replays/ diff --git a/app/config.py b/app/config.py index b9677af..a514e6f 100644 --- a/app/config.py +++ b/app/config.py @@ -34,7 +34,7 @@ class Settings: # SignalR 设置 SIGNALR_NEGOTIATE_TIMEOUT: int = int(os.getenv("SIGNALR_NEGOTIATE_TIMEOUT", "30")) - SIGNALR_PING_INTERVAL: int = int(os.getenv("SIGNALR_PING_INTERVAL", "120")) + SIGNALR_PING_INTERVAL: int = int(os.getenv("SIGNALR_PING_INTERVAL", "15")) # Fetcher 设置 FETCHER_CLIENT_ID: str = os.getenv("FETCHER_CLIENT_ID", "") diff --git a/app/models/score.py b/app/models/score.py index d1e391a..35bb2bf 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -1,6 +1,6 @@ from __future__ import annotations -from enum import Enum +from enum import Enum, IntEnum from typing import Literal, TypedDict from .mods import API_MODS, APIMod, init_mods @@ -83,6 +83,43 @@ class HitResult(str, Enum): ) +class HitResultInt(IntEnum): + PERFECT = 0 + GREAT = 1 + GOOD = 2 + OK = 3 + MEH = 4 + MISS = 5 + + LARGE_TICK_HIT = 6 + SMALL_TICK_HIT = 7 + SLIDER_TAIL_HIT = 8 + + LARGE_BONUS = 9 + SMALL_BONUS = 10 + + LARGE_TICK_MISS = 11 + SMALL_TICK_MISS = 12 + + IGNORE_HIT = 13 + IGNORE_MISS = 14 + + NONE = 15 + COMBO_BREAK = 16 + + LEGACY_COMBO_INCREASE = 99 + + def is_hit(self) -> bool: + return self not in ( + HitResultInt.NONE, + HitResultInt.IGNORE_MISS, + HitResultInt.COMBO_BREAK, + HitResultInt.LARGE_TICK_MISS, + HitResultInt.SMALL_TICK_MISS, + HitResultInt.MISS, + ) + + class LeaderboardType(Enum): GLOBAL = "global" FRIENDS = "friends" @@ -91,6 +128,7 @@ class LeaderboardType(Enum): ScoreStatistics = dict[HitResult, int] +ScoreStatisticsInt = dict[HitResultInt, int] class SoloScoreSubmissionInfo(BaseModel): @@ -128,8 +166,8 @@ class SoloScoreSubmissionInfo(BaseModel): class LegacyReplaySoloScoreInfo(TypedDict): online_id: int mods: list[APIMod] - statistics: ScoreStatistics - maximum_statistics: ScoreStatistics + statistics: ScoreStatisticsInt + maximum_statistics: ScoreStatisticsInt client_version: str rank: Rank user_id: int diff --git a/app/models/spectator_hub.py b/app/models/spectator_hub.py index d9aa296..053f0f6 100644 --- a/app/models/spectator_hub.py +++ b/app/models/spectator_hub.py @@ -4,18 +4,23 @@ import datetime from enum import IntEnum from typing import Any +from app.models.beatmap import BeatmapRankStatus + from .score import ( - HitResult, + GameMode, + ScoreStatisticsInt, ) from .signalr import MessagePackArrayModel import msgpack -from pydantic import Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator class APIMod(MessagePackArrayModel): acronym: str - settings: dict[str, Any] = Field(default_factory=dict) + settings: dict[str, Any] | list = Field( + default_factory=dict + ) # FIXME: with settings class SpectatedUserState(IntEnum): @@ -32,7 +37,7 @@ class SpectatorState(MessagePackArrayModel): ruleset_id: int | None = None # 0,1,2,3 mods: list[APIMod] = Field(default_factory=list) state: SpectatedUserState - maximum_statistics: dict[HitResult, int] = Field(default_factory=dict) + maximum_statistics: ScoreStatisticsInt = Field(default_factory=dict) def __eq__(self, other: object) -> bool: if not isinstance(other, SpectatorState): @@ -54,11 +59,13 @@ class ScoreProcessorStatistics(MessagePackArrayModel): class FrameHeader(MessagePackArrayModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + total_score: int acc: float combo: int max_combo: int - statistics: dict[HitResult, int] = Field(default_factory=dict) + statistics: ScoreStatisticsInt = Field(default_factory=dict) score_processor_statistics: ScoreProcessorStatistics received_time: datetime.datetime mods: list[APIMod] = Field(default_factory=list) @@ -78,6 +85,10 @@ class FrameHeader(MessagePackArrayModel): return datetime.datetime.fromisoformat(v) raise ValueError(f"Cannot convert {type(v)} to datetime") + @field_serializer("received_time") + def serialize_received_time(self, v: datetime.datetime) -> msgpack.ext.Timestamp: + return msgpack.ext.Timestamp.from_datetime(v) + class ReplayButtonState(IntEnum): NONE = 0 @@ -89,7 +100,7 @@ class ReplayButtonState(IntEnum): class LegacyReplayFrame(MessagePackArrayModel): - time: int # from ReplayFrame,the parent of LegacyReplayFrame + time: float # from ReplayFrame,the parent of LegacyReplayFrame x: float | None = None y: float | None = None button_state: ReplayButtonState @@ -98,3 +109,37 @@ class LegacyReplayFrame(MessagePackArrayModel): class FrameDataBundle(MessagePackArrayModel): header: FrameHeader frames: list[LegacyReplayFrame] + + +# Use for server +class APIUser(BaseModel): + id: int + name: str + + +class ScoreInfo(BaseModel): + mods: list[APIMod] + user: APIUser + ruleset: int + maximum_statistics: ScoreStatisticsInt + id: int | None = None + total_score: int | None = None + acc: float | None = None + max_combo: int | None = None + combo: int | None = None + statistics: ScoreStatisticsInt = Field(default_factory=dict) + + +class StoreScore(BaseModel): + score_info: ScoreInfo + replay_frames: list[LegacyReplayFrame] = Field(default_factory=list) + + +class StoreClientState(BaseModel): + state: SpectatorState | None + beatmap_status: BeatmapRankStatus + checksum: str + gamemode: GameMode + score_token: int + watched_user: set[int] + score: StoreScore diff --git a/app/path.py b/app/path.py index b61309c..d086837 100644 --- a/app/path.py +++ b/app/path.py @@ -5,3 +5,4 @@ from pathlib import Path STATIC_DIR = Path(__file__).parent.parent / "static" REPLAY_DIR = Path(__file__).parent.parent / "replays" +REPLAY_DIR.mkdir(exist_ok=True) diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index 1e5e123..3175b8d 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import time +import traceback from typing import Any from app.config import settings @@ -92,6 +93,12 @@ class Hub: async def send_packet(self, client: Client, type: PacketType, packet: list[Any]): await client.send_packet(type, packet) + async def broadcast_call(self, method: str, *args: Any) -> None: + tasks = [] + for client in self.clients.values(): + tasks.append(self.call_noblock(client, method, *args)) + await asyncio.gather(*tasks) + async def _listen_client(self, client: Client) -> None: jump = False while not jump: @@ -104,13 +111,12 @@ class Hub: self.tasks.add(task) task.add_done_callback(self.tasks.discard) except WebSocketDisconnect as e: - if e.code == 1005: - continue print( f"Client {client.connection_id} disconnected: {e.code}, {e.reason}" ) jump = True except Exception as e: + traceback.print_exc() print(f"Error in client {client.connection_id}: {e}") jump = True await self.remove_client(client.connection_id) @@ -139,6 +145,7 @@ class Hub: result = e.message except Exception as e: + traceback.print_exc() code = ResultKind.ERROR result = str(e) diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index 1c65a37..5d38d95 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -1,15 +1,278 @@ from __future__ import annotations -from app.models.spectator_hub import FrameDataBundle, SpectatorState +import json +import lzma +import struct +import time + +from app.database import Beatmap +from app.database.score import Score +from app.database.score_token import ScoreToken +from app.database.user import User +from app.dependencies.database import engine +from app.models.beatmap import BeatmapRankStatus +from app.models.mods import mods_to_int +from app.models.score import MODE_TO_INT, LegacyReplaySoloScoreInfo, ScoreStatisticsInt +from app.models.spectator_hub import ( + APIUser, + FrameDataBundle, + LegacyReplayFrame, + ScoreInfo, + SpectatedUserState, + SpectatorState, + StoreClientState, + StoreScore, +) +from app.path import REPLAY_DIR +from app.utils import unix_timestamp_to_windows from .hub import Client, Hub +from sqlalchemy.orm import joinedload +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +READ_SCORE_TIMEOUT = 30 +REPLAY_LATEST_VER = 30000016 + + +def encode_uleb128(num: int) -> bytes | bytearray: + if num == 0: + return b"\x00" + + ret = bytearray() + + while num != 0: + ret.append(num & 0x7F) + num >>= 7 + if num != 0: + ret[-1] |= 0x80 + + return ret + + +def encode_string(s: str) -> bytes: + """Write `s` into bytes (ULEB128 & string).""" + if s: + encoded = s.encode() + ret = b"\x0b" + encode_uleb128(len(encoded)) + encoded + else: + ret = b"\x00" + + return ret + + +def save_replay( + ruleset_id: int, + md5: str, + username: str, + score: Score, + statistics: ScoreStatisticsInt, + maximum_statistics: ScoreStatisticsInt, + frames: list[LegacyReplayFrame], +) -> None: + data = bytearray() + data.extend(struct.pack(" None: + super().__init__() + self.state: dict[int, StoreClientState] = {} + async def BeginPlaySession( self, client: Client, score_token: int, state: SpectatorState - ) -> None: ... + ) -> None: + user_id = int(client.connection_id) + previous_state = self.state.get(user_id) + if previous_state is not None: + return + if state.beatmap_id is None or state.ruleset_id is None: + return + async with AsyncSession(engine) as session: + async with session.begin(): + beatmap = ( + await session.exec( + select(Beatmap).where(Beatmap.id == state.beatmap_id) + ) + ).first() + if not beatmap: + return + user = ( + await session.exec(select(User).where(User.id == user_id)) + ).first() + if not user: + return + name = user.name + store = StoreClientState( + state=state, + beatmap_status=beatmap.beatmap_status, + checksum=beatmap.checksum, + gamemode=beatmap.mode, + score_token=score_token, + watched_user=set(), + score=StoreScore( + score_info=ScoreInfo( + mods=state.mods, + user=APIUser(id=user_id, name=name), + ruleset=state.ruleset_id, + maximum_statistics=state.maximum_statistics, + ) + ), + ) + self.state[user_id] = store + await self.broadcast_call("UserBeganPlaying", user_id, state.model_dump()) - async def SendFrameData( - self, client: Client, frame_data: FrameDataBundle - ) -> None: ... + async def SendFrameData(self, client: Client, frame_data: FrameDataBundle) -> None: + user_id = int(client.connection_id) + state = self.state.get(user_id) + if not state: + return + score = state.score + if not score: + return + score.score_info.acc = frame_data.header.acc + score.score_info.combo = frame_data.header.combo + score.score_info.max_combo = frame_data.header.max_combo + score.score_info.statistics = frame_data.header.statistics + score.score_info.total_score = frame_data.header.total_score + score.score_info.mods = frame_data.header.mods + score.replay_frames.extend(frame_data.frames) + await self.broadcast_call( + "UserSentFrames", + user_id, + frame_data.model_dump(), + ) + + async def EndPlaySession(self, client: Client, state: SpectatorState) -> None: + print(f"EndPlaySession -> {client.connection_id} {state.model_dump()!r}") + user_id = int(client.connection_id) + store = self.state.get(user_id) + if not store: + return + score = store.score + if not score or not store.score_token: + return + + async def _save_replay(): + async with AsyncSession(engine) as session: + async with session: + start_time = time.time() + score_record = None + while time.time() - start_time < READ_SCORE_TIMEOUT: + sub_query = select(ScoreToken.score_id).where( + ScoreToken.id == store.score_token, + ) + result = await session.exec( + select(Score) + .options(joinedload(Score.beatmap)) # pyright: ignore[reportArgumentType] + .where( + Score.id == sub_query, + Score.user_id == user_id, + ) + ) + score_record = result.first() + if score_record: + break + if not score_record: + return + if not score_record.passed: + return + score_record.has_replay = True + await session.commit() + await session.refresh(score_record) + save_replay( + ruleset_id=MODE_TO_INT[store.gamemode], + md5=store.checksum, + username=store.score.score_info.user.name, + score=score_record, + statistics=score.score_info.statistics, + maximum_statistics=score.score_info.maximum_statistics, + frames=score.replay_frames, + ) + + if ( + ( + BeatmapRankStatus.PENDING + < store.beatmap_status + <= BeatmapRankStatus.LOVED + ) + and any( + k.is_hit() and v > 0 for k, v in score.score_info.statistics.items() + ) + and state.state != SpectatedUserState.Failed + ): + # save replay + await _save_replay() + + del self.state[user_id] + if state.state == SpectatedUserState.Playing: + state.state = SpectatedUserState.Quit + await self.broadcast_call( + "UserEndedPlaying", + user_id, + state.model_dump(), + ) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index 1778659..bb97afd 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -27,7 +27,11 @@ class ResultKind(IntEnum): def parse_packet(data: bytes) -> tuple[PacketType, list[Any]]: length, offset = decode_varint(data) message_data = data[offset : offset + length] - unpacked = msgpack.unpackb(message_data, raw=False) + # FIXME: custom deserializer for APIMod + # https://github.com/ppy/osu/blob/master/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs + unpacked = msgpack.unpackb( + message_data, raw=False, strict_map_key=False, use_list=True + ) return PacketType(unpacked[0]), unpacked[1:] diff --git a/app/signalr/utils.py b/app/signalr/utils.py index 02d08c2..1bf84be 100644 --- a/app/signalr/utils.py +++ b/app/signalr/utils.py @@ -2,24 +2,20 @@ from __future__ import annotations from collections.abc import Callable import inspect +import sys from typing import Any, ForwardRef, cast +# https://github.com/pydantic/pydantic/blob/main/pydantic/v1/typing.py#L61-L75 +if sys.version_info < (3, 12, 4): -# https://github.com/pydantic/pydantic/blob/main/pydantic/v1/typing.py#L56-L66 -def evaluate_forwardref( - type_: ForwardRef, - globalns: Any, - localns: Any, -) -> Any: - # Even though it is the right signature for python 3.9, - # mypy complains with - # `error: Too many arguments for "_evaluate" of - # "ForwardRef"` hence the cast... - return cast(Any, type_)._evaluate( - globalns, - localns, - set(), - ) + def evaluate_forwardref(type_: ForwardRef, globalns: Any, localns: Any) -> Any: + return cast(Any, type_)._evaluate(globalns, localns, recursive_guard=set()) +else: + + def evaluate_forwardref(type_: ForwardRef, globalns: Any, localns: Any) -> Any: + return cast(Any, type_)._evaluate( + globalns, localns, type_params=(), recursive_guard=set() + ) def get_annotation(param: inspect.Parameter, globalns: dict[str, Any]) -> Any: diff --git a/app/utils.py b/app/utils.py index fe0c3fc..fa269fb 100644 --- a/app/utils.py +++ b/app/utils.py @@ -30,7 +30,7 @@ import rosu_pp_py as rosu def unix_timestamp_to_windows(timestamp: int) -> int: """Convert a Unix timestamp to a Windows timestamp.""" - return timestamp * 10_000 + 11_644_473_600_000_000 + return (timestamp + 62135596800) * 10_000_000 async def convert_db_user_to_api_user(db_user: DBUser, ruleset: str = "osu") -> User: From 9e441214277db8a670d94fa8a2a71c5d944b31f4 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 09:04:27 +0000 Subject: [PATCH 08/16] fix(api): avoid relationship api handling all requests --- app/router/relationship.py | 44 ++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/app/router/relationship.py b/app/router/relationship.py index 6f28ec2..eb8b961 100644 --- a/app/router/relationship.py +++ b/app/router/relationship.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Literal - from app.database import User as DBUser from app.database.relationship import Relationship, RelationshipResp, RelationshipType from app.dependencies.database import get_db @@ -9,21 +7,23 @@ from app.dependencies.user import get_current_user from .api_router import router -from fastapi import Depends, HTTPException, Query +from fastapi import Depends, HTTPException, Query, Request from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession -@router.get("/{type}", tags=["relationship"], response_model=list[RelationshipResp]) +@router.get("/friends", tags=["relationship"], response_model=list[RelationshipResp]) +@router.get("/blocks", tags=["relationship"], response_model=list[RelationshipResp]) async def get_relationship( - type: Literal["friends", "blocks"], + request: Request, current_user: DBUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - if type == "friends": - relationship_type = RelationshipType.FOLLOW - else: - relationship_type = RelationshipType.BLOCK + relationship_type = ( + RelationshipType.FOLLOW + if request.url.path.endswith("/friends") + else RelationshipType.BLOCK + ) relationships = await db.exec( select(Relationship).where( Relationship.user_id == current_user.id, @@ -33,17 +33,19 @@ async def get_relationship( return [await RelationshipResp.from_db(db, rel) for rel in relationships] -@router.post("/{type}", tags=["relationship"], response_model=RelationshipResp) +@router.post("/friends", tags=["relationship"], response_model=RelationshipResp) +@router.post("/blocks", tags=["relationship"]) async def add_relationship( - type: Literal["friends", "blocks"], + request: Request, target: int = Query(), current_user: DBUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - if type == "blocks": - relationship_type = RelationshipType.BLOCK - else: - relationship_type = RelationshipType.FOLLOW + relationship_type = ( + RelationshipType.FOLLOW + if request.url.path.endswith("/friends") + else RelationshipType.BLOCK + ) if target == current_user.id: raise HTTPException(422, "Cannot add relationship to yourself") relationship = ( @@ -78,18 +80,22 @@ async def add_relationship( await db.delete(target_relationship) await db.commit() await db.refresh(relationship) - return await RelationshipResp.from_db(db, relationship) + if relationship.type == RelationshipType.FOLLOW: + return await RelationshipResp.from_db(db, relationship) -@router.delete("/{type}/{target}", tags=["relationship"]) +@router.delete("/friends/{target}", tags=["relationship"]) +@router.delete("/blocks/{target}", tags=["relationship"]) async def delete_relationship( - type: Literal["friends", "blocks"], + request: Request, target: int, current_user: DBUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): relationship_type = ( - RelationshipType.BLOCK if type == "blocks" else RelationshipType.FOLLOW + RelationshipType.BLOCK + if "/blocks/" in request.url.path + else RelationshipType.FOLLOW ) relationship = ( await db.exec( From 589927a3004b2bfb7d7ef78790f9c8affadbbb26 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 11:45:55 +0000 Subject: [PATCH 09/16] feat(signalr): support json protocol --- app/models/signalr.py | 2 +- app/signalr/hub/hub.py | 155 +++++++++++++-------------- app/signalr/packet.py | 233 +++++++++++++++++++++++++++++++++++------ app/signalr/router.py | 18 ++-- app/signalr/store.py | 14 +-- 5 files changed, 286 insertions(+), 136 deletions(-) diff --git a/app/models/signalr.py b/app/models/signalr.py index fb4f55f..49db11f 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -20,7 +20,7 @@ class MessagePackArrayModel(BaseModel): class Transport(BaseModel): transport: str transfer_formats: list[str] = Field( - default_factory=lambda: ["Binary"], alias="transferFormats" + default_factory=lambda: ["Binary", "Text"], alias="transferFormats" ) diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index 3175b8d..42c6aef 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -8,41 +8,50 @@ from typing import Any from app.config import settings from app.signalr.exception import InvokeException from app.signalr.packet import ( - PacketType, - ResultKind, - encode_varint, - parse_packet, + CompletionPacket, + InvocationPacket, + Packet, + PingPacket, + Protocol, ) from app.signalr.store import ResultStore from app.signalr.utils import get_signature from fastapi import WebSocket -import msgpack from pydantic import BaseModel from starlette.websockets import WebSocketDisconnect class Client: def __init__( - self, connection_id: str, connection_token: str, connection: WebSocket + self, + connection_id: str, + connection_token: str, + connection: WebSocket, + protocol: Protocol, ) -> None: self.connection_id = connection_id self.connection_token = connection_token self.connection = connection + self.procotol = protocol self._listen_task: asyncio.Task | None = None self._ping_task: asyncio.Task | None = None self._store = ResultStore() - async def send_packet(self, type: PacketType, packet: list[Any]): - packet.insert(0, type.value) - payload = msgpack.packb(packet) - length = encode_varint(len(payload)) - await self.connection.send_bytes(length + payload) + async def send_packet(self, packet: Packet): + await self.connection.send_bytes(self.procotol.encode(packet)) + + async def receive_packet(self) -> Packet: + message = await self.connection.receive() + d = message.get("bytes") or message.get("text", "").encode() + if not d: + raise WebSocketDisconnect(code=1008, reason="Empty message received.") + return self.procotol.decode(d) async def _ping(self): while True: try: - await self.send_packet(PacketType.PING, []) + await self.send_packet(PingPacket()) await asyncio.sleep(settings.SIGNALR_PING_INTERVAL) except WebSocketDisconnect: break @@ -61,7 +70,11 @@ class Hub: self.waited_clients[connection_token] = timestamp def add_client( - self, connection_id: str, connection_token: str, connection: WebSocket + self, + connection_id: str, + connection_token: str, + protocol: Protocol, + connection: WebSocket, ) -> Client: if connection_token in self.clients: raise ValueError( @@ -74,7 +87,7 @@ class Hub: ): raise TimeoutError(f"Connection {connection_id} has waited too long.") del self.waited_clients[connection_token] - client = Client(connection_id, connection_token, connection) + client = Client(connection_id, connection_token, connection, protocol) self.clients[connection_token] = client task = asyncio.create_task(client._ping()) self.tasks.add(task) @@ -90,8 +103,8 @@ class Hub: client._ping_task.cancel() await client.connection.close() - async def send_packet(self, client: Client, type: PacketType, packet: list[Any]): - await client.send_packet(type, packet) + async def send_packet(self, client: Client, packet: Packet) -> None: + await client.send_packet(packet) async def broadcast_call(self, method: str, *args: Any) -> None: tasks = [] @@ -103,11 +116,8 @@ class Hub: jump = False while not jump: try: - message = await client.connection.receive_bytes() - packet_type, packet_data = parse_packet(message) - task = asyncio.create_task( - self._handle_packet(client, packet_type, packet_data) - ) + packet = await client.receive_packet() + task = asyncio.create_task(self._handle_packet(client, packet)) self.tasks.add(task) task.add_done_callback(self.tasks.discard) except WebSocketDisconnect as e: @@ -121,51 +131,30 @@ class Hub: jump = True await self.remove_client(client.connection_id) - async def _handle_packet( - self, client: Client, type: PacketType, packet: list[Any] - ) -> None: - match type: - case PacketType.PING: - ... - case PacketType.INVOCATION: - invocation_id: str | None = packet[1] # pyright: ignore[reportRedeclaration] - target: str = packet[2] - args: list[Any] | None = packet[3] - if args is None: - args = [] - # streams: list[str] | None = packet[4] # TODO: stream support - code = ResultKind.VOID - result = None - try: - result = await self.invoke_method(client, target, args) - if result is not None: - code = ResultKind.HAS_VALUE - except InvokeException as e: - code = ResultKind.ERROR - result = e.message - - except Exception as e: - traceback.print_exc() - code = ResultKind.ERROR - result = str(e) - - packet = [ - {}, # header - invocation_id, - code.value, - ] - if result is not None: - packet.append(result) - if invocation_id is not None: - await client.send_packet( - PacketType.COMPLETION, - packet, + async def _handle_packet(self, client: Client, packet: Packet) -> None: + if isinstance(packet, PingPacket): + return + elif isinstance(packet, InvocationPacket): + args = packet.arguments or [] + error = None + result = None + try: + result = await self.invoke_method(client, packet.target, args) + except InvokeException as e: + error = e.message + except Exception as e: + traceback.print_exc() + error = str(e) + if packet.invocation_id is not None: + await client.send_packet( + CompletionPacket( + invocation_id=packet.invocation_id, + error=error, + result=result, ) - case PacketType.COMPLETION: - invocation_id: str = packet[1] - code: ResultKind = ResultKind(packet[2]) - result: Any = packet[3] if len(packet) > 3 else None - client._store.add_result(invocation_id, code, result) + ) + elif isinstance(packet, CompletionPacket): + client._store.add_result(packet.invocation_id, packet.result, packet.error) async def invoke_method(self, client: Client, method: str, args: list[Any]) -> Any: method_ = getattr(self, method, None) @@ -185,32 +174,28 @@ class Hub: async def call(self, client: Client, method: str, *args: Any) -> Any: invocation_id = client._store.get_invocation_id() await client.send_packet( - PacketType.INVOCATION, - [ - {}, # header - invocation_id, - method, - list(args), - None, # streams - ], + InvocationPacket( + header={}, + invocation_id=invocation_id, + target=method, + arguments=list(args), + stream_ids=None, + ) ) r = await client._store.fetch(invocation_id, None) - if r[0] == ResultKind.HAS_VALUE: - return r[1] - if r[0] == ResultKind.ERROR: + if r[1]: raise InvokeException(r[1]) - return None + return r[0] async def call_noblock(self, client: Client, method: str, *args: Any) -> None: await client.send_packet( - PacketType.INVOCATION, - [ - {}, # header - None, # invocation_id - method, - list(args), - None, # streams - ], + InvocationPacket( + header={}, + invocation_id=None, + target=method, + arguments=list(args), + stream_ids=None, + ) ) return None diff --git a/app/signalr/packet.py b/app/signalr/packet.py index bb97afd..d3da080 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -1,7 +1,12 @@ from __future__ import annotations +from dataclasses import dataclass from enum import IntEnum -from typing import Any +import json +from typing import ( + Any, + Protocol as TypingProtocol, +) import msgpack @@ -18,43 +23,205 @@ class PacketType(IntEnum): CLOSE = 7 -class ResultKind(IntEnum): - ERROR = 1 - VOID = 2 - HAS_VALUE = 3 +@dataclass(kw_only=True) +class Packet: + type: PacketType + header: dict[str, Any] | None = None -def parse_packet(data: bytes) -> tuple[PacketType, list[Any]]: - length, offset = decode_varint(data) - message_data = data[offset : offset + length] - # FIXME: custom deserializer for APIMod - # https://github.com/ppy/osu/blob/master/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs - unpacked = msgpack.unpackb( - message_data, raw=False, strict_map_key=False, use_list=True - ) - return PacketType(unpacked[0]), unpacked[1:] +@dataclass(kw_only=True) +class InvocationPacket(Packet): + type: PacketType = PacketType.INVOCATION + invocation_id: str | None + target: str + arguments: list[Any] | None = None + stream_ids: list[str] | None = None -def encode_varint(value: int) -> bytes: - result = [] - while value >= 0x80: - result.append((value & 0x7F) | 0x80) - value >>= 7 - result.append(value & 0x7F) - return bytes(result) +@dataclass(kw_only=True) +class CompletionPacket(Packet): + type: PacketType = PacketType.COMPLETION + invocation_id: str + result: Any + error: str | None = None -def decode_varint(data: bytes, offset: int = 0) -> tuple[int, int]: - result = 0 - shift = 0 - pos = offset +@dataclass(kw_only=True) +class PingPacket(Packet): + type: PacketType = PacketType.PING - while pos < len(data): - byte = data[pos] - result |= (byte & 0x7F) << shift - pos += 1 - if (byte & 0x80) == 0: - break - shift += 7 - return result, pos +PACKETS = { + PacketType.INVOCATION: InvocationPacket, + PacketType.COMPLETION: CompletionPacket, + PacketType.PING: PingPacket, +} + + +class Protocol(TypingProtocol): + @staticmethod + def decode(input: bytes) -> Packet: ... + + @staticmethod + def encode(packet: Packet) -> bytes: ... + + +class MsgpackProtocol: + @staticmethod + def _encode_varint(value: int) -> bytes: + result = [] + while value >= 0x80: + result.append((value & 0x7F) | 0x80) + value >>= 7 + result.append(value & 0x7F) + return bytes(result) + + @staticmethod + def _decode_varint(data: bytes, offset: int = 0) -> tuple[int, int]: + result = 0 + shift = 0 + pos = offset + + while pos < len(data): + byte = data[pos] + result |= (byte & 0x7F) << shift + pos += 1 + if (byte & 0x80) == 0: + break + shift += 7 + + return result, pos + + @staticmethod + def decode(input: bytes) -> Packet: + length, offset = MsgpackProtocol._decode_varint(input) + message_data = input[offset : offset + length] + # FIXME: custom deserializer for APIMod + # https://github.com/ppy/osu/blob/master/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs + unpacked = msgpack.unpackb( + message_data, raw=False, strict_map_key=False, use_list=True + ) + packet_type = PacketType(unpacked[0]) + if packet_type not in PACKETS: + raise ValueError(f"Unknown packet type: {packet_type}") + match packet_type: + case PacketType.INVOCATION: + return InvocationPacket( + header=unpacked[1], + invocation_id=unpacked[2], + target=unpacked[3], + arguments=unpacked[4] if len(unpacked) > 4 else None, + stream_ids=unpacked[5] if len(unpacked) > 5 else None, + ) + case PacketType.COMPLETION: + result_kind = unpacked[3] + return CompletionPacket( + header=unpacked[1], + invocation_id=unpacked[2], + error=unpacked[4] if result_kind == 1 else None, + result=unpacked[5] if result_kind == 3 else None, + ) + case PacketType.PING: + return PingPacket() + raise ValueError(f"Unsupported packet type: {packet_type}") + + @staticmethod + def encode(packet: Packet) -> bytes: + payload = [packet.type.value, packet.header or {}] + if isinstance(packet, InvocationPacket): + payload.extend( + [ + packet.invocation_id, + packet.target, + ] + ) + if packet.arguments is not None: + payload.append(packet.arguments) + if packet.stream_ids is not None: + payload.append(packet.stream_ids) + elif isinstance(packet, CompletionPacket): + result_kind = 2 + if packet.error: + result_kind = 1 + elif packet.result is None: + result_kind = 3 + payload.extend( + [ + packet.invocation_id, + result_kind, + packet.error or packet.result or None, + ] + ) + elif isinstance(packet, PingPacket): + pass + + data = msgpack.packb(payload, use_bin_type=True) + return MsgpackProtocol._encode_varint(len(data)) + data + + +class JSONProtocol: + @staticmethod + def decode(input: bytes) -> Packet: + data = json.loads(input[:-1].decode("utf-8")) + packet_type = PacketType(data["type"]) + if packet_type not in PACKETS: + raise ValueError(f"Unknown packet type: {packet_type}") + match packet_type: + case PacketType.INVOCATION: + return InvocationPacket( + header=data.get("header"), + invocation_id=data.get("invocationId"), + target=data["target"], + arguments=data.get("arguments"), + stream_ids=data.get("streamIds"), + ) + case PacketType.COMPLETION: + return CompletionPacket( + header=data.get("header"), + invocation_id=data["invocationId"], + error=data.get("error"), + result=data.get("result"), + ) + case PacketType.PING: + return PingPacket() + raise ValueError(f"Unsupported packet type: {packet_type}") + + @staticmethod + def encode(packet: Packet) -> bytes: + payload: dict[str, Any] = { + "type": packet.type.value, + } + if packet.header: + payload["header"] = packet.header + if isinstance(packet, InvocationPacket): + payload.update( + { + "target": packet.target, + } + ) + if packet.invocation_id is not None: + payload["invocationId"] = packet.invocation_id + if packet.arguments is not None: + payload["arguments"] = packet.arguments + if packet.stream_ids is not None: + payload["streamIds"] = packet.stream_ids + elif isinstance(packet, CompletionPacket): + payload.update( + { + "invocationId": packet.invocation_id, + } + ) + if packet.error is not None: + payload["error"] = packet.error + if packet.result is not None: + payload["result"] = packet.result + elif isinstance(packet, PingPacket): + pass + + return json.dumps(payload).encode("utf-8") + + +PROTOCOLS: dict[str, Protocol] = { + "json": JSONProtocol, + "messagepack": MsgpackProtocol, +} diff --git a/app/signalr/router.py b/app/signalr/router.py index 49934b7..3d70931 100644 --- a/app/signalr/router.py +++ b/app/signalr/router.py @@ -12,7 +12,7 @@ from app.dependencies.user import get_current_user_by_token from app.models.signalr import NegotiateResponse, Transport from .hub import Hubs -from .packet import SEP +from .packet import PROTOCOLS, SEP from fastapi import APIRouter, Depends, Header, Query, WebSocket from sqlmodel.ext.asyncio.session import AsyncSession @@ -62,13 +62,14 @@ async def connect( await websocket.accept() # handshake - handshake = await websocket.receive_bytes() - handshake_payload = json.loads(handshake[:-1]) + handshake = await websocket.receive() + message = handshake.get("bytes") or handshake.get("text") + if not message: + await websocket.close(code=1008) + return + handshake_payload = json.loads(message[:-1]) error = "" - if (protocol := handshake_payload.get("protocol")) != "messagepack" or ( - handshake_payload.get("version") - ) != 1: - error = f"Requested protocol '{protocol}' is not available." + protocol = handshake_payload.get("protocol", "json") client = None try: @@ -76,7 +77,10 @@ async def connect( connection_id=user_id, connection_token=id, connection=websocket, + protocol=PROTOCOLS[protocol], ) + except KeyError: + error = f"Protocol '{protocol}' is not supported." except TimeoutError: error = f"Connection {id} has waited too long." except ValueError as e: diff --git a/app/signalr/store.py b/app/signalr/store.py index 5258293..008da03 100644 --- a/app/signalr/store.py +++ b/app/signalr/store.py @@ -2,9 +2,7 @@ from __future__ import annotations import asyncio import sys -from typing import Any, Literal - -from .packet import ResultKind +from typing import Any class ResultStore: @@ -22,21 +20,17 @@ class ResultStore: return str(s) def add_result( - self, invocation_id: str, type: ResultKind, result: dict[str, Any] | None + self, invocation_id: str, result: Any, error: str | None = None ) -> None: if isinstance(invocation_id, str) and invocation_id.isdecimal(): if future := self._futures.get(invocation_id): - future.set_result((type, result)) + future.set_result((result, error)) async def fetch( self, invocation_id: str, timeout: float | None, # noqa: ASYNC109 - ) -> ( - tuple[Literal[ResultKind.ERROR], str] - | tuple[Literal[ResultKind.VOID], None] - | tuple[Literal[ResultKind.HAS_VALUE], Any] - ): + ) -> tuple[Any, str | None]: future = asyncio.get_event_loop().create_future() self._futures[invocation_id] = future try: From e34e35caf1c3fe09ab68be8775585879d18e79d6 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 11:47:09 +0000 Subject: [PATCH 10/16] fix(spectator): wrong converted gamemode written into replay --- app/models/spectator_hub.py | 3 +-- app/signalr/hub/spectator.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/models/spectator_hub.py b/app/models/spectator_hub.py index 053f0f6..fe95930 100644 --- a/app/models/spectator_hub.py +++ b/app/models/spectator_hub.py @@ -7,7 +7,6 @@ from typing import Any from app.models.beatmap import BeatmapRankStatus from .score import ( - GameMode, ScoreStatisticsInt, ) from .signalr import MessagePackArrayModel @@ -139,7 +138,7 @@ class StoreClientState(BaseModel): state: SpectatorState | None beatmap_status: BeatmapRankStatus checksum: str - gamemode: GameMode + ruleset_id: int score_token: int watched_user: set[int] score: StoreScore diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index 5d38d95..b3e6f10 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -12,7 +12,7 @@ from app.database.user import User from app.dependencies.database import engine from app.models.beatmap import BeatmapRankStatus from app.models.mods import mods_to_int -from app.models.score import MODE_TO_INT, LegacyReplaySoloScoreInfo, ScoreStatisticsInt +from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatisticsInt from app.models.spectator_hub import ( APIUser, FrameDataBundle, @@ -171,7 +171,7 @@ class SpectatorHub(Hub): state=state, beatmap_status=beatmap.beatmap_status, checksum=beatmap.checksum, - gamemode=beatmap.mode, + ruleset_id=state.ruleset_id, score_token=score_token, watched_user=set(), score=StoreScore( @@ -245,7 +245,7 @@ class SpectatorHub(Hub): await session.commit() await session.refresh(score_record) save_replay( - ruleset_id=MODE_TO_INT[store.gamemode], + ruleset_id=store.ruleset_id, md5=store.checksum, username=store.score.score_info.user.name, score=score_record, From ed498a292a2abb3dd4039182bb85875656e1c7d3 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 13:09:36 +0000 Subject: [PATCH 11/16] feat(signalr): support broadcast to group --- app/signalr/hub/hub.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index 42c6aef..02c5537 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -38,6 +38,9 @@ class Client: self._ping_task: asyncio.Task | None = None self._store = ResultStore() + def __hash__(self) -> int: + return hash(self.connection_id + self.connection_token) + async def send_packet(self, packet: Packet): await self.connection.send_bytes(self.procotol.encode(packet)) @@ -65,10 +68,17 @@ class Hub: self.clients: dict[str, Client] = {} self.waited_clients: dict[str, int] = {} self.tasks: set[asyncio.Task] = set() + self.groups: dict[str, set[Client]] = {} def add_waited_client(self, connection_token: str, timestamp: int) -> None: self.waited_clients[connection_token] = timestamp + def get_client_by_id(self, id: str, default: Any = None) -> Client: + for client in self.clients.values(): + if client.connection_id == id: + return client + return default + def add_client( self, connection_id: str, @@ -112,6 +122,14 @@ class Hub: tasks.append(self.call_noblock(client, method, *args)) await asyncio.gather(*tasks) + async def broadcast_group_call( + self, group_id: str, method: str, *args: Any + ) -> None: + tasks = [] + for client in self.groups.get(group_id, []): + tasks.append(self.call_noblock(client, method, *args)) + await asyncio.gather(*tasks) + async def _listen_client(self, client: Client) -> None: jump = False while not jump: From 4b02225c83f4d4b381ea99d98af6582c4d709cbd Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 15:49:52 +0000 Subject: [PATCH 12/16] fix(signalr): fail to send message to client when using json protocol --- app/signalr/packet.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index d3da080..5fa0908 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -217,8 +217,7 @@ class JSONProtocol: payload["result"] = packet.result elif isinstance(packet, PingPacket): pass - - return json.dumps(payload).encode("utf-8") + return json.dumps(payload).encode("utf-8") + SEP PROTOCOLS: dict[str, Protocol] = { From fb610c489832dcde52d18346c6ddf7d569106642 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 16:23:47 +0000 Subject: [PATCH 13/16] feat(signalr): support on_client_connect hook --- app/signalr/hub/hub.py | 8 +++++++- app/signalr/router.py | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index 02c5537..40e75da 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -48,7 +48,7 @@ class Client: message = await self.connection.receive() d = message.get("bytes") or message.get("text", "").encode() if not d: - raise WebSocketDisconnect(code=1008, reason="Empty message received.") + return PingPacket() # FIXME: Graceful empty message handling return self.procotol.decode(d) async def _ping(self): @@ -104,6 +104,10 @@ class Hub: client._ping_task = task return client + async def on_connect(self, client: Client) -> None: + if method := getattr(self, "on_client_connect", None): + await method(client) + async def remove_client(self, connection_id: str) -> None: if client := self.clients.get(connection_id): del self.clients[connection_id] @@ -138,6 +142,8 @@ class Hub: task = asyncio.create_task(self._handle_packet(client, packet)) self.tasks.add(task) task.add_done_callback(self.tasks.discard) + except StopIteration: + pass except WebSocketDisconnect as e: print( f"Client {client.connection_id} disconnected: {e.code}, {e.reason}" diff --git a/app/signalr/router.py b/app/signalr/router.py index 3d70931..5c2f08f 100644 --- a/app/signalr/router.py +++ b/app/signalr/router.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import time from typing import Literal @@ -92,4 +93,7 @@ async def connect( if error or not client: await websocket.close(code=1008) return + task = asyncio.create_task(hub_.on_connect(client)) + hub_.tasks.add(task) + task.add_done_callback(hub_.tasks.discard) await hub_._listen_client(client) From 20d528d203c6df2c92b146f8176aafe79e999c31 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 16:25:08 +0000 Subject: [PATCH 14/16] feat(metadata): support metadata for user presence --- app/models/metadata_hub.py | 154 ++++++++++++++++++++++++++++++++++++ app/signalr/hub/metadata.py | 150 ++++++++++++++++++++++++++++++++++- 2 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 app/models/metadata_hub.py diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py new file mode 100644 index 0000000..24a29b5 --- /dev/null +++ b/app/models/metadata_hub.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from enum import IntEnum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class _UserActivity(BaseModel): + model_config = ConfigDict(serialize_by_alias=True) + type: Literal[ + "ChoosingBeatmap", + "InSoloGame", + "WatchingReplay", + "SpectatingUser", + "SearchingForLobby", + "InLobby", + "InMultiplayerGame", + "SpectatingMultiplayerGame", + "InPlaylistGame", + "EditingBeatmap", + "ModdingBeatmap", + "TestingBeatmap", + "InDailyChallengeLobby", + "PlayingDailyChallenge", + ] = Field(alias="$dtype") + value: Any | None = Field(alias="$value") + + +class ChoosingBeatmap(_UserActivity): + type: Literal["ChoosingBeatmap"] = "ChoosingBeatmap" + value: Literal[None] = None + + +class InGameValue(BaseModel): + beatmap_id: int = Field(alias="BeatmapID") + beatmap_display_title: str = Field(alias="BeatmapDisplayTitle") + ruleset_id: int = Field(alias="RulesetID") + ruleset_playing_verb: str = Field(alias="RulesetPlayingVerb") + + +class _InGame(_UserActivity): + value: InGameValue = Field(alias="$value") + + +class InSoloGame(_InGame): + type: Literal["InSoloGame"] = "InSoloGame" + + +class InMultiplayerGame(_InGame): + type: Literal["InMultiplayerGame"] = "InMultiplayerGame" + + +class SpectatingMultiplayerGame(_InGame): + type: Literal["SpectatingMultiplayerGame"] = "SpectatingMultiplayerGame" + + +class InPlaylistGame(_InGame): + type: Literal["InPlaylistGame"] = "InPlaylistGame" + + +class EditingBeatmapValue(BaseModel): + beatmap_id: int = Field(alias="BeatmapID") + beatmap_display_title: str = Field(alias="BeatmapDisplayTitle") + + +class EditingBeatmap(_UserActivity): + type: Literal["EditingBeatmap"] = "EditingBeatmap" + value: EditingBeatmapValue = Field(alias="$value") + + +class TestingBeatmap(_UserActivity): + type: Literal["TestingBeatmap"] = "TestingBeatmap" + + +class ModdingBeatmap(_UserActivity): + type: Literal["ModdingBeatmap"] = "ModdingBeatmap" + + +class WatchingReplayValue(BaseModel): + score_id: int = Field(alias="ScoreID") + player_name: str = Field(alias="PlayerName") + beatmap_id: int = Field(alias="BeatmapID") + beatmap_display_title: str = Field(alias="BeatmapDisplayTitle") + + +class WatchingReplay(_UserActivity): + type: Literal["WatchingReplay"] = "WatchingReplay" + value: int | None = Field(alias="$value") # Replay ID + + +class SpectatingUser(WatchingReplay): + type: Literal["SpectatingUser"] = "SpectatingUser" + + +class SearchingForLobby(_UserActivity): + type: Literal["SearchingForLobby"] = "SearchingForLobby" + value: None = Field(alias="$value") + + +class InLobbyValue(BaseModel): + room_id: int = Field(alias="RoomID") + room_name: str = Field(alias="RoomName") + + +class InLobby(_UserActivity): + type: Literal["InLobby"] = "InLobby" + value: None = Field(alias="$value") + + +class InDailyChallengeLobby(_UserActivity): + type: Literal["InDailyChallengeLobby"] = "InDailyChallengeLobby" + value: None = Field(alias="$value") + + +UserActivity = ( + ChoosingBeatmap + | InSoloGame + | WatchingReplay + | SpectatingUser + | SearchingForLobby + | InLobby + | InMultiplayerGame + | SpectatingMultiplayerGame + | InPlaylistGame + | EditingBeatmap + | ModdingBeatmap + | TestingBeatmap + | InDailyChallengeLobby +) + + +class MetadataClientState(BaseModel): + user_activity: UserActivity | None = None + status: OnlineStatus | None = None + + def to_dict(self) -> dict[str, Any] | None: + if self.status is None or self.status == OnlineStatus.OFFLINE: + return None + dumped = self.model_dump(by_alias=True, exclude_none=True) + return { + "Activity": dumped.get("user_activity"), + "Status": dumped.get("status"), + } + + @property + def pushable(self) -> bool: + return self.status is not None and self.status != OnlineStatus.OFFLINE + + +class OnlineStatus(IntEnum): + OFFLINE = 0 # 隐身 + DO_NOT_DISTURB = 1 + ONLINE = 2 diff --git a/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py index 325f77f..cfed09c 100644 --- a/app/signalr/hub/metadata.py +++ b/app/signalr/hub/metadata.py @@ -1,6 +1,152 @@ from __future__ import annotations -from .hub import Hub +import asyncio +from collections.abc import Coroutine + +from app.database.relationship import Relationship, RelationshipType +from app.dependencies.database import engine +from app.models.metadata_hub import MetadataClientState, OnlineStatus, UserActivity + +from .hub import Client, Hub + +from pydantic import TypeAdapter +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers" -class MetadataHub(Hub): ... +class MetadataHub(Hub): + def __init__(self) -> None: + super().__init__() + self.state: dict[int, MetadataClientState] = {} + + @staticmethod + def online_presence_watchers_group() -> str: + return ONLINE_PRESENCE_WATCHERS_GROUP + + def broadcast_tasks( + self, user_id: int, store: MetadataClientState + ) -> set[Coroutine]: + if not store.pushable: + return set() + return { + self.broadcast_group_call( + self.online_presence_watchers_group(), + "UserPresenceUpdated", + user_id, + store.to_dict(), + ), + self.broadcast_group_call( + self.friend_presence_watchers_group(user_id), + "FriendPresenceUpdated", + user_id, + store.to_dict(), + ), + } + + @staticmethod + def friend_presence_watchers_group(user_id: int): + return f"metadata:friend-presence-watchers:{user_id}" + + async def on_client_connect(self, client: Client) -> None: + user_id = int(client.connection_id) + if store := self.state.get(user_id): + store = MetadataClientState() + self.state[user_id] = store + + async with AsyncSession(engine) as session: + async with session.begin(): + friends = ( + await session.exec( + select(Relationship.target_id).where( + Relationship.user_id == user_id, + Relationship.type == RelationshipType.FOLLOW, + ) + ) + ).all() + tasks = [] + for friend_id in friends: + self.groups.setdefault( + self.friend_presence_watchers_group(friend_id), set() + ).add(client) + if ( + friend_state := self.state.get(friend_id) + ) and friend_state.pushable: + tasks.append( + self.broadcast_group_call( + self.friend_presence_watchers_group(friend_id), + "FriendPresenceUpdated", + friend_id, + friend_state.to_dict(), + ) + ) + await asyncio.gather(*tasks) + + async def UpdateStatus(self, client: Client, status: int) -> None: + status_ = OnlineStatus(status) + user_id = int(client.connection_id) + store = self.state.get(user_id) + if store: + if store.status is not None and store.status == status_: + return + store.status = OnlineStatus(status_) + else: + store = MetadataClientState(status=OnlineStatus(status_)) + self.state[user_id] = store + tasks = self.broadcast_tasks(user_id, store) + tasks.add( + self.call_noblock( + client, + "UserPresenceUpdated", + user_id, + store.to_dict(), + ) + ) + await asyncio.gather(*tasks) + + async def UpdateActivity(self, client: Client, activity_dict: dict | None) -> None: + if activity_dict is None: + # idle + return + user_id = int(client.connection_id) + activity = TypeAdapter(UserActivity).validate_python(activity_dict) + store = self.state.get(user_id) + if store: + store.user_activity = activity + else: + store = MetadataClientState( + user_activity=activity, + ) + self.state[user_id] = store + + tasks = self.broadcast_tasks(user_id, store) + tasks.add( + self.call_noblock( + client, + "UserPresenceUpdated", + user_id, + store.to_dict(), + ) + ) + await asyncio.gather(*tasks) + + async def BeginWatchingUserPresence(self, client: Client) -> None: + await asyncio.gather( + *[ + self.call_noblock( + client, + "UserPresenceUpdated", + user_id, + store.to_dict(), + ) + for user_id, store in self.state.items() + if store.pushable + ] + ) + self.groups.setdefault(self.online_presence_watchers_group(), set()).add(client) + + async def EndWatchingUserPresence(self, client: Client) -> None: + self.groups.setdefault(self.online_presence_watchers_group(), set()).discard( + client + ) From 722a6e57d809e3cbef389c13ad15d40d92c612ab Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 28 Jul 2025 05:52:48 +0000 Subject: [PATCH 15/16] feat(spectator): support spectate solo player --- app/models/metadata_hub.py | 28 +++++----- app/models/signalr.py | 39 +++++++++++++- app/models/spectator_hub.py | 38 ++++++------- app/signalr/hub/hub.py | 17 +++--- app/signalr/hub/metadata.py | 10 ++-- app/signalr/hub/spectator.py | 84 ++++++++++++++++++++++++++--- app/signalr/packet.py | 100 ++++++++++++++++++++--------------- 7 files changed, 213 insertions(+), 103 deletions(-) diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index 24a29b5..615ea9b 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -28,8 +28,7 @@ class _UserActivity(BaseModel): class ChoosingBeatmap(_UserActivity): - type: Literal["ChoosingBeatmap"] = "ChoosingBeatmap" - value: Literal[None] = None + type: Literal["ChoosingBeatmap"] = Field(alias="$dtype") class InGameValue(BaseModel): @@ -44,19 +43,19 @@ class _InGame(_UserActivity): class InSoloGame(_InGame): - type: Literal["InSoloGame"] = "InSoloGame" + type: Literal["InSoloGame"] = Field(alias="$dtype") class InMultiplayerGame(_InGame): - type: Literal["InMultiplayerGame"] = "InMultiplayerGame" + type: Literal["InMultiplayerGame"] = Field(alias="$dtype") class SpectatingMultiplayerGame(_InGame): - type: Literal["SpectatingMultiplayerGame"] = "SpectatingMultiplayerGame" + type: Literal["SpectatingMultiplayerGame"] = Field(alias="$dtype") class InPlaylistGame(_InGame): - type: Literal["InPlaylistGame"] = "InPlaylistGame" + type: Literal["InPlaylistGame"] = Field(alias="$dtype") class EditingBeatmapValue(BaseModel): @@ -65,16 +64,16 @@ class EditingBeatmapValue(BaseModel): class EditingBeatmap(_UserActivity): - type: Literal["EditingBeatmap"] = "EditingBeatmap" + type: Literal["EditingBeatmap"] = Field(alias="$dtype") value: EditingBeatmapValue = Field(alias="$value") class TestingBeatmap(_UserActivity): - type: Literal["TestingBeatmap"] = "TestingBeatmap" + type: Literal["TestingBeatmap"] = Field(alias="$dtype") class ModdingBeatmap(_UserActivity): - type: Literal["ModdingBeatmap"] = "ModdingBeatmap" + type: Literal["ModdingBeatmap"] = Field(alias="$dtype") class WatchingReplayValue(BaseModel): @@ -85,17 +84,16 @@ class WatchingReplayValue(BaseModel): class WatchingReplay(_UserActivity): - type: Literal["WatchingReplay"] = "WatchingReplay" + type: Literal["WatchingReplay"] = Field(alias="$dtype") value: int | None = Field(alias="$value") # Replay ID class SpectatingUser(WatchingReplay): - type: Literal["SpectatingUser"] = "SpectatingUser" + type: Literal["SpectatingUser"] = Field(alias="$dtype") class SearchingForLobby(_UserActivity): - type: Literal["SearchingForLobby"] = "SearchingForLobby" - value: None = Field(alias="$value") + type: Literal["SearchingForLobby"] = Field(alias="$dtype") class InLobbyValue(BaseModel): @@ -105,12 +103,10 @@ class InLobbyValue(BaseModel): class InLobby(_UserActivity): type: Literal["InLobby"] = "InLobby" - value: None = Field(alias="$value") class InDailyChallengeLobby(_UserActivity): - type: Literal["InDailyChallengeLobby"] = "InDailyChallengeLobby" - value: None = Field(alias="$value") + type: Literal["InDailyChallengeLobby"] = Field(alias="$dtype") UserActivity = ( diff --git a/app/models/signalr.py b/app/models/signalr.py index 49db11f..ac8475f 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -1,11 +1,42 @@ from __future__ import annotations -from typing import Any +import datetime +from typing import Any, get_origin -from pydantic import BaseModel, Field, model_validator +import msgpack +from pydantic import ( + BaseModel, + ConfigDict, + Field, + TypeAdapter, + model_serializer, + model_validator, +) + + +def serialize_to_list(value: BaseModel) -> list[Any]: + data = [] + for field, info in value.__class__.model_fields.items(): + v = getattr(value, field) + anno = get_origin(info.annotation) + if anno and issubclass(anno, BaseModel): + data.append(serialize_to_list(v)) + elif anno and issubclass(anno, list): + data.append( + TypeAdapter( + info.annotation, + ).dump_python(v) + ) + elif isinstance(v, datetime.datetime): + data.append([msgpack.ext.Timestamp.from_datetime(v), 0]) + else: + data.append(v) + return data class MessagePackArrayModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + @model_validator(mode="before") @classmethod def unpack(cls, v: Any) -> Any: @@ -16,6 +47,10 @@ class MessagePackArrayModel(BaseModel): return dict(zip(fields, v)) return v + @model_serializer + def serialize(self) -> list[Any]: + return serialize_to_list(self) + class Transport(BaseModel): transport: str diff --git a/app/models/spectator_hub.py b/app/models/spectator_hub.py index fe95930..820eb16 100644 --- a/app/models/spectator_hub.py +++ b/app/models/spectator_hub.py @@ -12,7 +12,7 @@ from .score import ( from .signalr import MessagePackArrayModel import msgpack -from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator +from pydantic import BaseModel, Field, field_validator class APIMod(MessagePackArrayModel): @@ -58,8 +58,6 @@ class ScoreProcessorStatistics(MessagePackArrayModel): class FrameHeader(MessagePackArrayModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - total_score: int acc: float combo: int @@ -84,25 +82,21 @@ class FrameHeader(MessagePackArrayModel): return datetime.datetime.fromisoformat(v) raise ValueError(f"Cannot convert {type(v)} to datetime") - @field_serializer("received_time") - def serialize_received_time(self, v: datetime.datetime) -> msgpack.ext.Timestamp: - return msgpack.ext.Timestamp.from_datetime(v) - -class ReplayButtonState(IntEnum): - NONE = 0 - LEFT1 = 1 - RIGHT1 = 2 - LEFT2 = 4 - RIGHT2 = 8 - SMOKE = 16 +# class ReplayButtonState(IntEnum): +# NONE = 0 +# LEFT1 = 1 +# RIGHT1 = 2 +# LEFT2 = 4 +# RIGHT2 = 8 +# SMOKE = 16 class LegacyReplayFrame(MessagePackArrayModel): time: float # from ReplayFrame,the parent of LegacyReplayFrame x: float | None = None y: float | None = None - button_state: ReplayButtonState + button_state: int class FrameDataBundle(MessagePackArrayModel): @@ -135,10 +129,10 @@ class StoreScore(BaseModel): class StoreClientState(BaseModel): - state: SpectatorState | None - beatmap_status: BeatmapRankStatus - checksum: str - ruleset_id: int - score_token: int - watched_user: set[int] - score: StoreScore + state: SpectatorState | None = None + beatmap_status: BeatmapRankStatus | None = None + checksum: str | None = None + ruleset_id: int | None = None + score_token: int | None = None + watched_user: set[int] = Field(default_factory=set) + score: StoreScore | None = None diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index 40e75da..e5c807c 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -44,11 +44,11 @@ class Client: async def send_packet(self, packet: Packet): await self.connection.send_bytes(self.procotol.encode(packet)) - async def receive_packet(self) -> Packet: + async def receive_packets(self) -> list[Packet]: message = await self.connection.receive() d = message.get("bytes") or message.get("text", "").encode() if not d: - return PingPacket() # FIXME: Graceful empty message handling + return [PingPacket()] # FIXME: Graceful empty message handling return self.procotol.decode(d) async def _ping(self): @@ -138,12 +138,13 @@ class Hub: jump = False while not jump: try: - packet = await client.receive_packet() - task = asyncio.create_task(self._handle_packet(client, packet)) - self.tasks.add(task) - task.add_done_callback(self.tasks.discard) - except StopIteration: - pass + packets = await client.receive_packets() + for packet in packets: + if isinstance(packet, PingPacket): + continue + task = asyncio.create_task(self._handle_packet(client, packet)) + self.tasks.add(task) + task.add_done_callback(self.tasks.discard) except WebSocketDisconnect as e: print( f"Client {client.connection_id} disconnected: {e.code}, {e.reason}" diff --git a/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py index cfed09c..4229723 100644 --- a/app/signalr/hub/metadata.py +++ b/app/signalr/hub/metadata.py @@ -106,11 +106,12 @@ class MetadataHub(Hub): await asyncio.gather(*tasks) async def UpdateActivity(self, client: Client, activity_dict: dict | None) -> None: - if activity_dict is None: - # idle - return user_id = int(client.connection_id) - activity = TypeAdapter(UserActivity).validate_python(activity_dict) + activity = ( + TypeAdapter(UserActivity).validate_python(activity_dict) + if activity_dict + else None + ) store = self.state.get(user_id) if store: store.user_activity = activity @@ -119,7 +120,6 @@ class MetadataHub(Hub): user_activity=activity, ) self.state[user_id] = store - tasks = self.broadcast_tasks(user_id, store) tasks.add( self.call_noblock( diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index b3e6f10..c0be2dd 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import lzma import struct @@ -13,6 +14,7 @@ from app.dependencies.database import engine from app.models.beatmap import BeatmapRankStatus from app.models.mods import mods_to_int from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatisticsInt +from app.models.signalr import serialize_to_list from app.models.spectator_hub import ( APIUser, FrameDataBundle, @@ -106,7 +108,7 @@ def save_replay( for frame in frames: frame_strs.append( f"{frame.time - last_time}|{frame.x or 0.0}" - f"|{frame.y or 0.0}|{frame.button_state.value}" + f"|{frame.y or 0.0}|{frame.button_state}" ) last_time = frame.time frame_strs.append("-12345|0|0|0") @@ -143,6 +145,20 @@ class SpectatorHub(Hub): super().__init__() self.state: dict[int, StoreClientState] = {} + @staticmethod + def group_id(user_id: int) -> str: + return f"watch:{user_id}" + + async def on_client_connect(self, client: Client) -> None: + tasks = [ + self.call_noblock( + client, "UserBeganPlaying", user_id, serialize_to_list(store.state) + ) + for user_id, store in self.state.items() + if store.state is not None + ] + await asyncio.gather(*tasks) + async def BeginPlaySession( self, client: Client, score_token: int, state: SpectatorState ) -> None: @@ -184,7 +200,12 @@ class SpectatorHub(Hub): ), ) self.state[user_id] = store - await self.broadcast_call("UserBeganPlaying", user_id, state.model_dump()) + await self.broadcast_group_call( + self.group_id(user_id), + "UserBeganPlaying", + user_id, + serialize_to_list(state), + ) async def SendFrameData(self, client: Client, frame_data: FrameDataBundle) -> None: user_id = int(client.connection_id) @@ -201,14 +222,14 @@ class SpectatorHub(Hub): score.score_info.total_score = frame_data.header.total_score score.score_info.mods = frame_data.header.mods score.replay_frames.extend(frame_data.frames) - await self.broadcast_call( + await self.broadcast_group_call( + self.group_id(user_id), "UserSentFrames", user_id, frame_data.model_dump(), ) async def EndPlaySession(self, client: Client, state: SpectatorState) -> None: - print(f"EndPlaySession -> {client.connection_id} {state.model_dump()!r}") user_id = int(client.connection_id) store = self.state.get(user_id) if not store: @@ -217,7 +238,13 @@ class SpectatorHub(Hub): if not score or not store.score_token: return + assert store.beatmap_status is not None + async def _save_replay(): + assert store.checksum is not None + assert store.ruleset_id is not None + assert store.state is not None + assert store.score is not None async with AsyncSession(engine) as session: async with session: start_time = time.time() @@ -271,8 +298,51 @@ class SpectatorHub(Hub): del self.state[user_id] if state.state == SpectatedUserState.Playing: state.state = SpectatedUserState.Quit - await self.broadcast_call( - "UserEndedPlaying", + await self.broadcast_group_call( + self.group_id(user_id), + "UserFinishedPlaying", user_id, - state.model_dump(), + serialize_to_list(state) if state else None, ) + + async def StartWatchingUser(self, client: Client, target_id: int) -> None: + print(f"StartWatchingUser -> {client.connection_id} {target_id}") + user_id = int(client.connection_id) + target_store = self.state.get(target_id) + if target_store and target_store.state: + await self.call_noblock( + client, + "UserBeganPlaying", + target_id, + serialize_to_list(target_store.state), + ) + store = self.state.get(user_id) + if store is None: + store = StoreClientState( + watched_user=set(), + ) + store.watched_user.add(target_id) + self.state[user_id] = store + self.groups.setdefault(self.group_id(target_id), set()).add(client) + + async with AsyncSession(engine) as session: + async with session.begin(): + username = ( + await session.exec(select(User.name).where(User.id == user_id)) + ).first() + if not username: + return + if (target_client := self.get_client_by_id(str(target_id))) is not None: + await self.call_noblock( + target_client, "UserStartedWatching", [[user_id, username]] + ) + + async def EndWatchingUser(self, client: Client, target_id: int) -> None: + print(f"EndWatchingUser -> {client.connection_id} {target_id}") + user_id = int(client.connection_id) + self.groups[self.group_id(target_id)].discard(client) + store = self.state.get(user_id) + if store: + store.watched_user.discard(target_id) + if (target_client := self.get_client_by_id(str(target_id))) is not None: + await self.call_noblock(target_client, "UserEndedWatching", user_id) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index 5fa0908..1ff9b83 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -60,7 +60,7 @@ PACKETS = { class Protocol(TypingProtocol): @staticmethod - def decode(input: bytes) -> Packet: ... + def decode(input: bytes) -> list[Packet]: ... @staticmethod def encode(packet: Packet) -> bytes: ... @@ -93,7 +93,7 @@ class MsgpackProtocol: return result, pos @staticmethod - def decode(input: bytes) -> Packet: + def decode(input: bytes) -> list[Packet]: length, offset = MsgpackProtocol._decode_varint(input) message_data = input[offset : offset + length] # FIXME: custom deserializer for APIMod @@ -106,23 +106,27 @@ class MsgpackProtocol: raise ValueError(f"Unknown packet type: {packet_type}") match packet_type: case PacketType.INVOCATION: - return InvocationPacket( - header=unpacked[1], - invocation_id=unpacked[2], - target=unpacked[3], - arguments=unpacked[4] if len(unpacked) > 4 else None, - stream_ids=unpacked[5] if len(unpacked) > 5 else None, - ) + return [ + InvocationPacket( + header=unpacked[1], + invocation_id=unpacked[2], + target=unpacked[3], + arguments=unpacked[4] if len(unpacked) > 4 else None, + stream_ids=unpacked[5] if len(unpacked) > 5 else None, + ) + ] case PacketType.COMPLETION: result_kind = unpacked[3] - return CompletionPacket( - header=unpacked[1], - invocation_id=unpacked[2], - error=unpacked[4] if result_kind == 1 else None, - result=unpacked[5] if result_kind == 3 else None, - ) + return [ + CompletionPacket( + header=unpacked[1], + invocation_id=unpacked[2], + error=unpacked[4] if result_kind == 1 else None, + result=unpacked[5] if result_kind == 3 else None, + ) + ] case PacketType.PING: - return PingPacket() + return [PingPacket()] raise ValueError(f"Unsupported packet type: {packet_type}") @staticmethod @@ -153,38 +157,48 @@ class MsgpackProtocol: ] ) elif isinstance(packet, PingPacket): - pass - - data = msgpack.packb(payload, use_bin_type=True) + payload.pop(-1) + data = msgpack.packb(payload, use_bin_type=True, datetime=True) return MsgpackProtocol._encode_varint(len(data)) + data class JSONProtocol: @staticmethod - def decode(input: bytes) -> Packet: - data = json.loads(input[:-1].decode("utf-8")) - packet_type = PacketType(data["type"]) - if packet_type not in PACKETS: - raise ValueError(f"Unknown packet type: {packet_type}") - match packet_type: - case PacketType.INVOCATION: - return InvocationPacket( - header=data.get("header"), - invocation_id=data.get("invocationId"), - target=data["target"], - arguments=data.get("arguments"), - stream_ids=data.get("streamIds"), - ) - case PacketType.COMPLETION: - return CompletionPacket( - header=data.get("header"), - invocation_id=data["invocationId"], - error=data.get("error"), - result=data.get("result"), - ) - case PacketType.PING: - return PingPacket() - raise ValueError(f"Unsupported packet type: {packet_type}") + def decode(input: bytes) -> list[Packet]: + packets_raw = input.removesuffix(SEP).split(SEP) + packets = [] + if len(packets_raw) > 1: + for packet_raw in packets_raw: + packets.extend(JSONProtocol.decode(packet_raw)) + return packets + else: + data = json.loads(packets_raw[0]) + packet_type = PacketType(data["type"]) + if packet_type not in PACKETS: + raise ValueError(f"Unknown packet type: {packet_type}") + match packet_type: + case PacketType.INVOCATION: + return [ + InvocationPacket( + header=data.get("header"), + invocation_id=data.get("invocationId"), + target=data["target"], + arguments=data.get("arguments"), + stream_ids=data.get("streamIds"), + ) + ] + case PacketType.COMPLETION: + return [ + CompletionPacket( + header=data.get("header"), + invocation_id=data["invocationId"], + error=data.get("error"), + result=data.get("result"), + ) + ] + case PacketType.PING: + return [PingPacket()] + raise ValueError(f"Unsupported packet type: {packet_type}") @staticmethod def encode(packet: Packet) -> bytes: From f60283a6c2dc6a44c71e3d17e92f2eda4a375db4 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 28 Jul 2025 08:46:20 +0000 Subject: [PATCH 16/16] feat(signalr): graceful state manager --- app/models/metadata_hub.py | 4 +- app/models/signalr.py | 5 ++ app/models/spectator_hub.py | 4 +- app/signalr/hub/hub.py | 118 +++++++++++++++++++++++++++-------- app/signalr/hub/metadata.py | 58 ++++++++--------- app/signalr/hub/spectator.py | 107 ++++++++++++++++--------------- app/signalr/packet.py | 37 +++++++++++ app/signalr/router.py | 8 ++- pyproject.toml | 2 +- 9 files changed, 234 insertions(+), 109 deletions(-) diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index 615ea9b..8ae3e65 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -3,6 +3,8 @@ from __future__ import annotations from enum import IntEnum from typing import Any, Literal +from app.models.signalr import UserState + from pydantic import BaseModel, ConfigDict, Field @@ -126,7 +128,7 @@ UserActivity = ( ) -class MetadataClientState(BaseModel): +class MetadataClientState(UserState): user_activity: UserActivity | None = None status: OnlineStatus | None = None diff --git a/app/models/signalr.py b/app/models/signalr.py index ac8475f..09c85be 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -64,3 +64,8 @@ class NegotiateResponse(BaseModel): connectionToken: str negotiateVersion: int = 1 availableTransports: list[Transport] + + +class UserState(BaseModel): + connection_id: str + connection_token: str diff --git a/app/models/spectator_hub.py b/app/models/spectator_hub.py index 820eb16..0575a57 100644 --- a/app/models/spectator_hub.py +++ b/app/models/spectator_hub.py @@ -9,7 +9,7 @@ from app.models.beatmap import BeatmapRankStatus from .score import ( ScoreStatisticsInt, ) -from .signalr import MessagePackArrayModel +from .signalr import MessagePackArrayModel, UserState import msgpack from pydantic import BaseModel, Field, field_validator @@ -128,7 +128,7 @@ class StoreScore(BaseModel): replay_frames: list[LegacyReplayFrame] = Field(default_factory=list) -class StoreClientState(BaseModel): +class StoreClientState(UserState): state: SpectatorState | None = None beatmap_status: BeatmapRankStatus | None = None checksum: str | None = None diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index e5c807c..a4882b2 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -1,13 +1,16 @@ from __future__ import annotations +from abc import abstractmethod import asyncio import time import traceback from typing import Any from app.config import settings +from app.models.signalr import UserState from app.signalr.exception import InvokeException from app.signalr.packet import ( + ClosePacket, CompletionPacket, InvocationPacket, Packet, @@ -22,6 +25,19 @@ from pydantic import BaseModel from starlette.websockets import WebSocketDisconnect +class CloseConnection(Exception): + def __init__( + self, + message: str = "Connection closed", + allow_reconnect: bool = False, + from_client: bool = False, + ) -> None: + super().__init__(message) + self.message = message + self.allow_reconnect = allow_reconnect + self.from_client = from_client + + class Client: def __init__( self, @@ -39,7 +55,11 @@ class Client: self._store = ResultStore() def __hash__(self) -> int: - return hash(self.connection_id + self.connection_token) + return hash(self.connection_token) + + @property + def user_id(self) -> int: + return int(self.connection_id) async def send_packet(self, packet: Packet): await self.connection.send_bytes(self.procotol.encode(packet)) @@ -48,7 +68,7 @@ class Client: message = await self.connection.receive() d = message.get("bytes") or message.get("text", "").encode() if not d: - return [PingPacket()] # FIXME: Graceful empty message handling + return [] return self.procotol.decode(d) async def _ping(self): @@ -63,12 +83,13 @@ class Client: break -class Hub: +class Hub[TState: UserState]: def __init__(self) -> None: self.clients: dict[str, Client] = {} self.waited_clients: dict[str, int] = {} self.tasks: set[asyncio.Task] = set() self.groups: dict[str, set[Client]] = {} + self.state: dict[int, TState] = {} def add_waited_client(self, connection_token: str, timestamp: int) -> None: self.waited_clients[connection_token] = timestamp @@ -79,7 +100,25 @@ class Hub: return client return default - def add_client( + @abstractmethod + def create_state(self, client: Client) -> TState: + raise NotImplementedError + + def get_or_create_state(self, client: Client) -> TState: + if (state := self.state.get(client.user_id)) is not None: + return state + state = self.create_state(client) + self.state[client.user_id] = state + return state + + def add_to_group(self, client: Client, group_id: str) -> None: + self.groups.setdefault(group_id, set()).add(client) + + def remove_from_group(self, client: Client, group_id: str) -> None: + if group_id in self.groups: + self.groups[group_id].discard(client) + + async def add_client( self, connection_id: str, connection_token: str, @@ -104,19 +143,34 @@ class Hub: client._ping_task = task return client + async def remove_client(self, client: Client) -> None: + del self.clients[client.connection_token] + if client._listen_task: + client._listen_task.cancel() + if client._ping_task: + client._ping_task.cancel() + for group in self.groups.values(): + group.discard(client) + await self.clean_state(client, False) + + @abstractmethod + async def _clean_state(self, state: TState) -> None: + return + + async def clean_state(self, client: Client, disconnected: bool) -> None: + if (state := self.state.get(client.user_id)) is None: + return + if disconnected and client.connection_token != state.connection_token: + return + try: + await self._clean_state(state) + except Exception: + ... + async def on_connect(self, client: Client) -> None: if method := getattr(self, "on_client_connect", None): await method(client) - async def remove_client(self, connection_id: str) -> None: - if client := self.clients.get(connection_id): - del self.clients[connection_id] - if client._listen_task: - client._listen_task.cancel() - if client._ping_task: - client._ping_task.cancel() - await client.connection.close() - async def send_packet(self, client: Client, packet: Packet) -> None: await client.send_packet(packet) @@ -135,26 +189,40 @@ class Hub: await asyncio.gather(*tasks) async def _listen_client(self, client: Client) -> None: - jump = False - while not jump: - try: + try: + while True: packets = await client.receive_packets() for packet in packets: if isinstance(packet, PingPacket): continue + elif isinstance(packet, ClosePacket): + raise CloseConnection( + packet.error or "Connection closed by client", + packet.allow_reconnect, + True, + ) task = asyncio.create_task(self._handle_packet(client, packet)) self.tasks.add(task) task.add_done_callback(self.tasks.discard) - except WebSocketDisconnect as e: - print( - f"Client {client.connection_id} disconnected: {e.code}, {e.reason}" - ) - jump = True - except Exception as e: + except WebSocketDisconnect as e: + print(f"Client {client.connection_id} disconnected: {e.code}, {e.reason}") + except RuntimeError as e: + if "disconnect message" in str(e): + print(f"Client {client.connection_id} closed the connection.") + else: traceback.print_exc() - print(f"Error in client {client.connection_id}: {e}") - jump = True - await self.remove_client(client.connection_id) + print(f"RuntimeError in client {client.connection_id}: {e}") + except CloseConnection as e: + if not e.from_client: + await client.send_packet( + ClosePacket(error=e.message, allow_reconnect=e.allow_reconnect) + ) + print(f"Client {client.connection_id} closed the connection: {e.message}") + except Exception as e: + traceback.print_exc() + print(f"Error in client {client.connection_id}: {e}") + + await self.remove_client(client) async def _handle_packet(self, client: Client, packet: Packet) -> None: if isinstance(packet, PingPacket): diff --git a/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py index 4229723..03774e7 100644 --- a/app/signalr/hub/metadata.py +++ b/app/signalr/hub/metadata.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio from collections.abc import Coroutine +from typing import override from app.database.relationship import Relationship, RelationshipType from app.dependencies.database import engine @@ -16,32 +17,32 @@ from sqlmodel.ext.asyncio.session import AsyncSession ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers" -class MetadataHub(Hub): +class MetadataHub(Hub[MetadataClientState]): def __init__(self) -> None: super().__init__() - self.state: dict[int, MetadataClientState] = {} @staticmethod def online_presence_watchers_group() -> str: return ONLINE_PRESENCE_WATCHERS_GROUP def broadcast_tasks( - self, user_id: int, store: MetadataClientState + self, user_id: int, store: MetadataClientState | None ) -> set[Coroutine]: - if not store.pushable: + if store is not None and not store.pushable: return set() + data = store.to_dict() if store else None return { self.broadcast_group_call( self.online_presence_watchers_group(), "UserPresenceUpdated", user_id, - store.to_dict(), + data, ), self.broadcast_group_call( self.friend_presence_watchers_group(user_id), "FriendPresenceUpdated", user_id, - store.to_dict(), + data, ), } @@ -49,11 +50,21 @@ class MetadataHub(Hub): def friend_presence_watchers_group(user_id: int): return f"metadata:friend-presence-watchers:{user_id}" + @override + async def _clean_state(self, state: MetadataClientState) -> None: + if state.pushable: + await asyncio.gather(*self.broadcast_tasks(int(state.connection_id), None)) + + @override + def create_state(self, client: Client) -> MetadataClientState: + return MetadataClientState( + connection_id=client.connection_id, + connection_token=client.connection_token, + ) + async def on_client_connect(self, client: Client) -> None: user_id = int(client.connection_id) - if store := self.state.get(user_id): - store = MetadataClientState() - self.state[user_id] = store + self.get_or_create_state(client) async with AsyncSession(engine) as session: async with session.begin(): @@ -73,6 +84,7 @@ class MetadataHub(Hub): if ( friend_state := self.state.get(friend_id) ) and friend_state.pushable: + print("Pushed") tasks.append( self.broadcast_group_call( self.friend_presence_watchers_group(friend_id), @@ -86,14 +98,10 @@ class MetadataHub(Hub): async def UpdateStatus(self, client: Client, status: int) -> None: status_ = OnlineStatus(status) user_id = int(client.connection_id) - store = self.state.get(user_id) - if store: - if store.status is not None and store.status == status_: - return - store.status = OnlineStatus(status_) - else: - store = MetadataClientState(status=OnlineStatus(status_)) - self.state[user_id] = store + store = self.get_or_create_state(client) + if store.status is not None and store.status == status_: + return + store.status = OnlineStatus(status_) tasks = self.broadcast_tasks(user_id, store) tasks.add( self.call_noblock( @@ -112,14 +120,8 @@ class MetadataHub(Hub): if activity_dict else None ) - store = self.state.get(user_id) - if store: - store.user_activity = activity - else: - store = MetadataClientState( - user_activity=activity, - ) - self.state[user_id] = store + store = self.get_or_create_state(client) + store.user_activity = activity tasks = self.broadcast_tasks(user_id, store) tasks.add( self.call_noblock( @@ -144,9 +146,7 @@ class MetadataHub(Hub): if store.pushable ] ) - self.groups.setdefault(self.online_presence_watchers_group(), set()).add(client) + self.add_to_group(client, self.online_presence_watchers_group()) async def EndWatchingUserPresence(self, client: Client) -> None: - self.groups.setdefault(self.online_presence_watchers_group(), set()).discard( - client - ) + self.remove_from_group(client, self.online_presence_watchers_group()) diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index c0be2dd..5f7e6d1 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -5,6 +5,7 @@ import json import lzma import struct import time +from typing import override from app.database import Beatmap from app.database.score import Score @@ -140,15 +141,29 @@ def save_replay( replay_path.write_bytes(data) -class SpectatorHub(Hub): - def __init__(self) -> None: - super().__init__() - self.state: dict[int, StoreClientState] = {} - +class SpectatorHub(Hub[StoreClientState]): @staticmethod def group_id(user_id: int) -> str: return f"watch:{user_id}" + @override + def create_state(self, client: Client) -> StoreClientState: + return StoreClientState( + connection_id=client.connection_id, + connection_token=client.connection_token, + ) + + @override + async def _clean_state(self, state: StoreClientState) -> None: + if state.state: + await self._end_session(int(state.connection_id), state.state) + for target in self.waited_clients: + target_client = self.get_client_by_id(target) + if target_client: + await self.call_noblock( + target_client, "UserEndedWatching", int(state.connection_id) + ) + async def on_client_connect(self, client: Client) -> None: tasks = [ self.call_noblock( @@ -163,8 +178,8 @@ class SpectatorHub(Hub): self, client: Client, score_token: int, state: SpectatorState ) -> None: user_id = int(client.connection_id) - previous_state = self.state.get(user_id) - if previous_state is not None: + store = self.get_or_create_state(client) + if store.state is not None: return if state.beatmap_id is None or state.ruleset_id is None: return @@ -183,23 +198,19 @@ class SpectatorHub(Hub): if not user: return name = user.name - store = StoreClientState( - state=state, - beatmap_status=beatmap.beatmap_status, - checksum=beatmap.checksum, - ruleset_id=state.ruleset_id, - score_token=score_token, - watched_user=set(), - score=StoreScore( - score_info=ScoreInfo( - mods=state.mods, - user=APIUser(id=user_id, name=name), - ruleset=state.ruleset_id, - maximum_statistics=state.maximum_statistics, - ) - ), + store.state = state + store.beatmap_status = beatmap.beatmap_status + store.checksum = beatmap.checksum + store.ruleset_id = state.ruleset_id + store.score_token = score_token + store.score = StoreScore( + score_info=ScoreInfo( + mods=state.mods, + user=APIUser(id=user_id, name=name), + ruleset=state.ruleset_id, + maximum_statistics=state.maximum_statistics, + ) ) - self.state[user_id] = store await self.broadcast_group_call( self.group_id(user_id), "UserBeganPlaying", @@ -209,19 +220,16 @@ class SpectatorHub(Hub): async def SendFrameData(self, client: Client, frame_data: FrameDataBundle) -> None: user_id = int(client.connection_id) - state = self.state.get(user_id) - if not state: + state = self.get_or_create_state(client) + if not state.score: return - score = state.score - if not score: - return - score.score_info.acc = frame_data.header.acc - score.score_info.combo = frame_data.header.combo - score.score_info.max_combo = frame_data.header.max_combo - score.score_info.statistics = frame_data.header.statistics - score.score_info.total_score = frame_data.header.total_score - score.score_info.mods = frame_data.header.mods - score.replay_frames.extend(frame_data.frames) + state.score.score_info.acc = frame_data.header.acc + state.score.score_info.combo = frame_data.header.combo + state.score.score_info.max_combo = frame_data.header.max_combo + state.score.score_info.statistics = frame_data.header.statistics + state.score.score_info.total_score = frame_data.header.total_score + state.score.score_info.mods = frame_data.header.mods + state.score.replay_frames.extend(frame_data.frames) await self.broadcast_group_call( self.group_id(user_id), "UserSentFrames", @@ -231,9 +239,7 @@ class SpectatorHub(Hub): async def EndPlaySession(self, client: Client, state: SpectatorState) -> None: user_id = int(client.connection_id) - store = self.state.get(user_id) - if not store: - return + store = self.get_or_create_state(client) score = store.score if not score or not store.score_token: return @@ -294,8 +300,15 @@ class SpectatorHub(Hub): ): # save replay await _save_replay() + store.state = None + store.beatmap_status = None + store.checksum = None + store.ruleset_id = None + store.score_token = None + store.score = None + await self._end_session(user_id, state) - del self.state[user_id] + async def _end_session(self, user_id: int, state: SpectatorState) -> None: if state.state == SpectatedUserState.Playing: state.state = SpectatedUserState.Quit await self.broadcast_group_call( @@ -308,22 +321,18 @@ class SpectatorHub(Hub): async def StartWatchingUser(self, client: Client, target_id: int) -> None: print(f"StartWatchingUser -> {client.connection_id} {target_id}") user_id = int(client.connection_id) - target_store = self.state.get(target_id) - if target_store and target_store.state: + target_store = self.get_or_create_state(client) + if target_store.state: await self.call_noblock( client, "UserBeganPlaying", target_id, serialize_to_list(target_store.state), ) - store = self.state.get(user_id) - if store is None: - store = StoreClientState( - watched_user=set(), - ) + store = self.get_or_create_state(client) store.watched_user.add(target_id) - self.state[user_id] = store - self.groups.setdefault(self.group_id(target_id), set()).add(client) + + self.add_to_group(client, self.group_id(target_id)) async with AsyncSession(engine) as session: async with session.begin(): @@ -340,7 +349,7 @@ class SpectatorHub(Hub): async def EndWatchingUser(self, client: Client, target_id: int) -> None: print(f"EndWatchingUser -> {client.connection_id} {target_id}") user_id = int(client.connection_id) - self.groups[self.group_id(target_id)].discard(client) + self.remove_from_group(client, self.group_id(target_id)) store = self.state.get(user_id) if store: store.watched_user.discard(target_id) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index 1ff9b83..3dfc8ca 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -51,10 +51,18 @@ class PingPacket(Packet): type: PacketType = PacketType.PING +@dataclass(kw_only=True) +class ClosePacket(Packet): + type: PacketType = PacketType.CLOSE + error: str | None = None + allow_reconnect: bool = False + + PACKETS = { PacketType.INVOCATION: InvocationPacket, PacketType.COMPLETION: CompletionPacket, PacketType.PING: PingPacket, + PacketType.CLOSE: ClosePacket, } @@ -127,6 +135,13 @@ class MsgpackProtocol: ] case PacketType.PING: return [PingPacket()] + case PacketType.CLOSE: + return [ + ClosePacket( + error=unpacked[1], + allow_reconnect=unpacked[2] if len(unpacked) > 2 else False, + ) + ] raise ValueError(f"Unsupported packet type: {packet_type}") @staticmethod @@ -156,6 +171,13 @@ class MsgpackProtocol: packet.error or packet.result or None, ] ) + elif isinstance(packet, ClosePacket): + payload.extend( + [ + packet.error or "", + packet.allow_reconnect, + ] + ) elif isinstance(packet, PingPacket): payload.pop(-1) data = msgpack.packb(payload, use_bin_type=True, datetime=True) @@ -198,6 +220,13 @@ class JSONProtocol: ] case PacketType.PING: return [PingPacket()] + case PacketType.CLOSE: + return [ + ClosePacket( + error=data.get("error"), + allow_reconnect=data.get("allowReconnect", False), + ) + ] raise ValueError(f"Unsupported packet type: {packet_type}") @staticmethod @@ -231,6 +260,14 @@ class JSONProtocol: payload["result"] = packet.result elif isinstance(packet, PingPacket): pass + elif isinstance(packet, ClosePacket): + payload.update( + { + "allowReconnect": packet.allow_reconnect, + } + ) + if packet.error is not None: + payload["error"] = packet.error return json.dumps(payload).encode("utf-8") + SEP diff --git a/app/signalr/router.py b/app/signalr/router.py index 5c2f08f..237a575 100644 --- a/app/signalr/router.py +++ b/app/signalr/router.py @@ -74,7 +74,7 @@ async def connect( client = None try: - client = hub_.add_client( + client = await hub_.add_client( connection_id=user_id, connection_token=id, connection=websocket, @@ -87,13 +87,17 @@ async def connect( except ValueError as e: error = str(e) payload = {"error": error} if error else {} - # finish handshake await websocket.send_bytes(json.dumps(payload).encode() + SEP) if error or not client: await websocket.close(code=1008) return + await hub_.clean_state(client, False) task = asyncio.create_task(hub_.on_connect(client)) hub_.tasks.add(task) task.add_done_callback(hub_.tasks.discard) await hub_._listen_client(client) + try: + await websocket.close() + except Exception: + ... diff --git a/pyproject.toml b/pyproject.toml index 0c50d97..415208c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ mark-parentheses = false keep-runtime-typing = true [tool.pyright] -pythonVersion = "3.11" +pythonVersion = "3.12" pythonPlatform = "All" typeCheckingMode = "standard"