From 9ee087306b6d5ea99def8a07a9f1ecc537cbd681 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 18 Aug 2025 05:00:18 +0000 Subject: [PATCH 1/2] fix(multiplayer): cannot play when settings changed --- app/signalr/hub/multiplayer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index ba87e14..962840b 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -1114,6 +1114,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if settings.match_type == MatchType.PLAYLISTS: raise InvokeException("Invalid match type selected") + settings.playlist_item_id = room.settings.playlist_item_id previous_settings = room.settings room.settings = settings From 219f19d623ba854081098f68b006635ed181e297 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 18 Aug 2025 08:48:13 +0000 Subject: [PATCH 2/2] feat(beatmap,score): support failtime & more exact playtime --- app/database/beatmap.py | 9 ++ app/database/failtime.py | 56 ++++++++++++ app/database/score.py | 80 +++++++++++++---- app/models/mods.py | 8 ++ app/models/score.py | 7 ++ app/router/v2/score.py | 8 +- app/service/recalculate.py | 6 +- app/signalr/hub/spectator.py | 88 +++++++++++++++++-- .../2fcfc28846c1_beatmap_add_failtime.py | 44 ++++++++++ 9 files changed, 279 insertions(+), 27 deletions(-) create mode 100644 app/database/failtime.py create mode 100644 migrations/versions/2fcfc28846c1_beatmap_add_failtime.py diff --git a/app/database/beatmap.py b/app/database/beatmap.py index 8e40549..278d722 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from app.calculator import calculate_beatmap_attribute from app.config import settings +from app.database.failtime import FailTime, FailTimeResp from app.models.beatmap import BeatmapAttributes, BeatmapRankStatus from app.models.mods import APIMod from app.models.score import GameMode @@ -67,6 +68,9 @@ class Beatmap(BeatmapBase, table=True): beatmapset: Beatmapset = Relationship( back_populates="beatmaps", sa_relationship_kwargs={"lazy": "joined"} ) + failtimes: FailTime | None = Relationship( + back_populates="beatmap", sa_relationship_kwargs={"lazy": "joined"} + ) @classmethod async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap": @@ -156,6 +160,7 @@ class BeatmapResp(BeatmapBase): url: str = "" playcount: int = 0 passcount: int = 0 + failtimes: FailTimeResp | None = None @classmethod async def from_db( @@ -187,6 +192,10 @@ class BeatmapResp(BeatmapBase): beatmap_["beatmapset"] = await BeatmapsetResp.from_db( beatmap.beatmapset, session=session, user=user ) + if beatmap.failtimes is not None: + beatmap_["failtimes"] = FailTimeResp.from_db(beatmap.failtimes) + else: + beatmap_["failtimes"] = FailTimeResp() if session: beatmap_["playcount"] = ( await session.exec( diff --git a/app/database/failtime.py b/app/database/failtime.py new file mode 100644 index 0000000..ae0f66f --- /dev/null +++ b/app/database/failtime.py @@ -0,0 +1,56 @@ +from struct import Struct +from typing import TYPE_CHECKING + +from pydantic import BaseModel +from sqlmodel import ( + VARBINARY, + Column, + Field, + Relationship, + SQLModel, +) + +if TYPE_CHECKING: + from .beatmap import Beatmap +FAILTIME_STRUCT = Struct("<100i") + + +class FailTime(SQLModel, table=True): + __tablename__ = "failtime" # pyright: ignore[reportAssignmentType] + beatmap_id: int = Field(primary_key=True, index=True, foreign_key="beatmaps.id") + exit: bytes = Field(sa_column=Column(VARBINARY(400), nullable=False)) + fail: bytes = Field(sa_column=Column(VARBINARY(400), nullable=False)) + + beatmap: "Beatmap" = Relationship(back_populates="failtimes") + + @property + def exit_(self) -> list[int]: + return list(FAILTIME_STRUCT.unpack(self.exit)) + + @property + def fail_(self) -> list[int]: + return list(FAILTIME_STRUCT.unpack(self.fail)) + + @classmethod + def from_resp(cls, beatmap_id: int, failtime: "FailTimeResp") -> "FailTime": + return cls( + beatmap_id=beatmap_id, + exit=FAILTIME_STRUCT.pack(*failtime.exit), + fail=FAILTIME_STRUCT.pack(*failtime.fail), + ) + + +class FailTimeResp(BaseModel): + exit: list[int] = Field( + default_factory=lambda: list(FAILTIME_STRUCT.unpack(b"\x00" * 400)) + ) + fail: list[int] = Field( + default_factory=lambda: list(FAILTIME_STRUCT.unpack(b"\x00" * 400)) + ) + + @classmethod + def from_db(cls, failtime: FailTime) -> "FailTimeResp": + return cls( + exit=list(FAILTIME_STRUCT.unpack(failtime.exit)), + fail=list(FAILTIME_STRUCT.unpack(failtime.fail)), + ) diff --git a/app/database/score.py b/app/database/score.py index 7c70483..40d8235 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -15,13 +15,14 @@ from app.calculator import ( from app.config import settings from app.database.events import Event, EventType from app.database.team import TeamMember +from app.dependencies.database import get_redis from app.models.model import ( CurrentUserAttributes, PinAttributes, RespWithCursor, UTCBaseModel, ) -from app.models.mods import APIMod, mod_to_save, mods_can_get_pp +from app.models.mods import APIMod, get_speed_rate, mod_to_save, mods_can_get_pp from app.models.score import ( GameMode, HitResult, @@ -393,7 +394,7 @@ async def get_score_position_by_user( subq = select(BestScore, rownum).join(Beatmap).where(*wheres).subquery() stmt = select(subq.c.row_number).where(subq.c.user_id == user.id) result = await session.exec(stmt) - s = result.one_or_none() + s = result.first() return s if s else 0 @@ -457,7 +458,10 @@ async def get_user_best_score_with_mod_in_beatmap( BestScore.gamemode == mode if mode is not None else True, BestScore.beatmap_id == beatmap, BestScore.user_id == user, - BestScore.mods == mod, + text( + "JSON_CONTAINS(total_score_best_scores.mods, :w)" + " AND JSON_CONTAINS(:w, total_score_best_scores.mods)" + ).params(w=json.dumps(mod)), ) .order_by(col(BestScore.total_score).desc()) ) @@ -497,11 +501,46 @@ async def get_user_best_pp( ).all() +# https://github.com/ppy/osu-queue-score-statistics/blob/master/osu.Server.Queues.ScoreStatisticsProcessor/Helpers/PlayValidityHelper.cs +def get_play_length(score: Score, beatmap_length: int): + speed_rate = get_speed_rate(score.mods) + length = beatmap_length / speed_rate + return int(min(length, (score.ended_at - score.started_at).total_seconds())) + + +def calculate_playtime(score: Score, beatmap_length: int) -> tuple[int, bool]: + total_length = get_play_length(score, beatmap_length) + total_obj_hited = ( + score.n300 + + score.n100 + + score.n50 + + score.ngeki + + score.nkatu + + (score.nlarge_tick_hit or 0) + + (score.nlarge_tick_miss or 0) + + (score.nslider_tail_hit or 0) + + (score.nsmall_tick_hit or 0) + ) + total_obj = 0 + for statistics, count in score.maximum_statistics.items(): + if not isinstance(statistics, HitResult): + statistics = HitResult(statistics) + if statistics.is_scorable(): + total_obj += count + + return total_length, score.passed or ( + total_length > 8 + and score.total_score >= 5000 + and total_obj_hited >= min(0.1 * total_obj, 20) + ) + + async def process_user( session: AsyncSession, user: User, score: Score, - length: int, + score_token: int, + beatmap_length: int, ranked: bool = False, has_leaderboard: bool = False, ): @@ -644,7 +683,7 @@ async def process_user( mods=mod_for_save, ) ) - elif previous_score_best is not None: + if previous_score_best is not None: previous_score_best.total_score = score.total_score previous_score_best.rank = score.rank previous_score_best.mods = mod_for_save @@ -652,7 +691,11 @@ async def process_user( statistics.play_count += 1 mouthly_playcount.count += 1 - statistics.play_time += length + playtime, is_valid = calculate_playtime(score, beatmap_length) + if is_valid: + redis = get_redis() + await redis.xadd(f"score:existed_time:{score_token}", {"time": playtime}) + statistics.play_time += playtime statistics.count_100 += score.n100 + score.nkatu statistics.count_300 += score.n300 + score.ngeki statistics.count_50 += score.n50 @@ -662,18 +705,19 @@ async def process_user( ) if score.passed and ranked: - best_pp_scores = await get_user_best_pp(session, user.id, score.gamemode) - pp_sum = 0.0 - acc_sum = 0.0 - for i, bp in enumerate(best_pp_scores): - pp_sum += calculate_weighted_pp(bp.pp, i) - acc_sum += calculate_weighted_acc(bp.acc, i) - if len(best_pp_scores): - # https://github.com/ppy/osu-queue-score-statistics/blob/c538ae/osu.Server.Queues.ScoreStatisticsProcessor/Helpers/UserTotalPerformanceAggregateHelper.cs#L41-L45 - acc_sum *= 100 / (20 * (1 - math.pow(0.95, len(best_pp_scores)))) - acc_sum = clamp(acc_sum, 0.0, 100.0) - statistics.pp = pp_sum - statistics.hit_accuracy = acc_sum + with session.no_autoflush: + best_pp_scores = await get_user_best_pp(session, user.id, score.gamemode) + pp_sum = 0.0 + acc_sum = 0.0 + for i, bp in enumerate(best_pp_scores): + pp_sum += calculate_weighted_pp(bp.pp, i) + acc_sum += calculate_weighted_acc(bp.acc, i) + if len(best_pp_scores): + # https://github.com/ppy/osu-queue-score-statistics/blob/c538ae/osu.Server.Queues.ScoreStatisticsProcessor/Helpers/UserTotalPerformanceAggregateHelper.cs#L41-L45 + acc_sum *= 100 / (20 * (1 - math.pow(0.95, len(best_pp_scores)))) + acc_sum = clamp(acc_sum, 0.0, 100.0) + statistics.pp = pp_sum + statistics.hit_accuracy = acc_sum if add_to_db: session.add(mouthly_playcount) await process_beatmap_playcount(session, user.id, score.beatmap_id) diff --git a/app/models/mods.py b/app/models/mods.py index 191e9b3..fb07b0c 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -207,3 +207,11 @@ def mod_to_save(mods: list[APIMod]) -> list[str]: s = list({mod["acronym"] for mod in mods}) s.sort() return s + + +def get_speed_rate(mods: list[APIMod]): + rate = 1.0 + for mod in mods: + if mod["acronym"] in {"DT", "NC", "HT", "DC"}: + rate *= mod.get("settings", {}).get("speed_change", 1.0) # pyright: ignore[reportOperatorIssue] + return rate diff --git a/app/models/score.py b/app/models/score.py index 53b61c5..701028f 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -159,6 +159,13 @@ class HitResult(str, Enum): HitResult.MISS, ) + def is_scorable(self) -> bool: + return self not in ( + HitResult.NONE, + HitResult.IGNORE_HIT, + HitResult.IGNORE_MISS, + ) + class LeaderboardType(Enum): GLOBAL = "global" diff --git a/app/router/v2/score.py b/app/router/v2/score.py index 9c2ffe0..9033ff5 100644 --- a/app/router/v2/score.py +++ b/app/router/v2/score.py @@ -121,7 +121,13 @@ async def submit_score( score_id = score.id score_token.score_id = score_id await process_user( - db, current_user, score, beatmap_length, has_pp, has_leaderboard + db, + current_user, + score, + token, + beatmap_length, + has_pp, + has_leaderboard, ) score = (await db.exec(select(Score).where(Score.id == score_id))).first() assert score is not None diff --git a/app/service/recalculate.py b/app/service/recalculate.py index d65db89..1b0c1c0 100644 --- a/app/service/recalculate.py +++ b/app/service/recalculate.py @@ -14,7 +14,7 @@ from app.config import settings from app.database import BestScore, UserStatistics from app.database.beatmap import Beatmap from app.database.pp_best_score import PPBestScore -from app.database.score import Score, get_user_best_pp +from app.database.score import Score, calculate_playtime, get_user_best_pp from app.dependencies.database import engine, get_redis from app.dependencies.fetcher import get_fetcher from app.fetcher import Fetcher @@ -245,7 +245,9 @@ async def _recalculate_statistics(statistics: UserStatistics, session: AsyncSess statistics.play_count += 1 statistics.total_score += score.total_score - statistics.play_time += beatmap.hit_length + playtime, is_valid = calculate_playtime(score, beatmap.hit_length) + if is_valid: + statistics.play_time += playtime statistics.count_300 += score.n300 + score.ngeki statistics.count_100 += score.n100 + score.nkatu statistics.count_50 += score.n50 diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index 5abe880..ed1b48b 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -8,16 +8,19 @@ import time from typing import override from venv import logger +from app.calculator import clamp from app.config import settings from app.database import Beatmap, User +from app.database.failtime import FailTime, FailTimeResp from app.database.score import Score from app.database.score_token import ScoreToken -from app.dependencies.database import engine +from app.database.statistics import UserStatistics +from app.dependencies.database import engine, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.storage import get_storage_service from app.exception import InvokeException -from app.models.mods import mods_to_int -from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatistics +from app.models.mods import APIMod, mods_to_int +from app.models.score import GameMode, LegacyReplaySoloScoreInfo, ScoreStatistics from app.models.spectator_hub import ( APIUser, FrameDataBundle, @@ -164,7 +167,7 @@ class SpectatorHub(Hub[StoreClientState]): @override async def _clean_state(self, state: StoreClientState) -> None: if state.state: - await self._end_session(int(state.connection_id), state.state) + await self._end_session(int(state.connection_id), state.state, state) for target in self.waited_clients: target_client = self.get_client_by_id(target) if target_client: @@ -254,20 +257,22 @@ class SpectatorHub(Hub[StoreClientState]): or store.score_token is None or store.beatmap_status is None or store.state is None + or store.score is None ): return + if ( settings.enable_all_beatmap_leaderboard and store.beatmap_status.has_leaderboard() ) and any(k.is_hit() and v > 0 for k, v in score.score_info.statistics.items()): await self._process_score(store, client) + await self._end_session(user_id, state, store) 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) async def _process_score(self, store: StoreClientState, client: Client) -> None: user_id = int(client.connection_id) @@ -319,9 +324,80 @@ class SpectatorHub(Hub[StoreClientState]): frames=store.score.replay_frames, ) - async def _end_session(self, user_id: int, state: SpectatorState) -> None: + async def _end_session( + self, user_id: int, state: SpectatorState, store: StoreClientState + ) -> None: + async def _add_failtime(): + async with AsyncSession(engine) as session: + failtime = await session.get(FailTime, state.beatmap_id) + total_length = ( + await session.exec( + select(Beatmap.total_length).where( + Beatmap.id == state.beatmap_id + ) + ) + ).one() + index = clamp(round((exit_time / total_length) * 100), 0, 99) + if failtime is not None: + resp = FailTimeResp.from_db(failtime) + else: + resp = FailTimeResp() + if state.state == SpectatedUserState.Failed: + resp.fail[index] += 1 + elif state.state == SpectatedUserState.Quit: + resp.exit[index] += 1 + + new_failtime = FailTime.from_resp(state.beatmap_id, resp) # pyright: ignore[reportArgumentType] + if failtime is not None: + await session.merge(new_failtime) + else: + session.add(new_failtime) + await session.commit() + + async def _edit_playtime(token: int, ruleset_id: int, mods: list[APIMod]): + redis = get_redis() + key = f"score:existed_time:{token}" + messages = await redis.xrange(key, min="-", max="+", count=1) + if not messages: + return + before_time = int(messages[0][1]["time"]) + await redis.delete(key) + async with AsyncSession(engine) as session: + gamemode = GameMode.from_int(ruleset_id).to_special_mode(mods) + statistics = ( + await session.exec( + select(UserStatistics).where( + UserStatistics.user_id == user_id, + UserStatistics.mode == gamemode, + ) + ) + ).first() + if statistics is None: + return + statistics.play_time -= before_time + statistics.play_time += round(min(before_time, exit_time)) + if state.state == SpectatedUserState.Playing: state.state = SpectatedUserState.Quit + exit_time = max(frame.time for frame in store.score.replay_frames) // 1000 # pyright: ignore[reportOptionalMemberAccess] + + task = asyncio.create_task( + _edit_playtime( + store.score_token, # pyright: ignore[reportArgumentType] + store.ruleset_id, # pyright: ignore[reportArgumentType] + store.score.score_info.mods, # pyright: ignore[reportOptionalMemberAccess] + ) + ) + self.tasks.add(task) + task.add_done_callback(self.tasks.discard) + if ( + state.state == SpectatedUserState.Failed + or state.state == SpectatedUserState.Quit + ): + task = asyncio.create_task(_add_failtime()) + self.tasks.add(task) + task.add_done_callback(self.tasks.discard) + logger.info( f"[SpectatorHub] {user_id} finished playing {state.beatmap_id} " f"with {state.state}" diff --git a/migrations/versions/2fcfc28846c1_beatmap_add_failtime.py b/migrations/versions/2fcfc28846c1_beatmap_add_failtime.py new file mode 100644 index 0000000..112d93d --- /dev/null +++ b/migrations/versions/2fcfc28846c1_beatmap_add_failtime.py @@ -0,0 +1,44 @@ +"""beatmap: add failtime + +Revision ID: 2fcfc28846c1 +Revises: 2dcd04d3f4dc +Create Date: 2025-08-18 06:06:30.929740 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "2fcfc28846c1" +down_revision: str | Sequence[str] | None = "2dcd04d3f4dc" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "failtime", + sa.Column("beatmap_id", sa.Integer(), nullable=False), + sa.Column("exit", sa.VARBINARY(length=400), nullable=False), + sa.Column("fail", sa.VARBINARY(length=400), nullable=False), + sa.ForeignKeyConstraint( + ["beatmap_id"], + ["beatmaps.id"], + ), + sa.PrimaryKeyConstraint("beatmap_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("failtime") + # ### end Alembic commands ###