chart_achievement: 正向支持按音符类别给判定数(绝赞单列),算出确切达成率

This commit is contained in:
2026-10-17 17:43:00 +08:00
parent 25ac522391
commit f6e84cf297
3 changed files with 353 additions and 25 deletions
+55 -10
View File
@@ -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` 留空时自动补足
每个类别写 `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`。
这时达成率取决于这些 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 个游戏谱面实测一致)。
+194 -14
View File
@@ -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
+103
View File
@@ -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]: