This commit is contained in:
咕谷酱
2025-08-18 17:15:43 +08:00
10 changed files with 280 additions and 27 deletions

View File

@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
from app.calculator import calculate_beatmap_attribute from app.calculator import calculate_beatmap_attribute
from app.config import settings from app.config import settings
from app.database.failtime import FailTime, FailTimeResp
from app.models.beatmap import BeatmapAttributes, BeatmapRankStatus from app.models.beatmap import BeatmapAttributes, BeatmapRankStatus
from app.models.mods import APIMod from app.models.mods import APIMod
from app.models.score import GameMode from app.models.score import GameMode
@@ -67,6 +68,9 @@ class Beatmap(BeatmapBase, table=True):
beatmapset: Beatmapset = Relationship( beatmapset: Beatmapset = Relationship(
back_populates="beatmaps", sa_relationship_kwargs={"lazy": "joined"} back_populates="beatmaps", sa_relationship_kwargs={"lazy": "joined"}
) )
failtimes: FailTime | None = Relationship(
back_populates="beatmap", sa_relationship_kwargs={"lazy": "joined"}
)
@classmethod @classmethod
async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap": async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap":
@@ -156,6 +160,7 @@ class BeatmapResp(BeatmapBase):
url: str = "" url: str = ""
playcount: int = 0 playcount: int = 0
passcount: int = 0 passcount: int = 0
failtimes: FailTimeResp | None = None
@classmethod @classmethod
async def from_db( async def from_db(
@@ -187,6 +192,10 @@ class BeatmapResp(BeatmapBase):
beatmap_["beatmapset"] = await BeatmapsetResp.from_db( beatmap_["beatmapset"] = await BeatmapsetResp.from_db(
beatmap.beatmapset, session=session, user=user 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: if session:
beatmap_["playcount"] = ( beatmap_["playcount"] = (
await session.exec( await session.exec(

56
app/database/failtime.py Normal file
View File

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

View File

@@ -15,13 +15,14 @@ from app.calculator import (
from app.config import settings from app.config import settings
from app.database.events import Event, EventType from app.database.events import Event, EventType
from app.database.team import TeamMember from app.database.team import TeamMember
from app.dependencies.database import get_redis
from app.models.model import ( from app.models.model import (
CurrentUserAttributes, CurrentUserAttributes,
PinAttributes, PinAttributes,
RespWithCursor, RespWithCursor,
UTCBaseModel, 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 ( from app.models.score import (
GameMode, GameMode,
HitResult, HitResult,
@@ -393,7 +394,7 @@ async def get_score_position_by_user(
subq = select(BestScore, rownum).join(Beatmap).where(*wheres).subquery() subq = select(BestScore, rownum).join(Beatmap).where(*wheres).subquery()
stmt = select(subq.c.row_number).where(subq.c.user_id == user.id) stmt = select(subq.c.row_number).where(subq.c.user_id == user.id)
result = await session.exec(stmt) result = await session.exec(stmt)
s = result.one_or_none() s = result.first()
return s if s else 0 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.gamemode == mode if mode is not None else True,
BestScore.beatmap_id == beatmap, BestScore.beatmap_id == beatmap,
BestScore.user_id == user, 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()) .order_by(col(BestScore.total_score).desc())
) )
@@ -497,11 +501,46 @@ async def get_user_best_pp(
).all() ).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( async def process_user(
session: AsyncSession, session: AsyncSession,
user: User, user: User,
score: Score, score: Score,
length: int, score_token: int,
beatmap_length: int,
ranked: bool = False, ranked: bool = False,
has_leaderboard: bool = False, has_leaderboard: bool = False,
): ):
@@ -644,7 +683,7 @@ async def process_user(
mods=mod_for_save, 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.total_score = score.total_score
previous_score_best.rank = score.rank previous_score_best.rank = score.rank
previous_score_best.mods = mod_for_save previous_score_best.mods = mod_for_save
@@ -652,7 +691,11 @@ async def process_user(
statistics.play_count += 1 statistics.play_count += 1
mouthly_playcount.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_100 += score.n100 + score.nkatu
statistics.count_300 += score.n300 + score.ngeki statistics.count_300 += score.n300 + score.ngeki
statistics.count_50 += score.n50 statistics.count_50 += score.n50
@@ -662,18 +705,19 @@ async def process_user(
) )
if score.passed and ranked: if score.passed and ranked:
best_pp_scores = await get_user_best_pp(session, user.id, score.gamemode) with session.no_autoflush:
pp_sum = 0.0 best_pp_scores = await get_user_best_pp(session, user.id, score.gamemode)
acc_sum = 0.0 pp_sum = 0.0
for i, bp in enumerate(best_pp_scores): acc_sum = 0.0
pp_sum += calculate_weighted_pp(bp.pp, i) for i, bp in enumerate(best_pp_scores):
acc_sum += calculate_weighted_acc(bp.acc, i) pp_sum += calculate_weighted_pp(bp.pp, i)
if len(best_pp_scores): acc_sum += calculate_weighted_acc(bp.acc, i)
# https://github.com/ppy/osu-queue-score-statistics/blob/c538ae/osu.Server.Queues.ScoreStatisticsProcessor/Helpers/UserTotalPerformanceAggregateHelper.cs#L41-L45 if len(best_pp_scores):
acc_sum *= 100 / (20 * (1 - math.pow(0.95, 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 = clamp(acc_sum, 0.0, 100.0) acc_sum *= 100 / (20 * (1 - math.pow(0.95, len(best_pp_scores))))
statistics.pp = pp_sum acc_sum = clamp(acc_sum, 0.0, 100.0)
statistics.hit_accuracy = acc_sum statistics.pp = pp_sum
statistics.hit_accuracy = acc_sum
if add_to_db: if add_to_db:
session.add(mouthly_playcount) session.add(mouthly_playcount)
await process_beatmap_playcount(session, user.id, score.beatmap_id) await process_beatmap_playcount(session, user.id, score.beatmap_id)

View File

@@ -207,3 +207,11 @@ def mod_to_save(mods: list[APIMod]) -> list[str]:
s = list({mod["acronym"] for mod in mods}) s = list({mod["acronym"] for mod in mods})
s.sort() s.sort()
return s 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

View File

@@ -159,6 +159,13 @@ class HitResult(str, Enum):
HitResult.MISS, HitResult.MISS,
) )
def is_scorable(self) -> bool:
return self not in (
HitResult.NONE,
HitResult.IGNORE_HIT,
HitResult.IGNORE_MISS,
)
class LeaderboardType(Enum): class LeaderboardType(Enum):
GLOBAL = "global" GLOBAL = "global"

View File

@@ -121,7 +121,13 @@ async def submit_score(
score_id = score.id score_id = score.id
score_token.score_id = score_id score_token.score_id = score_id
await process_user( 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() score = (await db.exec(select(Score).where(Score.id == score_id))).first()
assert score is not None assert score is not None

View File

@@ -14,7 +14,7 @@ from app.config import settings
from app.database import BestScore, UserStatistics from app.database import BestScore, UserStatistics
from app.database.beatmap import Beatmap from app.database.beatmap import Beatmap
from app.database.pp_best_score import PPBestScore 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.database import engine, get_redis
from app.dependencies.fetcher import get_fetcher from app.dependencies.fetcher import get_fetcher
from app.fetcher import Fetcher from app.fetcher import Fetcher
@@ -245,7 +245,9 @@ async def _recalculate_statistics(statistics: UserStatistics, session: AsyncSess
statistics.play_count += 1 statistics.play_count += 1
statistics.total_score += score.total_score 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_300 += score.n300 + score.ngeki
statistics.count_100 += score.n100 + score.nkatu statistics.count_100 += score.n100 + score.nkatu
statistics.count_50 += score.n50 statistics.count_50 += score.n50

View File

@@ -1114,6 +1114,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
if settings.match_type == MatchType.PLAYLISTS: if settings.match_type == MatchType.PLAYLISTS:
raise InvokeException("Invalid match type selected") raise InvokeException("Invalid match type selected")
settings.playlist_item_id = room.settings.playlist_item_id
previous_settings = room.settings previous_settings = room.settings
room.settings = settings room.settings = settings

View File

@@ -8,16 +8,19 @@ import time
from typing import override from typing import override
from venv import logger from venv import logger
from app.calculator import clamp
from app.config import settings from app.config import settings
from app.database import Beatmap, User from app.database import Beatmap, User
from app.database.failtime import FailTime, FailTimeResp
from app.database.score import Score from app.database.score import Score
from app.database.score_token import ScoreToken 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.fetcher import get_fetcher
from app.dependencies.storage import get_storage_service from app.dependencies.storage import get_storage_service
from app.exception import InvokeException from app.exception import InvokeException
from app.models.mods import mods_to_int from app.models.mods import APIMod, mods_to_int
from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatistics from app.models.score import GameMode, LegacyReplaySoloScoreInfo, ScoreStatistics
from app.models.spectator_hub import ( from app.models.spectator_hub import (
APIUser, APIUser,
FrameDataBundle, FrameDataBundle,
@@ -164,7 +167,7 @@ class SpectatorHub(Hub[StoreClientState]):
@override @override
async def _clean_state(self, state: StoreClientState) -> None: async def _clean_state(self, state: StoreClientState) -> None:
if state.state: 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: for target in self.waited_clients:
target_client = self.get_client_by_id(target) target_client = self.get_client_by_id(target)
if target_client: if target_client:
@@ -254,20 +257,22 @@ class SpectatorHub(Hub[StoreClientState]):
or store.score_token is None or store.score_token is None
or store.beatmap_status is None or store.beatmap_status is None
or store.state is None or store.state is None
or store.score is None
): ):
return return
if ( if (
settings.enable_all_beatmap_leaderboard settings.enable_all_beatmap_leaderboard
and store.beatmap_status.has_leaderboard() and store.beatmap_status.has_leaderboard()
) and any(k.is_hit() and v > 0 for k, v in score.score_info.statistics.items()): ) 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._process_score(store, client)
await self._end_session(user_id, state, store)
store.state = None store.state = None
store.beatmap_status = None store.beatmap_status = None
store.checksum = None store.checksum = None
store.ruleset_id = None store.ruleset_id = None
store.score_token = None store.score_token = None
store.score = None store.score = None
await self._end_session(user_id, state)
async def _process_score(self, store: StoreClientState, client: Client) -> None: async def _process_score(self, store: StoreClientState, client: Client) -> None:
user_id = int(client.connection_id) user_id = int(client.connection_id)
@@ -319,9 +324,80 @@ class SpectatorHub(Hub[StoreClientState]):
frames=store.score.replay_frames, 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: if state.state == SpectatedUserState.Playing:
state.state = SpectatedUserState.Quit 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( logger.info(
f"[SpectatorHub] {user_id} finished playing {state.beatmap_id} " f"[SpectatorHub] {user_id} finished playing {state.beatmap_id} "
f"with {state.state}" f"with {state.state}"

View File

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