From e65e8b45d843b84a128a25646c2f07b19f9447f1 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 15 Aug 2025 10:11:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(calculator):=20=E6=9B=B4=E6=94=B9=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E5=8F=AF=E7=96=91=E8=B0=B1=E9=9D=A2=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E7=9A=84=E5=88=A4=E6=96=AD=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Threshold 枚举类,定义各种异常阈值 - 实现 too_dense 函数,检测单位时间内的物件密度是否异常 - 实现 slider_is_sus 函数,检查滑条是否存在异常 - 重构 is_suspicious_beatmap 函数,增加对不同游戏模式的检测逻辑 --- app/calculator.py | 95 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 7 deletions(-) diff --git a/app/calculator.py b/app/calculator.py index d56ac80..b87cd5d 100644 --- a/app/calculator.py +++ b/app/calculator.py @@ -1,5 +1,6 @@ from __future__ import annotations +from enum import Enum import math from typing import TYPE_CHECKING @@ -9,7 +10,7 @@ from app.models.beatmap import BeatmapAttributes from app.models.mods import APIMod from app.models.score import GameMode -from osupyparser import OsuFile +from osupyparser import HitObject, OsuFile from osupyparser.osu.objects import Slider from sqlmodel import col, exists, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -266,13 +267,93 @@ def calculate_weighted_acc(acc: float, index: int) -> float: return calculate_pp_weight(index) * acc if acc > 0 else 0.0 +# 大致算法来自 https://github.com/MaxOhn/rosu-pp/blob/main/src/model/beatmap/suspicious.rs + + +class Threshold(int, Enum): + # 谱面异常常量 + NOTES_THRESHOLD = 500000 # 除 taiko 以外任何模式的物件数量 + TAIKO_THRESHOLD = 30000 # taiko 模式下的物量限制 + + NOTES_PER_1S_THRESHOLD = 200 # 3000 BPM + NOTES_PER_10S_THRESHOLD = 500 # 600 BPM + + # 这个尺寸已经是常规游玩区域大小的 4 倍了 …… 如果不合适那另说吧 + NOTE_POSX_THRESHOLD = 512 # x: [-512,512] + NOTE_POSY_THRESHOLD = 384 # y: [-384,384] + + POS_ERROR_THRESHOLD = ( + 1280 * 50 + ) # 超过这么多个物件(包括滑条控制点)的位置有问题就毙掉 + + SLIDER_REPEAT_THRESHOLD = 5000 + + +def too_dense(hit_objects: list[HitObject], per_1s: int, per_10s: int) -> bool: + per_1s = max(1, per_1s) + per_10s = max(1, per_10s) + for i in range(0, len(hit_objects)): + if len(hit_objects) > i + per_1s: + if hit_objects[i + per_1s].start_time - hit_objects[i].start_time < 1000: + return True + elif len(hit_objects) > i + per_10s: + if hit_objects[i + per_10s].start_time - hit_objects[i].start_time < 10000: + return True + return False + + +def slider_is_sus(hit_objects: list[HitObject]) -> bool: + for obj in hit_objects: + if isinstance(obj, Slider): + flag_repeat = obj.repeat_count > Threshold.SLIDER_REPEAT_THRESHOLD + flag_pos = int( + obj.pos.x > Threshold.NOTE_POSX_THRESHOLD + or obj.pos.x < 0 + or obj.pos.y > Threshold.NOTE_POSY_THRESHOLD + or obj.pos.y < 0 + ) + for point in obj.points: + flag_pos += int( + point.x > Threshold.NOTE_POSX_THRESHOLD + or point.x < 0 + or point.y > Threshold.NOTE_POSY_THRESHOLD + or point.y < 0 + ) + if flag_pos or flag_repeat: + return True + return False + + def is_suspicious_beatmap(content: str) -> bool: osufile = OsuFile(content=content.encode("utf-8-sig")).parse_file() - for obj in osufile.hit_objects: - if obj.pos.x < 0 or obj.pos.y < 0 or obj.pos.x > 512 or obj.pos.y > 384: + if ( + osufile.hit_objects[-1].start_time - osufile.hit_objects[0].start_time + > 24 * 60 * 60 * 1000 + ): + return True + if osufile.mode == int(GameMode.TAIKO): + if len(osufile.hit_objects) > Threshold.TAIKO_THRESHOLD: return True - if isinstance(obj, Slider): - for point in obj.points: - if point.x < 0 or point.y < 0 or point.x > 512 or point.y > 384: - return True + elif len(osufile.hit_objects) > Threshold.NOTES_THRESHOLD: + return True + match osufile.mode: + case int(GameMode.OSU): + return too_dense( + osufile.hit_objects, + Threshold.NOTES_PER_1S_THRESHOLD, + Threshold.NOTES_PER_10S_THRESHOLD, + ) or slider_is_sus(osufile.hit_objects) + case int(GameMode.TAIKO): + return too_dense( + osufile.hit_objects, + Threshold.NOTES_PER_1S_THRESHOLD * 2, + Threshold.NOTES_PER_10S_THRESHOLD * 2, + ) + case int(GameMode.FRUITS): + return slider_is_sus(osufile.hit_objects) + case int(GameMode.MANIA): + keys_per_hand = max(1, int(osufile.cs / 2)) + per_1s = Threshold.NOTES_PER_1S_THRESHOLD * keys_per_hand + per_10s = Threshold.NOTES_PER_10S_THRESHOLD * keys_per_hand + return too_dense(osufile.hit_objects, per_1s, per_10s) return False