- 40 张取证照与同名对比报告 → game/tests/evidence/ui-audit-2026-10-06/(该目录既有章程「人工测试证据」,目录根新增 .gdignore 令 Godot 整目录跳过导入,图不生成 .import 边车也不进导出包;parity/anchor.json 走文件系统路径读取不受影响) - 有接收方的两份审计(代码术语审计→06:546+迭代31 卡①/测试审计→测试分层卡片)平移 design/research/;iter27-探针迁移对照(T1 未施工=活交付物)迁 reports/,reports/README 放什么表补一行交付物类型 - 已核销的迭代18-实机目检总清单(2026-10-14 十八项全过)删除,顺带清掉其内 4 条指向8e02dcf0已删件的回指 - doc-conventions §11:暂存地唯一归 design/research/,验证凭据不归文档目录;新增「落档必写接收方」一行——写不出接收方的不落文件,内容在会话里交付 - tools/balance_verifier.py 报告与原始数据改落 sim-data/(QA_DIR→READINGS_DIR) - 活指针 13 处改判:AGENTS.md:22/30 与 Project Layout game 行、06:546、07:103、career 卡片×4、art-bible §8.2 L490 与 生涯.md:81 两处8e02dcf0遗留死指涉、game/tests/README 分级表与台账行、reports/README - 结构闸白名单三处同批收口:IMAGE_PREFIXES 去 design/qa/ 加 game/tests/evidence/、DESIGN_SUBDIRS 去 qa、RETIRED 加 design/qa(目录复活即 FAIL) - .qoder 技能 doc-consistency-check 两处指路改判(harness 配置不入 git,主区直接改) 单件试跑全绿:gdd_lint 10/10、doc_link --strict 死链 0、doc_term --strict 疑似现行 0、pipeline_gate 63 条目/21 卡、repo_structure_audit PASS。 本批 diff 触及 game/ 路径,合回需全量档回执(含代码域测试),待用户点名。
760 lines
37 KiB
Python
760 lines
37 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
r"""数值验证工具(判定端)——自动化评判 + 元进度爬升测量,替代「亲手试玩再反馈」循环。
|
||
|
||
两段采集(数据源 = smoke/verifier_batch.gd,本文件驱动它并做全部判定):
|
||
adventure 段 全冒险连探基线(零永久强化)——逐关条件通关率派生 + 止步分布 + 败退节点归因(层/节点/敌人);
|
||
换算率标定批起支持 inject 注入档(永久强化 flat OFAT——与白板同种子流配对,行带
|
||
inject_tag/override,标定面 = 逐关条件通关率差)
|
||
campaign 段 营地循环战役(隔离档真实元进度账本:注入→冒险→收束入账→买锻造)——爬升曲线;
|
||
行带 spend 花费序标签(多轨迹合并采集按序分组判定)
|
||
|
||
评判标准(溯源):
|
||
① 逐关条件通关率(冒险口径派生——打通 k-1 关才到达第 k 关,通关数/到达数):单关直入口口径
|
||
(分带批 2026-09-20,难度曲线与Boss v0.16)满血零上下文进关,与真实冒险的条件通关率脱节,
|
||
曾误导曲线调参——已随单关试玩入口一并退役(2026-09-23 用户拍板);旧带值锚定直入口读数不可比,
|
||
带重锚归曲线调参批——重锚前本表只测不判。
|
||
② 元循环目标(用户提案 2026-09-22,数字待确认):累计通过 5 次第 k 层的冒险后,第 k+1 层的
|
||
条件通关率(滑窗)应进入该层验收带——判定同①随口径退役暂停(重锚前只给滑窗读数与触发点)。
|
||
③ 魔法道具面:道具工坊六件按通关次数解锁(阈值 1/1/2/2/3/3),局内效果未实装(归 Alpha 魔法道具批)——
|
||
本工具只报解锁时点,不入判定。
|
||
④ 换算率标定(2026-09-23 批新增):OFAT 注入档 vs 白板的同种子配对逐关条件通关率差
|
||
→ 每级带平移量点估计±SE(配对 bootstrap)→ 与 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 # 全段默认规模
|
||
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> # 免重跑,对既有采集重新判定/出报告
|
||
输出:控制台判定结论 + sim-data/balance-verifier-<时间戳>.md(报告)+ 同名 .raw.jsonl(原始逐局数据)。
|
||
两件均落 sim-data/(整目录不入 git,读数以最新一份为准)。
|
||
兼容注:2026-09-23 前的旧 raw.jsonl 含 level 段(直入口单关)行——本版判定自动忽略,
|
||
逐关读数一律由冒险行派生。
|
||
"""
|
||
import argparse
|
||
import json
|
||
import math
|
||
import random
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
SLICE = ROOT / "game"
|
||
GODOT = r"D:\code\godot\Godot_v4.7.2-stable_win64_console.exe"
|
||
SCRIPT = "res://smoke/verifier_batch.gd"
|
||
READINGS_DIR = ROOT / "sim-data"
|
||
VJSON_PREFIX = "VJSON "
|
||
ERROR_MARKERS = ("SCRIPT ERROR", "Parse Error")
|
||
|
||
# ---- 判定标准区(溯源见文件头) ----
|
||
# 直入口验收带(原 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": "爆破工兵包"}
|
||
|
||
# ---- 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"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",
|
||
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:g},{hi:g}] ✓", 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 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, 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:
|
||
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
|
||
out[policy] = {"n": len(runs), "invalid": invalid, "stopped": stopped, "death_nodes": death_nodes}
|
||
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 层滑窗条件通关率——带待重锚(直入口口径退役)只报读数
|
||
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)
|
||
cp["verdict"], cp["pass"] = judge_band(rate, None) # 带重锚归曲线调参批
|
||
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: [rows]} 与单轴注入档 {attr: {flat: {policy: [rows]}}}。"""
|
||
base, cells = {}, {}
|
||
for r in rows:
|
||
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]
|
||
policy = r["policy"]
|
||
if not nonzero:
|
||
base.setdefault(policy, []).append(r)
|
||
elif len(nonzero) == 1:
|
||
a = nonzero[0]
|
||
cells.setdefault(a, {}).setdefault(int(ov[a]), {}).setdefault(policy, []).append(r)
|
||
return base, cells
|
||
|
||
|
||
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
|
||
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):
|
||
"""标定汇总:每属性 → 逐档明细 + 合并每级平移量(逆方差加权)+ 现行表值对照 + 建议投值。"""
|
||
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 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))
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------- 报告
|
||
|
||
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.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("## ① 逐关条件通关率(冒险口径——打通 k-1 关才到达第 k 关;带待重锚只测不判)")
|
||
A("")
|
||
alv = analyze_adventure_levels(rows)
|
||
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("")
|
||
|
||
# ② 冒险基线:止步分布与败退节点
|
||
A("## ② 零永久强化冒险:止步分布与败退节点(用户判据③)")
|
||
A("")
|
||
adv = analyze_adventure(rows)
|
||
for policy in ("normal", "perfect"):
|
||
d = adv[policy]
|
||
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 '—'}")
|
||
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)}"
|
||
f",表值平移 {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:
|
||
for attr in OVERRIDE_ATTRS:
|
||
e = est.get(attr)
|
||
if not e or not e["detail"]:
|
||
continue
|
||
per_flat = max(1, FORGE_FLAT.get(attr, 1))
|
||
A(f"### {ATTR_CN[attr]}(每级 flat +{per_flat})")
|
||
A("")
|
||
tbl = []
|
||
for d in e["detail"]:
|
||
cn = "普通档" if d["policy"] == "normal" else "完美档"
|
||
tbl.append([
|
||
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("")
|
||
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):
|
||
"""控制台判定结论(先读数后细节)。"""
|
||
alv = analyze_adventure_levels(rows)
|
||
print("\n判定结论(detail 见报告文件)")
|
||
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]
|
||
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("--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="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("--timeout", type=int, default=5400)
|
||
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.na, args.k = 8, 10
|
||
args.est_min = (2 * args.na * 4.0 + 2 * args.k * 4.0) / 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)
|
||
READINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||
raw_path = READINGS_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 = READINGS_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())
|