From 3ee95b0e7c0ecbb439ad12f5041c89b2efb98d0e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 09:03:23 +0000 Subject: [PATCH] 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: