换算率标定批:conversion 三行投值 6/8/1 + 护甲断链接线 + 判定端表值驱动(用户指令 2026-09-23)
- meta.tres conversion 三行 0→6/8/1(OFAT 注入档配对读数实测每级带平移 +6.5±0.7/+8.4±0.9/+1.4±0.4 点取整)
- 修复护甲断链:begin_battle hero_armor_class 参数零消费,_defender_ac 主角分支接线(缺参/-1=表值兜底,白板逐位不变)
- level_runner.start_level / run_level 增注入尾参(缺省 {} 零行为变更);verifier_batch 增 inject 档/levels 裁剪/armor_first 花费序/campaign 行 spend 标签
- balance_verifier.py:meta.tres 启动解析(表值锚配置推导化)+ 带平移判定 + ④标定段(配对差/逆方差合并/建议投值)
- 元进度 v1.4(换算率节回填+P1 销项)/ 06 删条 / registry 注记与变更记录
- 冒烟 553 项 0 失败;读数报告 production/qa/conversion-calibration/calibration.raw.md
This commit is contained in:
1 parent
3309aae2a3
commit
f0aca09bd0
13 files changed
+3943
-117
No files matched your search
+351
-82
@@ -3,34 +3,43 @@
|
||||
r"""数值验证工具(判定端)——自动化评判 + 元进度爬升测量,替代「亲手试玩再反馈」循环。
|
||||
|
||||
三段采集(数据源 = smoke/verifier_batch.gd,本文件驱动它并做全部判定):
|
||||
level 段 直入口单关基线(零加成)——验收带判定
|
||||
level 段 直入口单关基线(零加成)——验收带判定;换算率标定批起支持 inject 注入档
|
||||
(永久强化 flat OFAT——同种子流配对读数,通关率差 = 带平移量实测)
|
||||
adventure 段 全冒险连探基线(零永久强化)——止步分布 + 败退节点归因(层/节点/敌人)
|
||||
campaign 段 营地循环战役(隔离档真实元进度账本:注入→冒险→收束入账→买锻造)——爬升曲线
|
||||
campaign 段 营地循环战役(隔离档真实元进度账本:注入→冒险→收束入账→买锻造)——爬升曲线;
|
||||
换算率标定批起行带 spend 花费序标签(多轨迹合并采集按序分组判定)
|
||||
|
||||
评判标准(溯源):
|
||||
① 直入口基线通关率带(分带批 2026-09-20 用户拍板,难度曲线与Boss v0.16):
|
||||
第一层 普通 [35,55]% · 第二层 普通 [25,45]% · 完美档 [35,65]%(两关同带);第三层带未定——只测不判。
|
||||
带平移(元进度定稿批口径):注入/锻造状态按 meta.tres 换算率表折算平移量后套用——
|
||||
判读口径不变、带随解锁平移(表值驱动,本文件解析 meta.tres 取数,零硬编码)。
|
||||
② 元循环目标(用户提案 2026-09-22,数字待确认):累计通过 5 次第 k 层的冒险后,第 k+1 层的
|
||||
条件通关率(滑窗)应进入该层验收带——「5 次通过第一层的冒险后能过第二层;5 次通过第二层后能过第三层」。
|
||||
第三层带未定,只给爬升曲线与首次读数,不出判定。
|
||||
③ 魔法道具面:道具工坊六件按通关次数解锁(阈值 1/1/2/2/3/3),局内效果未实装(归 Alpha 魔法道具批)——
|
||||
本工具只报解锁时点,不入判定。
|
||||
④ 换算率标定(2026-09-23 批新增):OFAT 注入档 vs 白板的配对通关率差 → 每级平移量点估计±SE
|
||||
→ 与 meta.tres 现行表值对照、给建议投值。护甲行强条目:读数必须按解锁后参数实测(本段即重测面),
|
||||
不得用基线外推。
|
||||
|
||||
表值硬编码溯源(单一事实源仍在 .tres / GDD,变更须同步):
|
||||
· 锻造三档 flat(meta.tres):生命 +3 / 攻击 +1 / 护甲 +1
|
||||
表值溯源(单一事实源 = meta.tres,本文件启动时解析——解析失败即 FAIL,不做静默兜底):
|
||||
· 锻造三档 flat(forge_products.flat_value)· 换算率三行(conversion.per_level_shift_pct)
|
||||
· 钥石入账(adventure.tres + meta.tres):每层通关 10 + Boss 首杀/重复杀 5/1 · 8/2 · 12/3
|
||||
· 工坊阈值(meta.tres):显影尘1 · 工兵令1 · 探宝罗盘2 · 探巢摆钟2 · 地穴全图卷3 · 爆破工兵包3
|
||||
· 工坊阈值(meta.tres workshop_items):1/1/2/2/3/3(本文件只做中文名映射,不消费数值)
|
||||
|
||||
用法:
|
||||
python tools/balance_verifier.py # 全段默认规模(约 20–35 分钟)
|
||||
python tools/balance_verifier.py --quick # 小规模快跑(约 5–10 分钟,读数精度低)
|
||||
python tools/balance_verifier.py --n 100 --na 60 --k 60 --seed0 9000 --spend atk_first
|
||||
python tools/balance_verifier.py --only level --levels 1,2 --inject max_hp:3 --n 100
|
||||
python tools/balance_verifier.py --from-raw <raw.jsonl> # 免重跑,对既有采集重新判定/出报告
|
||||
输出:控制台判定结论 + production/qa/balance-verifier-<时间戳>.md(报告)+ 同名 .raw.jsonl(原始逐局数据)。
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -56,9 +65,93 @@ CAMPAIGN_WINDOW = 10 # 检查点滑窗局数(条件通关率的窗口
|
||||
CAMPAIGN_MIN_ATTEMPTS = 5 # 滑窗内少于此局数 = 数据不足(不出判定)
|
||||
LEVEL_CN = {1: "第一层", 2: "第二层", 3: "第三层"}
|
||||
NODE_TYPE_CN = {"battle": "战斗", "boss": "Boss", "rest": "恢复", "reward": "奖励", "shop": "商店房"}
|
||||
FORGE_FLAT = {"max_hp": 3, "atk": 1, "armor_class": 1} # meta.tres 锻造三档每级 flat 值
|
||||
OVERRIDE_ATTRS = ["max_hp", "atk", "armor_class"] # 注入契约三键(词表同 MetaTable.FORGE_ATTRS)
|
||||
WORKSHOP_CN = {"probe_dust": "显影尘", "sapper_order": "工兵令", "loot_compass": "探宝罗盘",
|
||||
"nest_pendulum": "探巢摆钟", "catacomb_scroll": "地穴全图卷", "blast_kit": "爆破工兵包"}
|
||||
SPEND_CN = {"hp_first": "生命优先", "atk_first": "攻击优先", "armor_first": "护甲优先"}
|
||||
|
||||
# ---- meta.tres 表值(启动时解析——表值锚配置推导化:判定阈值随配置表走,零硬编码) ----
|
||||
META_TRES = SLICE / "data" / "tables" / "meta.tres"
|
||||
FORGE_FLAT = {} # attr → flat_value(锻造三档每级 flat)
|
||||
CONVERSION = {} # attr → per_level_shift_pct(换算率三行)
|
||||
|
||||
|
||||
def load_meta_table():
|
||||
"""解析 meta.tres 的 forge_products/conversion 行族(; 注释行剥除 + 括号配平 + 键值扫描)。
|
||||
|
||||
解析失败直接 FAIL——静默兜底会复生「过期硬编码常量」bug 类(表值锚配置推导化禁)。
|
||||
"""
|
||||
try:
|
||||
text = META_TRES.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
die(f"meta.tres 不可读:{e}")
|
||||
text = "\n".join(ln for ln in text.splitlines() if not ln.strip().startswith(";"))
|
||||
for family in ("forge_products", "conversion"):
|
||||
rows = _parse_tres_rows(text, family)
|
||||
if not rows:
|
||||
die(f"meta.tres 行族解析为空:{family}(表结构或解析器失配——人工排查)")
|
||||
for row in rows:
|
||||
attr = str(row.get("attr", ""))
|
||||
if attr not in OVERRIDE_ATTRS:
|
||||
die(f"meta.tres {family} attr 出域 {OVERRIDE_ATTRS}: {attr}")
|
||||
if family == "forge_products":
|
||||
FORGE_FLAT[attr] = int(row.get("flat_value", 0))
|
||||
else:
|
||||
CONVERSION[attr] = int(row.get("per_level_shift_pct", 0))
|
||||
if sorted(FORGE_FLAT) != sorted(OVERRIDE_ATTRS) or sorted(CONVERSION) != sorted(OVERRIDE_ATTRS):
|
||||
die(f"meta.tres 三键覆盖不全:forge={FORGE_FLAT} conversion={CONVERSION}")
|
||||
|
||||
|
||||
def _parse_tres_rows(text, family):
|
||||
"""取 family = [ {...}, ... ] 的行字典列表(.tres 人类可读格式——非严格 YAML,同仓惯例不走上解析器)。"""
|
||||
marker = family + " = ["
|
||||
start = text.find(marker)
|
||||
if start < 0:
|
||||
return []
|
||||
i = start + len(marker) - 1 # 指向 '['
|
||||
depth, j = 0, i
|
||||
while j < len(text):
|
||||
if text[j] == "[":
|
||||
depth += 1
|
||||
elif text[j] == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
j += 1
|
||||
body = text[i:j + 1]
|
||||
rows, depth, row_start = [], 0, None
|
||||
for idx, ch in enumerate(body):
|
||||
if ch == "{":
|
||||
if depth == 0:
|
||||
row_start = idx
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0 and row_start is not None:
|
||||
block = body[row_start:idx + 1]
|
||||
row = {}
|
||||
for key, val in re.findall(r'"(\w+)":\s*(-?\d+|"[^"]*")', block):
|
||||
row[key] = int(val) if val.lstrip("-").isdigit() else val.strip('"')
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def band_shift_for(override):
|
||||
"""注入/锻造 flat 状态 → 通关率带平移量(百分点)= Σ flat÷每级flat×每级平移(表值驱动)。"""
|
||||
shift = 0.0
|
||||
for attr in OVERRIDE_ATTRS:
|
||||
flat = int((override or {}).get(attr, 0))
|
||||
per_flat = FORGE_FLAT.get(attr, 1)
|
||||
if per_flat > 0:
|
||||
shift += flat / per_flat * CONVERSION.get(attr, 0)
|
||||
return shift
|
||||
|
||||
|
||||
def shifted_band(band, shift):
|
||||
"""验收带随平移量平移(clamp [0,100];band=None = 带未定不平移)。"""
|
||||
if band is None:
|
||||
return None
|
||||
return (max(0.0, band[0] + shift), min(100.0, band[1] + shift))
|
||||
|
||||
|
||||
def die(msg):
|
||||
@@ -82,7 +175,8 @@ def collect(args):
|
||||
segs = args.only.split(",")
|
||||
cmd = [args.godot, "--headless", "--path", str(SLICE), "--script", SCRIPT, "--",
|
||||
f"n={args.n}", f"na={args.na}", f"k={args.k}", f"seed0={args.seed0}",
|
||||
f"only={args.only}", f"spend={args.spend}"]
|
||||
f"only={args.only}", f"spend={args.spend}",
|
||||
f"inject={args.inject}", f"levels={args.levels}"]
|
||||
print(f"[数值验证] 采集启动({' · '.join(segs)},约 {args.est_min} 分钟)…")
|
||||
t0 = time.time()
|
||||
try:
|
||||
@@ -163,14 +257,17 @@ def node_label(defeat):
|
||||
|
||||
# ---------------------------------------------------------------- 分段分析
|
||||
|
||||
def analyze_level(rows):
|
||||
"""直入口基线:按(画像, 层)聚合计数。"""
|
||||
def analyze_level(rows, baseline_only=False):
|
||||
"""直入口基线:按(画像, 层, 注入档)聚合计数。baseline_only=True 只留白板档(① 基线判定面)。"""
|
||||
agg = {}
|
||||
for r in rows:
|
||||
if r.get("seg") != "level":
|
||||
continue
|
||||
key = (r["policy"], int(r["level"]))
|
||||
a = agg.setdefault(key, {"n": 0, "wins": 0, "deaths": {}})
|
||||
tag = r.get("inject_tag", "白板")
|
||||
if baseline_only and tag != "白板":
|
||||
continue
|
||||
key = (r["policy"], int(r["level"]), tag)
|
||||
a = agg.setdefault(key, {"n": 0, "wins": 0, "deaths": {}, "override": r.get("override") or {}})
|
||||
a["n"] += 1
|
||||
if r.get("result") == "victory":
|
||||
a["wins"] += 1
|
||||
@@ -221,13 +318,25 @@ def window_rate(series, end_idx, window):
|
||||
return 100.0 * sum(1 for _, ok, _ in chunk if ok) / len(chunk), len(chunk)
|
||||
|
||||
|
||||
def analyze_campaign(rows):
|
||||
def campaign_spend_tags(rows):
|
||||
"""战役段花费序标签清单(按出现序;旧采集无 spend 字段 = 单策略,归 hp_first 桶)。"""
|
||||
tags = []
|
||||
for r in rows:
|
||||
if r.get("seg") != "campaign":
|
||||
continue
|
||||
t = r.get("spend", "hp_first")
|
||||
if t not in tags:
|
||||
tags.append(t)
|
||||
return tags
|
||||
|
||||
|
||||
def analyze_campaign(rows, spend_tag="hp_first"):
|
||||
out = {}
|
||||
for policy in ("normal", "perfect"):
|
||||
runs = [r for r in rows if r.get("seg") == "campaign" and r.get("policy") == policy
|
||||
and not r.get("anomaly")]
|
||||
and r.get("spend", "hp_first") == spend_tag and not r.get("anomaly")]
|
||||
anomalies = [r for r in rows if r.get("seg") == "campaign" and r.get("policy") == policy
|
||||
and r.get("anomaly")]
|
||||
and r.get("spend", "hp_first") == spend_tag and r.get("anomaly")]
|
||||
res = {"n": len(runs), "anomalies": len(anomalies), "runs": runs}
|
||||
if not runs:
|
||||
out[policy] = res
|
||||
@@ -257,9 +366,11 @@ def analyze_campaign(rows):
|
||||
rate = 100.0 * sum(1 for _, ok, _ in chunk if ok) / len(chunk)
|
||||
cp["rate"] = rate
|
||||
cp["attempts"] = len(chunk)
|
||||
band = LEVEL_BANDS.get((policy, k + 1))
|
||||
ov = chunk[0][2].get("override", {})
|
||||
cp["forge_at"] = ov
|
||||
cp["band_shift"] = band_shift_for(ov)
|
||||
band = shifted_band(LEVEL_BANDS.get((policy, k + 1)), cp["band_shift"])
|
||||
cp["verdict"], cp["pass"] = judge_band(rate, band)
|
||||
cp["forge_at"] = chunk[0][2].get("override", {})
|
||||
checkpoints.append(cp)
|
||||
res["checkpoints"] = checkpoints
|
||||
# 工坊解锁时点(魔法道具面——只报不判)
|
||||
@@ -277,6 +388,81 @@ def analyze_campaign(rows):
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 换算率标定(④)
|
||||
|
||||
def ofat_cells(rows):
|
||||
"""OFAT 分组:白板基线 {(policy, level): [rows]} 与单轴注入档 {attr: {flat: {(policy, level): [rows]}}}。"""
|
||||
base, cells = {}, {}
|
||||
for r in rows:
|
||||
if r.get("seg") != "level":
|
||||
continue
|
||||
ov = r.get("override") or {}
|
||||
nonzero = [a for a in OVERRIDE_ATTRS if int(ov.get(a, 0)) != 0]
|
||||
key = (r["policy"], int(r["level"]))
|
||||
if not nonzero:
|
||||
base.setdefault(key, []).append(r)
|
||||
elif len(nonzero) == 1:
|
||||
a = nonzero[0]
|
||||
cells.setdefault(a, {}).setdefault(int(ov[a]), {}).setdefault(key, []).append(r)
|
||||
return base, cells
|
||||
|
||||
|
||||
def paired_rate_delta(base_rows, cell_rows):
|
||||
"""同种子配对通关率差(百分点)与配对 SE——跨档同序种子流复用,配对消方差。"""
|
||||
bmap = {int(r["seed"]): 1 if r.get("result") == "victory" else 0 for r in base_rows}
|
||||
diffs = []
|
||||
for r in cell_rows:
|
||||
s = int(r["seed"])
|
||||
if s in bmap:
|
||||
diffs.append((1 if r.get("result") == "victory" else 0) - bmap[s])
|
||||
n = len(diffs)
|
||||
if n == 0:
|
||||
return None
|
||||
m_raw = sum(diffs) / n
|
||||
se = None
|
||||
if n > 1:
|
||||
var = sum((d - m_raw) ** 2 for d in diffs) / (n - 1)
|
||||
se = math.sqrt(var / n) * 100.0
|
||||
return m_raw * 100.0, se, n
|
||||
|
||||
|
||||
def calibration_estimate(rows):
|
||||
"""标定汇总:每属性 → 逐档明细 + 合并每级平移量(逆方差加权)+ 现行表值对照 + 建议投值。"""
|
||||
base, cells = ofat_cells(rows)
|
||||
out = {}
|
||||
for attr, flats in cells.items():
|
||||
per_flat = max(1, FORGE_FLAT.get(attr, 1))
|
||||
detail, pts = [], []
|
||||
for flat in sorted(flats):
|
||||
lv = flat / per_flat
|
||||
for key in sorted(flats[flat]):
|
||||
d = paired_rate_delta(base.get(key, []), flats[flat][key])
|
||||
if d is None:
|
||||
continue
|
||||
mean_pp, se_pp, n = d
|
||||
per_lvl = mean_pp / lv
|
||||
per_lvl_se = (se_pp / lv) if se_pp is not None else None
|
||||
detail.append({"flat": flat, "key": key, "n": n, "mean_pp": mean_pp,
|
||||
"se_pp": se_pp, "per_lvl": per_lvl, "per_lvl_se": per_lvl_se})
|
||||
if per_lvl_se is not None and per_lvl_se > 0:
|
||||
pts.append((per_lvl, 1.0 / per_lvl_se ** 2))
|
||||
if pts:
|
||||
pooled = sum(p * w for p, w in pts) / sum(w for _, w in pts)
|
||||
pooled_se = math.sqrt(1.0 / sum(w for _, w in pts))
|
||||
elif detail:
|
||||
pooled = sum(d["per_lvl"] for d in detail) / len(detail)
|
||||
pooled_se = None
|
||||
else:
|
||||
pooled, pooled_se = None, None
|
||||
sug = None if pooled is None else max(0, min(100, round(pooled)))
|
||||
out[attr] = {"detail": detail, "pooled": pooled, "pooled_se": pooled_se,
|
||||
"table_val": CONVERSION.get(attr, 0), "suggest": sug}
|
||||
return out
|
||||
|
||||
|
||||
ATTR_CN = {"max_hp": "生命上限", "atk": "基础攻击", "armor_class": "护甲等级"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 报告
|
||||
|
||||
def fmt_table(header, rows):
|
||||
@@ -294,24 +480,27 @@ def report(args, rows, meta):
|
||||
A = lines.append
|
||||
A(f"# 数值验证报告({meta['ts']})")
|
||||
A("")
|
||||
A(f"- 采集规模:直入口每格 {args.n} 局 · 基线冒险 {args.na} 局/画像 · 战役 {args.k} 局/画像"
|
||||
A(f"- 采集规模:直入口每格 {args.n} 局(层集 {args.levels} · 注入档 {args.inject or '白板'})"
|
||||
f" · 基线冒险 {args.na} 局/画像 · 战役 {args.k} 局/画像"
|
||||
f" · 种子 {args.seed0} 起逐局独立 · 锻造花费策略 = {args.spend}")
|
||||
A(f"- 切片版本:{meta['commit']} · 表状态:判定带与表值溯源见 tools/balance_verifier.py 文件头")
|
||||
A(f"- 切片版本:{meta['commit']} · 换算率表值(meta.tres 启动解析):"
|
||||
+ " · ".join(f"{ATTR_CN[a]}每级平移{CONVERSION.get(a, 0)}点" for a in OVERRIDE_ATTRS))
|
||||
A("")
|
||||
|
||||
# ① 直入口基线
|
||||
A("## ① 直入口基线通关率(验收带判定)")
|
||||
# ① 直入口基线(只留白板档——注入档读数归 ④ 标定段)
|
||||
A("## ① 直入口基线通关率(验收带判定,白板档)")
|
||||
A("")
|
||||
lvl = analyze_level(rows)
|
||||
lvl = analyze_level(rows, baseline_only=True)
|
||||
tbl = []
|
||||
for (policy, li), a in sorted(lvl.items()):
|
||||
for key, a in sorted(lvl.items()):
|
||||
policy, li = key[0], key[1]
|
||||
rate = 100.0 * a["wins"] / max(1, a["n"])
|
||||
ci = 1.96 * math.sqrt((rate / 100) * (1 - rate / 100) / max(1, a["n"])) * 100
|
||||
band = LEVEL_BANDS.get((policy, li))
|
||||
verdict, _ = judge_band(rate, band)
|
||||
tbl.append([f"{LEVEL_CN[li]}", "普通档" if policy == "normal" else "完美档",
|
||||
f"{a['wins']}/{a['n']}", f"{rate:.1f}% ±{ci:.1f}",
|
||||
f"[{band[0]},{band[1]}]" if band else "未定", verdict])
|
||||
f"[{band[0]:g},{band[1]:g}]" if band else "未定", verdict])
|
||||
A(fmt_table(["层", "画像", "通关/局数", "通关率±95%CI", "验收带", "判定"], tbl))
|
||||
A("")
|
||||
|
||||
@@ -321,6 +510,8 @@ def report(args, rows, meta):
|
||||
adv = analyze_adventure(rows)
|
||||
for policy in ("normal", "perfect"):
|
||||
d = adv[policy]
|
||||
if d["n"] == 0:
|
||||
continue
|
||||
cn = "普通档" if policy == "normal" else "完美档"
|
||||
A(f"### {cn}({d['n']} 局)")
|
||||
A("")
|
||||
@@ -333,65 +524,125 @@ def report(args, rows, meta):
|
||||
A(f" - {lb} ×{c}")
|
||||
A("")
|
||||
|
||||
# ③ 元循环爬升
|
||||
# ③ 元循环爬升(多花费序轨迹分组——换算率标定批起 campaign 行带 spend 标签)
|
||||
A("## ③ 营地循环爬升(用户判据④:5 次通关 → 下一层可过)")
|
||||
A("")
|
||||
camp = analyze_campaign(rows)
|
||||
for policy in ("normal", "perfect"):
|
||||
d = camp[policy]
|
||||
cn = "普通档" if policy == "normal" else "完美档"
|
||||
A(f"### {cn}({d['n']} 局战役)")
|
||||
A("")
|
||||
if not d["runs"]:
|
||||
A("- 无数据")
|
||||
for spend in campaign_spend_tags(rows):
|
||||
camp = analyze_campaign(rows, spend)
|
||||
spend_cn = SPEND_CN.get(spend, spend)
|
||||
for policy in ("normal", "perfect"):
|
||||
d = camp[policy]
|
||||
cn = "普通档" if policy == "normal" else "完美档"
|
||||
A(f"### {cn} · {spend_cn}({d['n']} 局战役)")
|
||||
A("")
|
||||
continue
|
||||
A(f"- 钥石:全程入账 {d['income_total']} · 终态余额 {d['keystone_final']}"
|
||||
f" · 锻造终态 = 生命+{d['forge_final'].get('max_hp', 0) * FORGE_FLAT['max_hp']}"
|
||||
f"/攻击+{d['forge_final'].get('atk', 0) * FORGE_FLAT['atk']}"
|
||||
f"/护甲+{d['forge_final'].get('armor_class', 0) * FORGE_FLAT['armor_class']}")
|
||||
if d["unlocks"]:
|
||||
A("- 道具工坊解锁时点(魔法道具面,局内效果未实装只报不判):" +
|
||||
"、".join(f"{k} 第 {v} 局" for k, v in sorted(d["unlocks"].items(), key=lambda x: x[1])))
|
||||
else:
|
||||
A("- 道具工坊解锁时点:无(未达阈值)")
|
||||
if d.get("anomalies"):
|
||||
A(f"- ⚠ 编排异常局 {d['anomalies']} 局(未结算零入账——timeout/advance_failed,须排查)")
|
||||
if not d["runs"]:
|
||||
A("- 无数据")
|
||||
A("")
|
||||
continue
|
||||
A(f"- 钥石:全程入账 {d['income_total']} · 终态余额 {d['keystone_final']}"
|
||||
f" · 锻造终态 = 生命+{d['forge_final'].get('max_hp', 0) * FORGE_FLAT.get('max_hp', 1)}"
|
||||
f"/攻击+{d['forge_final'].get('atk', 0) * FORGE_FLAT.get('atk', 1)}"
|
||||
f"/护甲+{d['forge_final'].get('armor_class', 0) * FORGE_FLAT.get('armor_class', 1)}")
|
||||
if d["unlocks"]:
|
||||
A("- 道具工坊解锁时点(魔法道具面,局内效果未实装只报不判):" +
|
||||
"、".join(f"{k} 第 {v} 局" for k, v in sorted(d["unlocks"].items(), key=lambda x: x[1])))
|
||||
else:
|
||||
A("- 道具工坊解锁时点:无(未达阈值)")
|
||||
if d.get("anomalies"):
|
||||
A(f"- ⚠ 编排异常局 {d['anomalies']} 局(未结算零入账——timeout/advance_failed,须排查)")
|
||||
A("")
|
||||
for cp in d.get("checkpoints", []):
|
||||
A(f"- 判据:累计通过{LEVEL_CN[cp['level']]} {CAMPAIGN_CLEARS_TARGET} 次后,{LEVEL_CN[cp['level'] + 1]}"
|
||||
f"条件通关率(滑窗 {CAMPAIGN_WINDOW} 局)——{cp['verdict']}"
|
||||
+ (f"(触发时注入 = 生命+{cp['forge_at'].get('max_hp', 0)}/攻击+{cp['forge_at'].get('atk', 0)}"
|
||||
f"/护甲+{cp['forge_at'].get('armor_class', 0)},表值平移 {cp.get('band_shift', 0.0):+.1f} 点)"
|
||||
if "forge_at" in cp else ""))
|
||||
A("")
|
||||
tbl = []
|
||||
series = d.get("series", {})
|
||||
for r in d["runs"][::max(1, len(d["runs"]) // 20)]:
|
||||
cells = [f"第{r['run_no']}局",
|
||||
f"血{r['override'].get('max_hp', 0)}/攻{r['override'].get('atk', 0)}/甲{r['override'].get('armor_class', 0)}",
|
||||
str(r.get("keystone_after", "-"))]
|
||||
for k in (1, 2, 3):
|
||||
s = series.get(k, [])
|
||||
idx = next((i for i, (rn, _, _) in enumerate(s) if rn == r["run_no"]), None)
|
||||
if idx is None:
|
||||
cells.append("—")
|
||||
else:
|
||||
rate, cnt = window_rate(s, idx, CAMPAIGN_WINDOW)
|
||||
cells.append(f"{rate:.0f}%(n={cnt})" if rate is not None else "—")
|
||||
sa = int(r.get("stopped_at", -1))
|
||||
cells.append("通关" if sa == 0 else (f"止步第{sa}层" if sa > 0 else str(r.get("result", "?"))))
|
||||
tbl.append(cells)
|
||||
A(fmt_table(["局", "注入(flat)", "钥石", "第1层率(窗)", "第2层率(窗)", "第3层率(窗)", "终局"], tbl))
|
||||
A("")
|
||||
|
||||
# ④ 换算率标定(OFAT 注入档配对读数——护甲行强条目重测面)
|
||||
A("## ④ 换算率标定(OFAT 注入档 vs 白板,同种子配对)")
|
||||
A("")
|
||||
est = calibration_estimate(rows)
|
||||
if not any(e["detail"] for e in est.values()):
|
||||
A("- 无注入档数据(本采集未带 inject 档——标定面跳过)")
|
||||
A("")
|
||||
for cp in d.get("checkpoints", []):
|
||||
A(f"- 判据:累计通过{LEVEL_CN[cp['level']]} {CAMPAIGN_CLEARS_TARGET} 次后,{LEVEL_CN[cp['level'] + 1]}"
|
||||
f"条件通关率(滑窗 {CAMPAIGN_WINDOW} 局)——{cp['verdict']}"
|
||||
+ (f"(触发时注入 = 生命+{cp['forge_at'].get('max_hp', 0)}/攻击+{cp['forge_at'].get('atk', 0)}"
|
||||
f"/护甲+{cp['forge_at'].get('armor_class', 0)})" if "forge_at" in cp else ""))
|
||||
A("")
|
||||
tbl = []
|
||||
series = d.get("series", {})
|
||||
runs_by_no = {r["run_no"]: r for r in d["runs"]}
|
||||
for r in d["runs"][::max(1, len(d["runs"]) // 20)]:
|
||||
cells = [f"第{r['run_no']}局",
|
||||
f"血{r['override'].get('max_hp', 0)}/攻{r['override'].get('atk', 0)}/甲{r['override'].get('armor_class', 0)}",
|
||||
str(r.get("keystone_after", "-"))]
|
||||
for k in (1, 2, 3):
|
||||
s = series.get(k, [])
|
||||
idx = next((i for i, (rn, _, _) in enumerate(s) if rn == r["run_no"]), None)
|
||||
if idx is None:
|
||||
cells.append("—")
|
||||
else:
|
||||
any_detail = False
|
||||
for attr in OVERRIDE_ATTRS:
|
||||
e = est.get(attr)
|
||||
if not e or not e["detail"]:
|
||||
continue
|
||||
any_detail = True
|
||||
per_flat = max(1, FORGE_FLAT.get(attr, 1))
|
||||
A(f"### {ATTR_CN[attr]}(每级 flat +{per_flat})")
|
||||
A("")
|
||||
tbl = []
|
||||
for d in e["detail"]:
|
||||
policy, li = d["key"]
|
||||
tbl.append([
|
||||
f"+{d['flat']}({d['flat'] // per_flat} 级)",
|
||||
"普通档" if policy == "normal" else "完美档", f"{LEVEL_CN[li]}",
|
||||
str(d["n"]),
|
||||
f"{d['mean_pp']:+.1f}" + (f" ±{d['se_pp']:.1f}" if d["se_pp"] is not None else ""),
|
||||
f"{d['per_lvl']:+.1f}" + (f" ±{d['per_lvl_se']:.1f}" if d["per_lvl_se"] is not None else ""),
|
||||
])
|
||||
A(fmt_table(["注入档", "画像", "层", "配对局数", "通关率差(点)", "折每级(点/级)"], tbl))
|
||||
A("")
|
||||
if any_detail:
|
||||
A("### 三行汇总与建议投值")
|
||||
A("")
|
||||
tbl = []
|
||||
for attr in OVERRIDE_ATTRS:
|
||||
e = est.get(attr)
|
||||
if not e or (not e["detail"] and e["pooled"] is None):
|
||||
tbl.append([ATTR_CN[attr], str(e["table_val"] if e else CONVERSION.get(attr, 0)),
|
||||
"—", "—", "—"])
|
||||
continue
|
||||
pooled_s = "—" if e["pooled"] is None else f"{e['pooled']:+.1f}"
|
||||
if e["pooled"] is not None and e["pooled_se"] is not None:
|
||||
pooled_s += f" ±{e['pooled_se']:.1f}"
|
||||
if e["suggest"] is None:
|
||||
state = "无读数"
|
||||
elif e["table_val"] == 0:
|
||||
state = "表值未投值(现行 0)"
|
||||
elif e["table_val"] == e["suggest"]:
|
||||
state = "与表值一致 ✓"
|
||||
else:
|
||||
rate, cnt = window_rate(s, idx, CAMPAIGN_WINDOW)
|
||||
cells.append(f"{rate:.0f}%(n={cnt})" if rate is not None else "—")
|
||||
sa = int(r.get("stopped_at", -1))
|
||||
cells.append("通关" if sa == 0 else (f"止步第{sa}层" if sa > 0 else str(r.get("result", "?"))))
|
||||
tbl.append(cells)
|
||||
A(fmt_table(["局", "注入(flat)", "钥石", "第1层率(窗)", "第2层率(窗)", "第3层率(窗)", "终局"], tbl))
|
||||
A("")
|
||||
state = f"与表值偏差 {e['suggest'] - e['table_val']:+d}"
|
||||
tbl.append([ATTR_CN[attr], str(e["table_val"]), pooled_s,
|
||||
"—" if e["suggest"] is None else str(e["suggest"]), state])
|
||||
A(fmt_table(["属性", "现行表值(点/级)", "实测每级(点/级, 合并)", "建议投值", "状态"], tbl))
|
||||
A("- 折算语义:每级该属性强化 ≈ 通关率带平移量(百分点)——配对同种子读数,护甲行即解锁后实测(强条目)")
|
||||
A("- 合并口径:逆方差加权跨(档位×画像×层);带平移为线性单值近似——近带顶/带底的读数受天花板压缩,判读看 ④ 明细表")
|
||||
A("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def console_verdict(args, rows):
|
||||
"""控制台判定结论(先读数后细节)。"""
|
||||
lvl = analyze_level(rows)
|
||||
lvl = analyze_level(rows, baseline_only=True)
|
||||
print("\n判定结论(detail 见报告文件)")
|
||||
for (policy, li), a in sorted(lvl.items()):
|
||||
for key, a in sorted(lvl.items()):
|
||||
policy, li = key[0], key[1]
|
||||
rate = 100.0 * a["wins"] / max(1, a["n"])
|
||||
band = LEVEL_BANDS.get((policy, li))
|
||||
verdict, ok = judge_band(rate, band)
|
||||
@@ -401,20 +652,33 @@ def console_verdict(args, rows):
|
||||
adv = analyze_adventure(rows)
|
||||
for policy in ("normal", "perfect"):
|
||||
d = adv[policy]
|
||||
if d["n"] == 0:
|
||||
continue
|
||||
cn = "普通档" if policy == "normal" else "完美档"
|
||||
top = sorted(d["death_nodes"].items(), key=lambda x: -x[1])[:2]
|
||||
print(f" [·] 零强化冒险·{cn}:止步分布 {'、'.join(f'{k}{v}' for k, v in sorted(d['stopped'].items()))}"
|
||||
+ (f";主要败退点 = {';'.join(f'{lb} ×{c}' for lb, c in top)}" if top else ""))
|
||||
camp = analyze_campaign(rows)
|
||||
for policy in ("normal", "perfect"):
|
||||
d = camp[policy]
|
||||
cn = "普通档" if policy == "normal" else "完美档"
|
||||
if not d["runs"]:
|
||||
print(f" [·] 营地循环·{cn}:无数据")
|
||||
for spend in campaign_spend_tags(rows):
|
||||
camp = analyze_campaign(rows, spend)
|
||||
spend_cn = SPEND_CN.get(spend, spend)
|
||||
for policy in ("normal", "perfect"):
|
||||
d = camp[policy]
|
||||
cn = "普通档" if policy == "normal" else "完美档"
|
||||
if not d["runs"]:
|
||||
continue
|
||||
for cp in d.get("checkpoints", []):
|
||||
mark = {True: "✓", False: "✗", None: "·"}.get(cp.get("pass"), "·")
|
||||
print(f" [{mark}] 营地循环·{cn}·{spend_cn}·{LEVEL_CN[cp['level']]}→{LEVEL_CN[cp['level'] + 1]}:{cp['verdict']}")
|
||||
est = calibration_estimate(rows)
|
||||
for attr in OVERRIDE_ATTRS:
|
||||
e = est.get(attr)
|
||||
if not e or (not e["detail"] and e["pooled"] is None):
|
||||
continue
|
||||
for cp in d.get("checkpoints", []):
|
||||
mark = {True: "✓", False: "✗", None: "·"}.get(cp.get("pass"), "·")
|
||||
print(f" [{mark}] 营地循环·{cn}·{LEVEL_CN[cp['level']]}→{LEVEL_CN[cp['level'] + 1]}:{cp['verdict']}")
|
||||
pooled_s = "—" if e["pooled"] is None else f"{e['pooled']:+.1f}"
|
||||
if e["pooled"] is not None and e["pooled_se"] is not None:
|
||||
pooled_s += f" ±{e['pooled_se']:.1f}"
|
||||
print(f" [·] 换算率标定·{ATTR_CN[attr]}:实测每级 {pooled_s} 点 vs 表值 {e['table_val']}"
|
||||
+ (f" → 建议投 {e['suggest']}" if e["suggest"] is not None else ""))
|
||||
print()
|
||||
|
||||
|
||||
@@ -430,14 +694,19 @@ def main():
|
||||
ap.add_argument("--k", type=int, default=60, help="战役局数/画像")
|
||||
ap.add_argument("--seed0", type=int, default=8000)
|
||||
ap.add_argument("--only", default="level,adventure,campaign")
|
||||
ap.add_argument("--spend", default="hp_first", choices=["hp_first", "atk_first"])
|
||||
ap.add_argument("--spend", default="hp_first", choices=["hp_first", "atk_first", "armor_first"])
|
||||
ap.add_argument("--inject", default="", help="直入口注入档(flat:max_hp:3,atk:1 式——OFAT 标定用)")
|
||||
ap.add_argument("--levels", default="1,2,3", help="直入口层集(标定批常用 1,2——第三层带未定)")
|
||||
ap.add_argument("--timeout", type=int, default=5400)
|
||||
ap.add_argument("--quick", action="store_true", help="小规模快跑(12/8/10)")
|
||||
ap.add_argument("--from-raw", default=None, help="跳过采集,对既有 raw.jsonl 重新判定出报告")
|
||||
args = ap.parse_args()
|
||||
if args.quick:
|
||||
args.n, args.na, args.k = 12, 8, 10
|
||||
args.est_min = (3 * 2 * args.n * 1.5 + 2 * args.na * 3.5 + 2 * args.k * 3.5) / 60
|
||||
n_levels = len([s for s in args.levels.split(",") if s.strip()])
|
||||
args.est_min = (n_levels * 2 * args.n * 1.5 + 2 * args.na * 3.5 + 2 * args.k * 3.5) / 60
|
||||
|
||||
load_meta_table() # 表值锚配置推导化——解析失败即 FAIL,零硬编码兜底
|
||||
|
||||
ts = time.strftime("%Y%m%d-%H%M%S")
|
||||
if args.from_raw:
|
||||
|
||||
Reference in new issue
Block a user