merge: 并入 master 4ce9791(直入口退役+冒险条件口径+L2 收紧)——标定批转新测量面

# Conflicts:
#	prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd
#	prototypes/dungeon-sweeper-vertical-slice/smoke/verifier_batch.gd
#	tools/balance_verifier.py
This commit is contained in:
DKChan committed 2026-09-23 07:01:04 +08:00
commit 7fd6fbd0bd
33 files changed
+1039 -600

No files matched your search

+187 -174
View File
@@ -2,26 +2,25 @@
# -*- coding: utf-8 -*-
r"""数值验证工具(判定端)——自动化评判 + 元进度爬升测量,替代「亲手试玩再反馈」循环。
三段采集(数据源 = smoke/verifier_batch.gd,本文件驱动它并做全部判定):
level 段 直入口单关基线(零加成)——验收带判定;换算率标定批起支持 inject 注入档
(永久强化 flat OFAT——同种子流配对读数,通关率差 = 带平移量实测)
adventure 段 全冒险连探基线(零永久强化)——止步分布 + 败退节点归因(层/节点/敌人)
两段采集(数据源 = smoke/verifier_batch.gd,本文件驱动它并做全部判定):
adventure 段 全冒险连探基线(零永久强化)——逐关条件通关率派生 + 止步分布 + 败退节点归因(层/节点/敌人);
换算率标定批起支持 inject 注入档(永久强化 flat OFAT——与白板同种子流配对,行带
inject_tag/override,标定面 = 逐关条件通关率差)
campaign 段 营地循环战役(隔离档真实元进度账本:注入→冒险→收束入账→买锻造)——爬升曲线;
换算率标定批起行带 spend 花费序标签(多轨迹合并采集按序分组判定)
行带 spend 花费序标签(多轨迹合并采集按序分组判定)
评判标准(溯源):
① 直入口基线通关率带(分带批 2026-09-20 用户拍板,难度曲线与Boss v0.16):
第一层 普通 [35,55]% · 第二层 普通 [25,45]% · 完美档 [35,65]%(两关同带);第三层带未定——只测不判。
带平移(元进度定稿批口径):注入/锻造状态按 meta.tres 换算率表折算平移量后套用——
判读口径不变、带随解锁平移(表值驱动,本文件解析 meta.tres 取数,零硬编码)。
① 逐关条件通关率(冒险口径派生——打通 k-1 关才到达第 k 关,通关数/到达数):单关直入口口径
(分带批 2026-09-20,难度曲线与Boss v0.16)满血零上下文进关,与真实冒险的条件通关率脱节,
曾误导曲线调参——已随单关试玩入口一并退役(2026-09-23 用户拍板);旧带值锚定直入口读数不可比,
带重锚归曲线调参批——重锚前本表只测不判。
② 元循环目标(用户提案 2026-09-22,数字待确认):累计通过 5 次第 k 层的冒险后,第 k+1 层的
条件通关率(滑窗)应进入该层验收带——「5 次通过第一层的冒险后能过第二层;5 次通过第二层后能过第三层」。
第三层带未定,只给爬升曲线与首次读数,不出判定。
条件通关率(滑窗)应进入该层验收带——判定同①随口径退役暂停(重锚前只给滑窗读数与触发点)。
③ 魔法道具面:道具工坊六件按通关次数解锁(阈值 1/1/2/2/3/3),局内效果未实装(归 Alpha 魔法道具批)——
本工具只报解锁时点,不入判定。
④ 换算率标定(2026-09-23 批新增):OFAT 注入档 vs 白板的配对通关率差 → 每级平移量点估计±SE
→ 与 meta.tres 现行表值对照、给建议投值。护甲行强条目:读数必须按解锁后参数实测(本段即重测面),
不得用基线外推。
④ 换算率标定(2026-09-23 批新增):OFAT 注入档 vs 白板的同种子配对逐关条件通关率差
→ 每级带平移量点估计±SE(配对 bootstrap)→ 与 meta.tres 现行表值对照、给建议投值。
护甲行强条目:读数必须按解锁后参数实测(本段即重测面),不得用基线外推。
表值溯源(单一事实源 = meta.tres,本文件启动时解析——解析失败即 FAIL,不做静默兜底):
· 锻造三档 flat(forge_products.flat_value)· 换算率三行(conversion.per_level_shift_pct)
@@ -29,16 +28,19 @@ r"""数值验证工具(判定端)——自动化评判 + 元进度爬升测
· 工坊阈值(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 # 全段默认规模
python tools/balance_verifier.py --quick # 小规模快跑(读数精度低)
python tools/balance_verifier.py --na 60 --k 60 --seed0 9000 --spend atk_first
python tools/balance_verifier.py --only adventure --inject max_hp:3 --na 100
python tools/balance_verifier.py --from-raw <raw.jsonl> # 免重跑,对既有采集重新判定/出报告
输出:控制台判定结论 + production/qa/balance-verifier-<时间戳>.md(报告)+ 同名 .raw.jsonl(原始逐局数据)。
兼容注:2026-09-23 前的旧 raw.jsonl 含 level 段(直入口单关)行——本版判定自动忽略,
逐关读数一律由冒险行派生。
"""
import argparse
import json
import math
import random
import re
import subprocess
import sys
@@ -54,21 +56,19 @@ 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),
}
# 直入口验收带(原 LEVEL_BANDS)已随单关直入口口径退役(2026-09-23 用户拍板)——
# 逐关/检查点判定全部暂停(judge_band 传 band=None = 只测不判),带重锚归曲线调参批。
# 带重锚后换算率平移随之启用:带按 band_shift_for(override) 平移后套用(表值驱动,④ 段供数)。
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)
ATTR_CN = {"max_hp": "生命上限", "atk": "基础攻击", "armor_class": "护甲等级"}
SPEND_CN = {"hp_first": "生命优先", "atk_first": "攻击优先", "armor_first": "护甲优先"}
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"
@@ -148,7 +148,7 @@ def band_shift_for(override):
def shifted_band(band, shift):
"""验收带随平移量平移(clamp [0,100];band=None = 带未定不平移)。"""
"""验收带随平移量平移(clamp [0,100];band=None = 带未定不平移——重锚前恒此态)。"""
if band is None:
return None
return (max(0.0, band[0] + shift), min(100.0, band[1] + shift))
@@ -174,10 +174,9 @@ def pad(s, w, right=False):
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} 分钟)…")
f"na={args.na}", f"k={args.k}", f"seed0={args.seed0}",
f"only={args.only}", f"spend={args.spend}", f"inject={args.inject}"]
print(f"[数值验证] 采集启动({' · '.join(segs)},约 {args.est_min:.0f} 分钟)…")
t0 = time.time()
try:
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
@@ -227,7 +226,7 @@ def binom_ci(wins, n):
def judge_band(rate_pct, band):
"""落带判定:返回 (结论, 是否达标)。band=None = 带未定,只报读数。"""
"""落带判定:返回 (结论, 是否达标)。band=None = 带未定(直入口口径退役后待重锚),只报读数。"""
if band is None:
return "带未定(只测不判)", None
lo, hi = band
@@ -235,7 +234,7 @@ def judge_band(rate_pct, band):
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
return f"落带 [{lo:g},{hi:g}] ✓", True
def node_label(defeat):
@@ -257,31 +256,38 @@ def node_label(defeat):
# ---------------------------------------------------------------- 分段分析
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 valid_adventure_runs(rows, policy, baseline_only=False):
"""冒险段有效局(summary 在场——levels_cleared >= 0);编排异常局单列。
baseline_only=True 只留白板档(①② 基线判定面——注入档行归 ④ 标定段)。"""
runs = [r for r in rows if r.get("seg") == "adventure" and r.get("policy") == policy
and int(r.get("levels_cleared", -1)) >= 0
and (not baseline_only or r.get("inject_tag", "白板") == "白板")]
invalid = sum(1 for r in rows if r.get("seg") == "adventure" and r.get("policy") == policy
and int(r.get("levels_cleared", -1)) < 0)
return runs, invalid
def analyze_adventure_levels(rows):
"""逐关条件通关率(冒险口径派生——直入口口径退役 2026-09-23;白板档):
到达第 k 关 ⇔ levels_cleared >= k-1;通关第 k 关 ⇔ levels_cleared >= k。
条件通关率 = 通关数 / 到达数(L1 的到达数 = 全部有效局——冒险即从第一关开始)。"""
out = {}
for policy in ("normal", "perfect"):
runs, invalid = valid_adventure_runs(rows, policy, baseline_only=True)
levels = {}
for k in (1, 2, 3):
reached = sum(1 for r in runs if int(r.get("levels_cleared", 0)) >= k - 1)
cleared = sum(1 for r in runs if int(r.get("levels_cleared", 0)) >= k)
levels[k] = {"reached": reached, "cleared": cleared}
out[policy] = {"n": len(runs), "invalid": invalid, "levels": levels}
return out
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}
runs, invalid = valid_adventure_runs(rows, policy, baseline_only=True)
stopped, death_nodes = {}, {}
for r in runs:
sa = int(r.get("stopped_at", -1))
if sa == 0:
@@ -292,11 +298,7 @@ def analyze_adventure(rows):
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}
out[policy] = {"n": len(runs), "invalid": invalid, "stopped": stopped, "death_nodes": death_nodes}
return out
@@ -343,7 +345,7 @@ def analyze_campaign(rows, spend_tag="hp_first"):
continue
series = campaign_series(runs)
res["series"] = series
# 检查点判定:累计通过第 k 层达目标后,第 k+1 层滑窗条件通关率 vs 验收带
# 检查点:累计通过第 k 层达目标后,第 k+1 层滑窗条件通关率——带待重锚(直入口口径退役)只报读数
checkpoints = []
for k in (1, 2):
clears = 0
@@ -369,8 +371,7 @@ def analyze_campaign(rows, spend_tag="hp_first"):
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["verdict"], cp["pass"] = judge_band(rate, None) # 带重锚归曲线调参批
checkpoints.append(cp)
res["checkpoints"] = checkpoints
# 工坊解锁时点(魔法道具面——只报不判)
@@ -388,42 +389,62 @@ def analyze_campaign(rows, spend_tag="hp_first"):
return out
# ---------------------------------------------------------------- 换算率标定(④)
# ---------------------------------------------------------------- 换算率标定(④,冒险条件口径)
def ofat_cells(rows):
"""OFAT 分组:白板基线 {(policy, level): [rows]} 与单轴注入档 {attr: {flat: {(policy, level): [rows]}}}。"""
"""OFAT 分组(冒险行):白板基线 {policy: [rows]} 与单轴注入档 {attr: {flat: {policy: [rows]}}}。"""
base, cells = {}, {}
for r in rows:
if r.get("seg") != "level":
if r.get("seg") != "adventure" or int(r.get("levels_cleared", -1)) < 0:
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"]))
policy = r["policy"]
if not nonzero:
base.setdefault(key, []).append(r)
base.setdefault(policy, []).append(r)
elif len(nonzero) == 1:
a = nonzero[0]
cells.setdefault(a, {}).setdefault(int(ov[a]), {}).setdefault(key, []).append(r)
cells.setdefault(a, {}).setdefault(int(ov[a]), {}).setdefault(policy, []).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)
def cond_rate(rows, k):
"""第 k 关条件通关率(%):通关数/到达数;到达数 0 = None。"""
reached = sum(1 for r in rows if int(r.get("levels_cleared", 0)) >= k - 1)
cleared = sum(1 for r in rows if int(r.get("levels_cleared", 0)) >= k)
if reached <= 0:
return None, reached
return 100.0 * cleared / reached, reached
def paired_cond_delta(base_rows, cell_rows, k, reps=2000):
"""同种子配对第 k 关条件通关率差(点)与配对 bootstrap SE。
点估计 = 全配对样本上 两档条件率之差;SE = 配对 bootstrap(同一次重采样同时进两档——
保种子级相关)。bootsrap 确定性种子固定,复判可重现。
"""
bmap = {int(r["seed"]): r for r in base_rows}
pairs = [(bmap[int(r["seed"])], r) for r in cell_rows if int(r["seed"]) in bmap]
n = len(pairs)
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
rate_b, reached_b = cond_rate([p[0] for p in pairs], k)
rate_c, reached_c = cond_rate([p[1] for p in pairs], k)
if rate_b is None or rate_c is None:
return None
rng = random.Random(20260923)
deltas = []
for _ in range(reps):
idx = [rng.randrange(n) for _ in range(n)]
rb, _ = cond_rate([pairs[i][0] for i in idx], k)
rc, _ = cond_rate([pairs[i][1] for i in idx], k)
if rb is not None and rc is not None:
deltas.append(rc - rb)
if not deltas:
return None
se = math.sqrt(sum((d - sum(deltas) / len(deltas)) ** 2 for d in deltas) / (len(deltas) - 1)) if len(deltas) > 1 else 0.0
return {"n": n, "reached_b": reached_b, "reached_c": reached_c,
"rate_b": rate_b, "rate_c": rate_c, "delta": rate_c - rate_b, "se": se}
def calibration_estimate(rows):
@@ -435,17 +456,19 @@ def calibration_estimate(rows):
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))
for policy in sorted(flats[flat]):
for k in (1, 2, 3):
d = paired_cond_delta(base.get(policy, []), flats[flat][policy], k)
if d is None:
continue
per_lvl = d["delta"] / lv
per_lvl_se = d["se"] / lv if d["se"] > 0 else None
detail.append({"flat": flat, "policy": policy, "k": k, "n": d["n"],
"rate_b": d["rate_b"], "rate_c": d["rate_c"],
"delta": d["delta"], "se": d["se"],
"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))
@@ -460,9 +483,6 @@ def calibration_estimate(rows):
return out
ATTR_CN = {"max_hp": "生命上限", "atk": "基础攻击", "armor_class": "护甲等级"}
# ---------------------------------------------------------------- 报告
def fmt_table(header, rows):
@@ -480,28 +500,32 @@ def report(args, rows, meta):
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 启动解析):"
A(f"- 采集规模:基线冒险 {args.na} 局/画像 · 战役 {args.k} 局/画像"
f" · 种子 {args.seed0} 起逐局独立 · 锻造花费策略 = {args.spend}"
+ (f" · 注入档 = {args.inject}" if args.inject else ""))
A(f"- 切片版本:{meta['commit']} · 口径:逐关读数 = 冒险条件口径(单关直入口口径已退役 2026-09-23,"
f"带待重锚只测不判——重锚归曲线调参批) · 换算率表值(meta.tres 启动解析):"
+ " · ".join(f"{ATTR_CN[a]}每级平移{CONVERSION.get(a, 0)}点" for a in OVERRIDE_ATTRS))
A("")
# ① 直入口基线(只留白板档——注入档读数归 ④ 标定段)
A("## ① 直入口基线通关率(验收带判定,白板档)")
# ① 逐关条件通关率(冒险口径派生,白板档)
A("## ① 逐关条件通关率(冒险口径——打通 k-1 关才到达第 k 关;带待重锚只测不判)")
A("")
lvl = analyze_level(rows, baseline_only=True)
alv = analyze_adventure_levels(rows)
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))
for policy in ("normal", "perfect"):
d = alv[policy]
cn = "普通档" if policy == "normal" else "完美档"
for k in (1, 2, 3):
lv = d["levels"][k]
if lv["reached"] <= 0:
tbl.append([LEVEL_CN[k], cn, f"—/{d['n']}", "—(未到达)", "未定"])
continue
rate = 100.0 * lv["cleared"] / lv["reached"]
ci = binom_ci(lv["cleared"], lv["reached"]) * 100
tbl.append([LEVEL_CN[k], cn, f"{lv['cleared']}/{lv['reached']}",
f"{rate:.1f}% ±{ci:.1f}", "未定"])
A(fmt_table(["层", "画像", "通关/到达", "条件通关率±95%CI", "验收带"], tbl))
A("")
# ② 冒险基线:止步分布与败退节点
@@ -510,15 +534,11 @@ 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("")
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}")
@@ -555,7 +575,8 @@ def report(args, rows, meta):
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} 点)"
f"/护甲+{cp['forge_at'].get('armor_class', 0)}"
f",表值平移 {cp.get('band_shift', 0.0):+.1f} 点——带重锚后随平移判)"
if "forge_at" in cp else ""))
A("")
tbl = []
@@ -578,82 +599,77 @@ def report(args, rows, meta):
A(fmt_table(["局", "注入(flat)", "钥石", "第1层率(窗)", "第2层率(窗)", "第3层率(窗)", "终局"], tbl))
A("")
# ④ 换算率标定(OFAT 注入档配对读数——护甲行强条目重测面)
A("## ④ 换算率标定(OFAT 注入档 vs 白板,同种子配对)")
# ④ 换算率标定(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"]
cn = "普通档" if d["policy"] == "normal" else "完美档"
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['flat']}({d['flat'] // per_flat} 级)", cn, LEVEL_CN[d["k"]],
str(d["n"]), f"{d['rate_b']:.1f}→{d['rate_c']:.1f}",
f"{d['delta']:+.1f} ±{d['se']:.1f}",
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(fmt_table(["注入档", "画像", "层", "配对局数", "条件率 白板→档", "差(点)", "折每级(点/级)"], tbl))
A("")
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(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("- 合并口径:逆方差加权跨(档位×画像×层,配对 bootstrap SE);带平移为线性单值近似——近带顶/带底的读数受天花板压缩,判读看 ④ 明细表")
A("")
return "\n".join(lines)
def console_verdict(args, rows):
"""控制台判定结论(先读数后细节)。"""
lvl = analyze_level(rows, baseline_only=True)
alv = analyze_adventure_levels(rows)
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}")
for policy in ("normal", "perfect"):
d = alv[policy]
cn = "普通档" if policy == "normal" else "完美档"
cells = []
for k in (1, 2, 3):
lv = d["levels"][k]
if lv["reached"] > 0:
rate = 100.0 * lv["cleared"] / lv["reached"]
cells.append(f"{LEVEL_CN[k]} {rate:.1f}%({lv['cleared']}/{lv['reached']})")
print(f" [·] 逐关条件通关率·{cn}:{' · '.join(cells) or '—'}(冒险口径——带待重锚只测不判)")
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()))}"
@@ -689,22 +705,19 @@ def main():
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("--na", type=int, default=60, help="基线冒险局数/画像(±95%CI 约 ±9 点——判读须看 CI)")
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("--only", default="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("--inject", default="", help="冒险段注入档(flat:max_hp:3,atk:1 式——OFAT 标定用)")
ap.add_argument("--timeout", type=int, default=5400)
ap.add_argument("--quick", action="store_true", help="小规模快跑(12/8/10)")
ap.add_argument("--quick", action="store_true", help="小规模快跑(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
args.na, args.k = 8, 10
args.est_min = (2 * args.na * 4.0 + 2 * args.k * 4.0) / 60
load_meta_table() # 表值锚配置推导化——解析失败即 FAIL,零硬编码兜底