diff --git a/tools/chart_achievement/README.md b/tools/chart_achievement/README.md index 9a87e04..364ca0a 100644 --- a/tools/chart_achievement/README.md +++ b/tools/chart_achievement/README.md @@ -42,18 +42,60 @@ python chart_achievement.py # 不给参数 = 交互向导 ## 正向:已知判定数 → 达成率 + DX 分 +达成率是**按音符权重加权**的(Tap 500 / Hold 1000 / Slide 1500 / Break 2500), +所以有两种给判定数的方式: + +### ① 精确:按音符类别给(推荐,得到确切数值) + ```powershell -python chart_achievement.py --chart "<...>\000008_03.ma2" --cp 290 --perfect 8 --great 3 --good 1 -python chart_achievement.py --game "<...>\Sinmai_Data" --music-id 8 --level 3 --cp 290 --perfect 8 --great 3 --good 1 # 3 = MASTER +python chart_achievement.py --game "<...>\Sinmai_Data" --music-id 8 --level 3 --tap 260/0/3 --break 6 # 3 = MASTER ``` -- `--cp` / `--perfect` / `--great` / `--good` 都给个数,`--miss` 留空时自动补足 - (`miss = 总音符数 - CP - Perfect - Great - Good`)。 -- 输出达成率的**区间**(下界 ~ 上界)和「按比例分摊」的参考值: - 只给聚合判定数时,达成率取决于这些 Great/Good/Miss **落在哪些音符上** - (落在绝赞上扣得多、落在 Tap 上扣得少),所以是一个区间而不是一个数。 - 下界 = 扣分尽量落在高权重音符上,上界 = 尽量落在低权重音符上。 -- DX 分是确定的:`3×CP + 2×Perfect + 1×Great`。 +每个类别写 `CP/Perfect/Great/Good/Miss` 五个数(`/`、`,` 或空格分隔,后面可以省略), +**没给满的部分按 CP 算**: + +| 参数 | 类别 | 权重 | +| --- | --- | --- | +| `--tap` | Tap / Star / Touch / EX | 500 | +| `--hold` | Hold 系 | 1000 | +| `--slide` | Slide 系 | 1500 | +| `--break` | 绝赞(Break 系) | 2500 | + +绝赞的 Great / Perfect 本身还有档位,需要时可以再细分(不给就按 Great 失分 20%、Perfect 奖励 75 算): + +| 参数 | 含义 | +| --- | --- | +| `--break-great40 N` | 绝赞里 N 个 Great 是「失分 40%」档(Great2nd) | +| `--break-great50 N` | 绝赞里 N 个 Great 是「失分 50%」档(Great3rd) | +| `--break-perfect50 N` | 绝赞里 N 个 Perfect 是「奖励分 50」档(Perfect2nd) | + +输出就是一个**确切**的达成率,并附上失分 / 绝赞奖励分的明细: + +``` +判定输入(按音符类别;Break = 绝赞;没给的部分按 CP 算) + Tap/Star/Touch/EX (500) 263 个:CP 260 · Great 3 + Hold (1000) 14 个:CP 14 + Slide (1500) 19 个:CP 19 + Break (2500) 6 个:CP 6 +结果 + 达成率 100.8413% (确切值) + DX 分 900 / 906 (99.34%) +``` + +### ② 汇总:只给总判定数(得到估计值 + 区间) + +```powershell +python chart_achievement.py --chart "<...>\000008_03.ma2" --cp 290 --perfect 8 --great 3 --good 1 +``` + +`--cp` / `--perfect` / `--great` / `--good` 都给总数,`--miss` 留空时自动补足 +(`miss = 总音符数 - CP - Perfect - Great - Good`)。 + +这时达成率取决于这些 Great/Good/Miss **落在哪类音符上**(落在绝赞上扣得多、落在 Tap 上扣得少), +所以给出的是「按类别比例分摊的估计值」+ 一个区间(下界 = 扣分都落在高权重音符上, +上界 = 都落在低权重音符上)。想要一个确切数值就用上面的 ①。 + +DX 分在两种方式下都是确定的:`3×CP + 2×Perfect + 1×Great`。 ## 反向:已知达成率 → 判定组合 + DX 分 @@ -146,8 +188,11 @@ CP 的物理可能范围(Perfect 只能落在绝赞音符上,所以 CP 不 ## 已知局限 -- 只按谱面文本计算,不涉及游戏运行时的实际判定时机分布(例如绝赞 Perfect 是 75 还是 50 档, - 正向计算里只能给区间)。 +- 只按谱面文本计算,不涉及游戏运行时的实际判定时机分布。绝赞的 Great 档位(失分 20% / 40% / 50%) + 和 Perfect 档位(奖励 75 / 50)需要自己用 `--break-great40` / `--break-great50` / + `--break-perfect50` 指定,不给就按 20% / 75 算。 +- 汇总模式(只给总判定数)只能给区间——这不是精度问题,是信息量不够:同样的判定总数, + 落在绝赞上和落在 Tap 上扣的分不同。要确切值就按类别给(`--tap` / `--hold` / `--slide` / `--break`)。 - 精确反向只验证坏音符数 ≤ 12 的组合(见上文)。 - 若 `.ma2` 里的 `T_NUM_ALL` 与解析出的计分音符数不一致,会在输出里给警告 (全部 5419 个游戏谱面实测一致)。 diff --git a/tools/chart_achievement/chart_achievement.py b/tools/chart_achievement/chart_achievement.py index 76b3c6a..6ccbe98 100644 --- a/tools/chart_achievement/chart_achievement.py +++ b/tools/chart_achievement/chart_achievement.py @@ -13,6 +13,7 @@ from __future__ import annotations import argparse import json import os +import re import sys from fractions import Fraction from typing import Dict, List, Optional @@ -22,9 +23,10 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from ma2 import (DIFFICULTY_NAMES, GROUP_LABEL, GROUP_ORDER, Chart, chart_path, find_music_dir, level_display, parse_difficulty, parse_ma2, parse_music_xml) -from score import (DX_PER, JUDGE_LABEL, JUDGMENTS, Theory, dx_score, - reference_achievement, solve_forward, solve_reverse, - theory_from_chart) +from score import (CLASS_LABEL, CLASS_ORDER, CLASS_WEIGHT, DX_PER, JUDGE_LABEL, + JUDGMENTS, Theory, class_capacity, dx_score, + reference_achievement, solve_exact, solve_forward, + solve_reverse, theory_from_chart) try: # 中文输出到管道时避免 UnicodeEncodeError sys.stdout.reconfigure(errors="replace") @@ -138,20 +140,70 @@ def print_chart(chart: Chart, th: Theory) -> None: def print_forward(chart: Chart, result) -> None: th = result.theory print(LINE) - print("判定输入") + print("判定输入(汇总)") for j in JUDGMENTS: print(" %-18s %d" % (JUDGE_LABEL[j], result.judges.get(j, 0))) print(" %-18s %d / %d" % ("合计", sum(result.judges.values()), th.total_notes)) print(LINE) print("结果") - print(" 达成率 %s ~ %s" % (pct(result.achievement_min), pct(result.achievement_max))) - print(" 按比例参考 %s" % pct(result.achievement_ref)) + exact = result.achievement_min == result.achievement_max + if exact: + print(" 达成率 %s (确切值)" % pct(result.achievement_min)) + else: + print(" 达成率 %s (按音符类别比例分摊的估计值)" % pct(result.achievement_ref)) + print(" 区间 %s ~ %s" + % (pct(result.achievement_min), pct(result.achievement_max))) print(" DX 分 %d / %d (%.2f%%)" % (result.dx, th.max_dx, 100.0 * result.dx / th.max_dx if th.max_dx else 0.0)) - if result.achievement_min != result.achievement_max: + if not exact: print() - print(" 说明:只给聚合判定数时,达成率取决于「哪些音符」被判成 Great/Good/Miss——") + print(" 说明:只给汇总判定数时,达成率取决于 Great/Good/Miss 落在哪类音符上——") print(" 区间下界 = 扣分都落在权重最高的音符上,上界 = 都落在权重最低的音符上。") + print(" 想要一个确切数值:用 --tap / --hold / --slide / --break 按类别给判定数。") + print(LINE) + + +_EXACT_KEYS = ("cp", "perfect", "perfect50", "great", "great40", "great50", "good", "miss") +_EXACT_KEY_LABEL = { + "cp": "CP", "perfect": "Perfect", "perfect50": "Perfect(50)", + "great": "Great", "great40": "Great(40%)", "great50": "Great(50%)", + "good": "Good", "miss": "Miss", +} +# 打印时用纯 ASCII,避免中文宽字符把表格撑歪 +_EXACT_CLASS_TITLE = { + "tap": "Tap/Star/Touch/EX", "hold": "Hold", "slide": "Slide", "break": "Break", +} + + +def print_exact(chart: Chart, result) -> None: + th = result.theory + print(LINE) + print("判定输入(按音符类别;Break = 绝赞;没给的部分按 CP 算)") + for cls in CLASS_ORDER: + cap = class_capacity(th, cls) + if not cap: + continue + parts = [] + for key in _EXACT_KEYS: + n = result.spec.get(cls, {}).get(key, 0) + if n: + parts.append("%s %d" % (_EXACT_KEY_LABEL[key], n)) + print(" %-24s %4d 个:%s" + % ("%s (%d)" % (_EXACT_CLASS_TITLE[cls], CLASS_WEIGHT[cls]), cap, + " · ".join(parts) if parts else "-")) + print(LINE) + print("结果") + print(" 达成率 %s (确切值)" % pct(result.achievement)) + print(" DX 分 %d / %d (%.2f%%)" + % (result.dx, th.max_dx, 100.0 * result.dx / th.max_dx if th.max_dx else 0.0)) + if th.all_perfect_score: + print(" 失分 %d / %d (%.4f%%)" + % (result.loss_total, th.all_perfect_score, + 100.0 * result.loss_total / th.all_perfect_score)) + if th.break_bonus_score: + print(" 绝赞奖励 %d / %d (%.4f%%)" + % (result.bonus_total, th.break_bonus_score, + 100.0 * result.bonus_total / th.break_bonus_score)) print(LINE) @@ -283,8 +335,9 @@ def interactive(args) -> None: th = theory_from_chart(chart) print_chart(chart, th) - mode = ask("计算模式:1) 已知判定数算达成率 2) 已知达成率倒算判定数 [1]: ", "1") - if mode.startswith("2"): + mode = ask("计算模式:1) 汇总判定数(估计值 + 区间) 2) 按音符类别给判定数(确切值)" + " 3) 由达成率倒算判定数 [1]: ", "1") + if mode.startswith("3"): while True: raw = ask("目标达成率(如 99.5 或 100.5): ") if not raw: @@ -297,6 +350,21 @@ def interactive(args) -> None: mode = ask("反算方式:1) 精确(只列真能打出该达成率的组合) 2) 区间(判定数只是估算时用) [1]: ", "1") args.mode = "range" if mode.startswith("2") else "exact" run_reverse(chart, th, target, args) + elif mode.startswith("2"): + spec = {} + for cls in CLASS_ORDER: + cap = class_capacity(th, cls) + if not cap: + continue + raw = ask(" %s(%d 个)CP/Perfect/Great/Good/Miss [%d/0/0/0/0]: " + % (CLASS_LABEL[cls], cap, cap)) + if not raw: + continue + try: + spec[cls] = parse_class_counts(raw, cls) + except ValueError as exc: + print(" [错误] %s" % exc) + run_exact(chart, th, spec, args) else: values = {} for j in JUDGMENTS: @@ -321,6 +389,83 @@ def normalize_judges(th: Theory, raw: Dict[str, Optional[int]]) -> Dict[str, int return given +_CLASS_KEYS = ("cp", "perfect", "great", "good", "miss") + + +def parse_class_counts(text: str, cls: str) -> Dict[str, int]: + """把 "CP/Perfect/Great/Good/Miss"(斜杠、逗号或空格分隔)解析成判定数字典。""" + parts = [p for p in re.split(r"[\/,\s]+", str(text).strip()) if p] + if len(parts) > len(_CLASS_KEYS): + raise ValueError("%s 最多 %d 个数(CP/Perfect/Great/Good/Miss):%s" + % (CLASS_LABEL[cls], len(_CLASS_KEYS), text)) + out: Dict[str, int] = {} + for key, raw in zip(_CLASS_KEYS, parts): + try: + n = int(raw) + except ValueError: + raise ValueError("%s 里的「%s」不是整数:%s" % (CLASS_LABEL[cls], raw, text)) + if n < 0: + raise ValueError("%s 里不能有负数:%s" % (CLASS_LABEL[cls], text)) + if n: + out[key] = n + return out + + +def build_spec(args) -> Dict[str, Dict[str, int]]: + """把 --tap/--hold/--slide/--break 以及绝赞档位参数拼成 solve_exact 的输入。""" + spec: Dict[str, Dict[str, int]] = {} + for cls in CLASS_ORDER: + text = getattr(args, cls, None) + if text: + spec[cls] = parse_class_counts(text, cls) + brk = dict(spec.get("break") or {}) + perfect_total = brk.get("perfect", 0) + great_total = brk.get("great", 0) + p50 = int(args.break_perfect50 or 0) + g40 = int(args.break_great40 or 0) + g50 = int(args.break_great50 or 0) + if p50 or g40 or g50: + if p50 > perfect_total: + raise ValueError("--break-perfect50 %d 超过绝赞 Perfect 总数 %d" + "(先在 --break 里写 CP/Perfect/Great/Good/Miss)" % (p50, perfect_total)) + if g40 + g50 > great_total: + raise ValueError("--break-great40 + --break-great50 = %d 超过绝赞 Great 总数 %d" + % (g40 + g50, great_total)) + if p50: + brk["perfect"] = perfect_total - p50 + brk["perfect50"] = p50 + if g40: + brk["great40"] = g40 + if g50: + brk["great50"] = g50 + if great_total - g40 - g50: + brk["great"] = great_total - g40 - g50 + else: + brk.pop("great", None) + brk = {k: v for k, v in brk.items() if v} + spec["break"] = brk + return spec + + +def run_exact(chart: Chart, th: Theory, spec: Dict[str, Dict[str, int]], args) -> None: + try: + result = solve_exact(th, spec) + except ValueError as exc: + die(str(exc)) + if args.json: + print(to_json(chart, th, { + "mode": "forward_exact", + "judges_by_class": result.spec, + "achievement": float(result.achievement), + "dx_score": result.dx, + "loss_total": result.loss_total, + "bonus_total": result.bonus_total, + })) + return + print_chart(chart, th) + print_exact(chart, result) + + def run_forward(chart: Chart, th: Theory, raw: Dict[str, Optional[int]], args) -> None: judges = normalize_judges(th, raw) @@ -372,9 +517,12 @@ def build_parser() -> argparse.ArgumentParser: description="舞萌DX 谱面达成率 / DX 分计算器(正向计算 + 由达成率倒算判定数)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""示例: - # 直接给谱面文件,正向计算 + # 直接给谱面文件,正向计算(汇总判定数 -> 估计值 + 区间) python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --cp 290 --perfect 10 --great 2 + # 按音符类别给判定数 -> 确切的达成率(CP/Perfect/Great/Good/Miss,可省略后面的) + python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --tap 260/0/3 --break 6 + # 给游戏路径 + musicid + 难度(难度用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER) python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --cp 300 --perfect 2 @@ -392,13 +540,30 @@ def build_parser() -> argparse.ArgumentParser: help="谱面难度,用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT " "3=MASTER 4=RE:MASTER(也可直接写名字;默认 3=MASTER)") - fw = p.add_argument_group("正向计算:已知判定数") + fw = p.add_argument_group("正向计算(汇总):只给总判定数,得到估计值 + 区间") fw.add_argument("--cp", type=int, metavar="N", help="Critical Perfect 个数") fw.add_argument("--perfect", type=int, metavar="N", help="Perfect 个数") fw.add_argument("--great", type=int, metavar="N", help="Great 个数") fw.add_argument("--good", type=int, metavar="N", help="Good 个数") fw.add_argument("--miss", type=int, metavar="N", help="Miss 个数(留空 = 总数减去其它判定)") + ex = p.add_argument_group( + "正向计算(精确):按音符类别给判定数,得到确切的达成率", + "每个类别写 CP/Perfect/Great/Good/Miss 五个数(斜杠或空格分隔,后面可以省略)," + "没给满的部分按 CP 算。") + ex.add_argument("--tap", metavar="CP/P/G/GD/M", + help="Tap/Star/Touch/EX 类(权重 500)") + ex.add_argument("--hold", metavar="CP/P/G/GD/M", help="Hold 系(权重 1000)") + ex.add_argument("--slide", metavar="CP/P/G/GD/M", help="Slide 系(权重 1500)") + ex.add_argument("--break", dest="break", metavar="CP/P/G/GD/M", + help="绝赞(权重 2500;Great/Perfect 档位见下面三个参数)") + ex.add_argument("--break-perfect50", type=int, metavar="N", + help="绝赞 Perfect 里奖励分只有 50 的个数(Perfect2nd)") + ex.add_argument("--break-great40", type=int, metavar="N", + help="绝赞 Great 里失分 40%% 的个数(Great2nd)") + ex.add_argument("--break-great50", type=int, metavar="N", + help="绝赞 Great 里失分 50%% 的个数(Great3rd)") + rv = p.add_argument_group("反向计算:已知达成率") rv.add_argument("--achievement", metavar="A", help="目标达成率(如 99.5 / 100.5)") rv.add_argument("--mode", choices=("exact", "range"), default="exact", @@ -458,11 +623,26 @@ def main(argv: Optional[List[str]] = None) -> int: run_reverse(chart, th, target, args) return 0 + class_flags = {"tap": args.tap, "hold": args.hold, "slide": args.slide, + "break": getattr(args, "break", None), + "perfect50": args.break_perfect50, + "great40": args.break_great40, "great50": args.break_great50} + if any(v for v in class_flags.values()): + if any(v is not None for v in (args.cp, args.perfect, args.great, args.good, args.miss)): + die("汇总判定数(--cp/--perfect/--great/--good/--miss)" + "和按类别判定数(--tap/--hold/--slide/--break)只能给一种") + try: + spec = build_spec(args) + except ValueError as exc: + die(str(exc)) + run_exact(chart, th, spec, args) + return 0 + raw = {"cp": args.cp, "perfect": args.perfect, "great": args.great, "good": args.good, "miss": args.miss} if all(v is None for v in raw.values()): - die("请给判定数(--cp/--perfect/--great/--good),或用 --achievement 做反向计算;" - "两个都不给则进入交互模式(需去掉其它参数)") + die("请给判定数:汇总用 --cp/--perfect/--great/--good," + "精确用 --tap/--hold/--slide/--break;或用 --achievement 做反向计算") run_forward(chart, th, raw, args) return 0 diff --git a/tools/chart_achievement/score.py b/tools/chart_achievement/score.py index 4375380..1c31512 100644 --- a/tools/chart_achievement/score.py +++ b/tools/chart_achievement/score.py @@ -427,6 +427,109 @@ def solve_forward(th: Theory, judges: Dict[str, int]) -> ForwardResult: ) +# ------------------------------------------------------------------ 正向:按音符类别精确计算 +# 达成率是按「音符权重」加权的,只给汇总判定数时只能得到区间; +# 把判定数按权重类别分开给(绝赞单独一列),就能算出确切的数值。 +CLASS_ORDER = ("tap", "hold", "slide", "break") +CLASS_LABEL = { + "tap": "Tap/Star/Touch/EX", + "hold": "Hold 系", + "slide": "Slide 系", + "break": "绝赞(Break 系)", +} +CLASS_WEIGHT = {"tap": 500, "hold": 1000, "slide": 1500, "break": 2500} +CLASS_GROUPS = { + "tap": (GROUP_TAP, GROUP_EX), + "hold": (GROUP_HOLD,), + "slide": (GROUP_SLIDE,), + "break": (GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX_BREAK), +} +# 每类里每个判定键的 (失分, 奖励分);绝赞的 Great / Perfect 有多个档位 +CLASS_OPTIONS = { + "tap": {"cp": (0, 0), "perfect": (0, 0), "great": (100, 0), + "good": (250, 0), "miss": (500, 0)}, + "hold": {"cp": (0, 0), "perfect": (0, 0), "great": (200, 0), + "good": (500, 0), "miss": (1000, 0)}, + "slide": {"cp": (0, 0), "perfect": (0, 0), "great": (300, 0), + "good": (750, 0), "miss": (1500, 0)}, + "break": {"cp": (0, 100), "perfect": (0, 75), "perfect50": (0, 50), + "great": (500, 40), "great40": (1000, 40), "great50": (1250, 40), + "good": (1500, 30), "miss": (2500, 0)}, +} +_JUDGE_OF_KEY = { + "cp": "cp", "perfect": "perfect", "perfect50": "perfect", + "great": "great", "great40": "great", "great50": "great", + "good": "good", "miss": "miss", +} + + +def class_capacity(th: Theory, cls: str) -> int: + """这一类音符在谱面里有多少个。""" + return sum(th.counts.get(g, 0) for g in CLASS_GROUPS[cls]) + + +@dataclass +class ExactResult: + theory: Theory + spec: Dict[str, Dict[str, int]] # 实际参与计算的判定数(已补足 CP) + achievement: Fraction + dx: int + loss_total: int + bonus_total: int + breakdown: Dict[str, Tuple[int, int, int]] # 类别 -> (音符数, 失分, 奖励分) + + @property + def score(self) -> int: + return self.theory.all_perfect_score - self.loss_total + + +def solve_exact(th: Theory, spec: Dict[str, Dict[str, int]]) -> ExactResult: + """按音符类别给判定数,算出**确切**的达成率与 DX 分。 + + spec: {"tap": {"cp": 250, "great": 2}, "break": {"cp": 4, "great": 1}, ...} + 每类没给满的部分按 CP 补足(非绝赞 CP 与 Perfect 同分;绝赞按 CP 计奖励 100)。 + """ + filled: Dict[str, Dict[str, int]] = {} + loss_total = 0 + bonus_total = 0 + dx = 0 + breakdown: Dict[str, Tuple[int, int, int]] = {} + for cls in CLASS_ORDER: + cap = class_capacity(th, cls) + given = {k: int(v) for k, v in (spec.get(cls) or {}).items() if int(v)} + for key in given: + if key not in CLASS_OPTIONS[cls]: + raise ValueError("「%s」没有「%s」这个判定" % (CLASS_LABEL[cls], key)) + used = sum(given.values()) + if used > cap: + raise ValueError("%s 只有 %d 个音符,但给了 %d 个判定" + % (CLASS_LABEL[cls], cap, used)) + if cap > used: + given["cp"] = given.get("cp", 0) + (cap - used) + filled[cls] = given + cls_loss = 0 + cls_bonus = 0 + for key, n in given.items(): + dl, db = CLASS_OPTIONS[cls][key] + cls_loss += dl * n + cls_bonus += db * n + dx += DX_PER[_JUDGE_OF_KEY[key]] * n + loss_total += cls_loss + bonus_total += cls_bonus + breakdown[cls] = (cap, cls_loss, cls_bonus) + + w = th.all_perfect_score + if w <= 0: + raise ValueError("谱面里没有计分音符") + achievement = Fraction(100) - Fraction(100 * loss_total, w) + if th.break_bonus_score: + achievement += Fraction(bonus_total, th.break_bonus_score) + return ExactResult( + theory=th, spec=filled, achievement=achievement, dx=dx, + loss_total=loss_total, bonus_total=bonus_total, breakdown=breakdown, + ) + + # ------------------------------------------------------------------ 反向:工具函数 def _judges_for(th: Theory, miss: int, good: int, great: int, cp: int, perfect: int) -> Dict[str, int]: