Files
gplan-2/tools/balance_verifier.py
T
DKChan f0aca09bd0 换算率标定批: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
2026-09-23 06:50:19 +08:00

746 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""数值验证工具(判定端)——自动化评判 + 元进度爬升测量,替代「亲手试玩再反馈」循环。
三段采集(数据源 = smoke/verifier_batch.gd,本文件驱动它并做全部判定):
level 段 直入口单关基线(零加成)——验收带判定;换算率标定批起支持 inject 注入档
(永久强化 flat OFAT——同种子流配对读数,通关率差 = 带平移量实测)
adventure 段 全冒险连探基线(零永久强化)——止步分布 + 败退节点归因(层/节点/敌人)
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 现行表值对照、给建议投值。护甲行强条目:读数必须按解锁后参数实测(本段即重测面),
不得用基线外推。
表值溯源(单一事实源 = 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 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
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SLICE = ROOT / "prototypes" / "dungeon-sweeper-vertical-slice"
GODOT = r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe"
SCRIPT = "res://smoke/verifier_batch.gd"
QA_DIR = ROOT / "production" / "qa"
VJSON_PREFIX = "VJSON "
ERROR_MARKERS = ("SCRIPT ERROR", "Parse Error")
# ---- 判定标准区(溯源见文件头) ----
LEVEL_BANDS = {
("normal", 1): (35, 55),
("normal", 2): (25, 45),
("perfect", 1): (35, 65),
("perfect", 2): (35, 65),
}
CAMPAIGN_CLEARS_TARGET = 5 # 元循环检查点:累计通过第 k 层的冒险次数(用户提案 2026-09-22,待确认)
CAMPAIGN_WINDOW = 10 # 检查点滑窗局数(条件通关率的窗口)
CAMPAIGN_MIN_ATTEMPTS = 5 # 滑窗内少于此局数 = 数据不足(不出判定)
LEVEL_CN = {1: "第一层", 2: "第二层", 3: "第三层"}
NODE_TYPE_CN = {"battle": "战斗", "boss": "Boss", "rest": "恢复", "reward": "奖励", "shop": "商店房"}
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):
print(f"[数值验证] FAIL:{msg}")
sys.exit(1)
def disp_w(s):
return sum(2 if ord(c) > 0x2E7F else 1 for c in str(s))
def pad(s, w, right=False):
s = str(s)
gap = w - disp_w(s)
return (" " * gap + s) if right else (s + " " * gap)
# ---------------------------------------------------------------- 采集
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"inject={args.inject}", f"levels={args.levels}"]
print(f"[数值验证] 采集启动({' · '.join(segs)},约 {args.est_min} 分钟)…")
t0 = time.time()
try:
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=args.timeout)
except subprocess.TimeoutExpired:
die(f"外部超时(>{args.timeout}s)——可加大 --timeout 或缩小规模")
out = (proc.stdout or "") + (proc.stderr or "")
if proc.returncode != 0:
print(out[-3000:])
die(f"采集进程退出码 {proc.returncode}")
hits = [ln.strip() for ln in out.splitlines() if any(k in ln for k in ERROR_MARKERS)]
if hits:
print("\n".join(hits[:5]))
die(f"脚本错误标记命中 {len(hits)} 行(脚本错误静默中断断言/采集,必须人工排查)")
rows, bad = [], 0
for ln in out.splitlines():
if ln.startswith(VJSON_PREFIX):
try:
rows.append(json.loads(ln[len(VJSON_PREFIX):]))
except json.JSONDecodeError:
bad += 1
if bad:
die(f"VJSON 行解析失败 {bad} 行(采集端输出契约被破坏)")
if not rows:
die("采集零数据行(verifier_batch.gd 未按契约输出)")
print(f"[数值验证] 采集完成:{len(rows)} 局,耗时 {(time.time() - t0) / 60:.1f} 分钟")
return rows, out
def load_raw(path):
rows = []
with open(path, encoding="utf-8") as f:
for ln in f:
ln = ln.strip()
if ln:
rows.append(json.loads(ln))
return rows
# ---------------------------------------------------------------- 判定件
def binom_ci(wins, n):
if n == 0:
return 0.0
p = wins / n
return 1.96 * math.sqrt(p * (1 - p) / n)
def judge_band(rate_pct, band):
"""落带判定:返回 (结论, 是否达标)。band=None = 带未定,只报读数。"""
if band is None:
return "带未定(只测不判)", None
lo, hi = band
if rate_pct < lo:
return f"越下界(低于 {lo}% 达 {lo - rate_pct:.1f} 点)", False
if rate_pct > hi:
return f"越上界(高于 {hi}% 达 {rate_pct - hi:.1f} 点)", False
return f"落带 [{lo},{hi}] ✓", True
def node_label(defeat):
"""败退节点人读名:第X层·节点N·类型·敌人(敌人 = 死亡遭遇显示名,最多 3 个)。"""
if not defeat:
return "—"
t = NODE_TYPE_CN.get(defeat.get("node_type", ""), defeat.get("node_type", "?"))
s = f"{LEVEL_CN.get(defeat.get('level_index', -1), '?层')}·节点{defeat.get('node_id', '?')}·{t}"
enemies = defeat.get("enemies") or []
if enemies:
seen, names = set(), []
for e in enemies:
if e not in seen:
seen.add(e)
names.append(e)
s += "·" + "、".join(names[:3])
return s
# ---------------------------------------------------------------- 分段分析
def analyze_level(rows, baseline_only=False):
"""直入口基线:按(画像, 层, 注入档)聚合计数。baseline_only=True 只留白板档(① 基线判定面)。"""
agg = {}
for r in rows:
if r.get("seg") != "level":
continue
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
elif r.get("result") == "defeat":
k = (int(r.get("defeat_node_id", -1)), r.get("defeat_node_type", ""))
a["deaths"][k] = a["deaths"].get(k, 0) + 1
return agg
def analyze_adventure(rows):
out = {}
for policy in ("normal", "perfect"):
runs = [r for r in rows if r.get("seg") == "adventure" and r.get("policy") == policy]
stopped, death_nodes, l1 = {}, {}, {"n": 0, "wins": 0}
for r in runs:
sa = int(r.get("stopped_at", -1))
if sa == 0:
stopped["通关"] = stopped.get("通关", 0) + 1
elif sa > 0:
stopped[f"止步第{sa}层"] = stopped.get(f"止步第{sa}层", 0) + 1
d = r.get("defeat") or {}
if d:
lb = node_label(d)
death_nodes[lb] = death_nodes.get(lb, 0) + 1
if int(r.get("levels_cleared", -1)) >= 0:
l1["n"] += 1
if int(r.get("levels_cleared", 0)) >= 1:
l1["wins"] += 1
out[policy] = {"n": len(runs), "stopped": stopped, "death_nodes": death_nodes, "l1": l1}
return out
def campaign_series(runs):
"""逐局爬升序列:条件通关尝试流(第 k 层的尝试 = levels_cleared ≥ k-1 的局,按局序)。"""
return {
k: [(r["run_no"], int(r.get("levels_cleared", -1)) >= k, r)
for r in sorted(runs, key=lambda x: x["run_no"])
if int(r.get("levels_cleared", -1)) >= k - 1]
for k in (1, 2, 3)
}
def window_rate(series, end_idx, window):
"""尝试流截至 end_idx(含)往前 window 次尝试的通关率(%)。"""
chunk = series[max(0, end_idx - window + 1): end_idx + 1]
if not chunk:
return None, 0
return 100.0 * sum(1 for _, ok, _ in chunk if ok) / len(chunk), len(chunk)
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 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("spend", "hp_first") == spend_tag and r.get("anomaly")]
res = {"n": len(runs), "anomalies": len(anomalies), "runs": runs}
if not runs:
out[policy] = res
continue
series = campaign_series(runs)
res["series"] = series
# 检查点判定:累计通过第 k 层达目标后,第 k+1 层滑窗条件通关率 vs 验收带
checkpoints = []
for k in (1, 2):
clears = 0
trigger_run = None
for r in sorted(runs, key=lambda x: x["run_no"]):
if int(r.get("levels_cleared", 0)) >= k:
clears += 1
if clears >= CAMPAIGN_CLEARS_TARGET and trigger_run is None:
trigger_run = r["run_no"]
cp = {"level": k, "trigger_run": trigger_run, "clears": clears}
if trigger_run is None:
cp["verdict"] = f"数据不足:全程通过{LEVEL_CN[k]}仅 {clears} 次(目标 {CAMPAIGN_CLEARS_TARGET})——加大 --k 重跑"
else:
attempts = [(rn, ok, r) for rn, ok, r in series.get(k + 1, []) if rn > trigger_run]
chunk = attempts[:CAMPAIGN_WINDOW]
if len(chunk) < CAMPAIGN_MIN_ATTEMPTS:
cp["verdict"] = (f"触发于第 {trigger_run} 局,但其后通过{LEVEL_CN[k + 1]}的尝试仅 "
f"{len(chunk)} 局(不足 {CAMPAIGN_MIN_ATTEMPTS})——加大 --k 重跑")
else:
rate = 100.0 * sum(1 for _, ok, _ in chunk if ok) / len(chunk)
cp["rate"] = rate
cp["attempts"] = len(chunk)
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)
checkpoints.append(cp)
res["checkpoints"] = checkpoints
# 工坊解锁时点(魔法道具面——只报不判)
unlocks = {}
for r in runs:
for iid in r.get("new_unlocks", []):
unlocks[WORKSHOP_CN.get(iid, iid)] = r["run_no"]
res["unlocks"] = unlocks
# 累计钥石与锻造终态
last = max(runs, key=lambda x: x["run_no"])
res["forge_final"] = last.get("forge", {})
res["keystone_final"] = int(last.get("keystone_after", -1))
res["income_total"] = sum(int(r.get("keystone_income", 0)) for r in runs)
out[policy] = res
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):
widths = [max(disp_w(h), max((disp_w(r[i]) for r in rows), default=0)) for i, h in enumerate(header)]
def line(cells):
return "| " + " | ".join(pad(c, w) for c, w in zip(cells, widths)) + " |"
sep = "|" + "|".join("-" * (w + 2) for w in widths) + "|"
out = [line(header), sep]
out += [line(r) for r in rows]
return "\n".join(out)
def report(args, rows, meta):
lines = []
A = lines.append
A(f"# 数值验证报告({meta['ts']})")
A("")
A(f"- 采集规模:直入口每格 {args.n} 局(层集 {args.levels} · 注入档 {args.inject or '白板'})"
f" · 基线冒险 {args.na} 局/画像 · 战役 {args.k} 局/画像"
f" · 种子 {args.seed0} 起逐局独立 · 锻造花费策略 = {args.spend}")
A(f"- 切片版本:{meta['commit']} · 换算率表值(meta.tres 启动解析):"
+ " · ".join(f"{ATTR_CN[a]}每级平移{CONVERSION.get(a, 0)}点" for a in OVERRIDE_ATTRS))
A("")
# ① 直入口基线(只留白板档——注入档读数归 ④ 标定段)
A("## ① 直入口基线通关率(验收带判定,白板档)")
A("")
lvl = analyze_level(rows, baseline_only=True)
tbl = []
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]:g},{band[1]:g}]" if band else "未定", verdict])
A(fmt_table(["层", "画像", "通关/局数", "通关率±95%CI", "验收带", "判定"], tbl))
A("")
# ② 冒险基线:止步分布与败退节点
A("## ② 零永久强化冒险:止步分布与败退节点(用户判据③)")
A("")
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("")
stop_line = "、".join(f"{k} {v}({100.0 * v / max(1, d['n']):.0f}%)" for k, v in sorted(d["stopped"].items()))
A(f"- 止步分布:{stop_line or '—'}")
l1r = 100.0 * d["l1"]["wins"] / max(1, d["l1"]["n"])
A(f"- 第一层通过率(冒险口径交叉核对):{l1r:.1f}%")
A("- 败退节点分布(失败局归因):")
for lb, c in sorted(d["death_nodes"].items(), key=lambda x: -x[1]):
A(f" - {lb} ×{c}")
A("")
# ③ 元循环爬升(多花费序轨迹分组——换算率标定批起 campaign 行带 spend 标签)
A("## ③ 营地循环爬升(用户判据④:5 次通关 → 下一层可过)")
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("")
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("")
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:
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, baseline_only=True)
print("\n判定结论(detail 见报告文件)")
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)
mark = {True: "✓", False: "✗", None: "·"}[ok]
print(f" [{mark}] 直入口{LEVEL_CN[li]}{'普通档' if policy == 'normal' else '完美档'}:"
f"{rate:.1f}%({a['wins']}/{a['n']})→ {verdict}")
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 ""))
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
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()
# ---------------------------------------------------------------- 入口
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
ap = argparse.ArgumentParser(description="数值验证工具(判定端)")
ap.add_argument("--godot", default=GODOT)
ap.add_argument("--n", type=int, default=100, help="直入口每格局数(±95%CI 约 ±9 点——带边判读须看 CI)")
ap.add_argument("--na", type=int, default=60, help="基线冒险局数/画像")
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", "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
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:
rows = load_raw(args.from_raw)
raw_path = Path(args.from_raw)
print(f"[数值验证] 已载入既有采集 {len(rows)} 局:{raw_path}")
else:
rows, _ = collect(args)
QA_DIR.mkdir(parents=True, exist_ok=True)
raw_path = QA_DIR / f"balance-verifier-{ts}.raw.jsonl"
with open(raw_path, "w", encoding="utf-8") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
commit = "?"
try:
commit = subprocess.run(["git", "-C", str(SLICE), "rev-parse", "--short", "HEAD"],
capture_output=True, text=True).stdout.strip()
except OSError:
pass
meta = {"ts": ts, "commit": commit}
console_verdict(args, rows)
md = report(args, rows, meta)
if args.from_raw:
md_path = raw_path.with_suffix(".md")
else:
md_path = QA_DIR / f"balance-verifier-{ts}.md"
md_path.write_text(md, encoding="utf-8")
print(f"[数值验证] 报告:{md_path}")
print(f"[数值验证] 原始逐局数据:{raw_path}(--from-raw 可免重跑重判)")
return 0
if __name__ == "__main__":
sys.exit(main())