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.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(

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.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)