From e8d8d6051ede1c32648ec32ef04b78ff96a98095 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Tue, 22 Sep 2026 20:12:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=E6=95=B0=E5=80=BC=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=B7=A5=E5=85=B7=E6=89=B9=E2=80=94=E2=80=94=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8C=96=E8=AF=84=E5=88=A4=E4=B8=89=E6=AE=B5=E9=87=87?= =?UTF-8?q?=E9=9B=86=EF=BC=88=E7=9B=B4=E5=85=A5=E5=8F=A3=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=E5=B8=A6=E5=88=A4=E5=AE=9A+=E9=9B=B6=E5=BC=BA=E5=8C=96?= =?UTF-8?q?=E6=AD=A2=E6=AD=A5=E5=BD=92=E5=9B=A0+=E8=90=A5=E5=9C=B0?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF=E7=88=AC=E5=8D=87=E6=88=98=E5=BD=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 冒险摘要增败退明细 defeat{level_index,node_id,node_type}(仅失败局附加,消费方零感知) - level_autoplay 冒险驱动增 hero_override 尾参透传(永久强化 flat 注入)+末场战斗敌方名采集 - 新增采集端 smoke/verifier_batch.gd(VJSON 行契约,隔离档真实元进度账本,绝不碰 user://save.json) - 新增判定端 tools/balance_verifier.py(带判定/止步归因/滑窗检查点判定/报告落 production/qa) - 冒烟 552 项 0 失败(新增败退明细断言 2 项) --- CLAUDE.md | 1 + .../core/adventure_runner.gd | 10 + .../smoke/level_autoplay.gd | 38 +- .../smoke/smoke_all.gd | 8 + .../smoke/verifier_batch.gd | 196 ++++++++ .../smoke/verifier_batch.gd.uid | 1 + tools/balance_verifier.py | 476 ++++++++++++++++++ 7 files changed, 723 insertions(+), 7 deletions(-) create mode 100644 prototypes/dungeon-sweeper-vertical-slice/smoke/verifier_batch.gd create mode 100644 prototypes/dungeon-sweeper-vertical-slice/smoke/verifier_batch.gd.uid create mode 100644 tools/balance_verifier.py diff --git a/CLAUDE.md b/CLAUDE.md index 1b92a407..1107bfd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ - 冒烟(全量 headless 断言):`python tools/run_smoke.py`(包装器三闸:退出码+摘要失败数+SCRIPT ERROR 扫描——脚本错误不计冒烟失败数且静默中断所在函数剩余断言,2026-09-19 台账 84 立闸)。底层命令 = `…console.exe --headless --path . --script res://smoke/smoke_all.gd` - 场景级探针(呈现层回归):`…console.exe --headless --path . res://smoke/ui_probe.tscn`;另有 `ui_tune_probe.tscn`(数值调整板)、`ui_click_probe.tscn`(合成鼠标事件行为探针) - 调参期批量工具:`--script res://smoke/level_monte_carlo.gd`(关卡期望扫描)· `--script res://smoke/reward_expectation.gd`(奖励获取期望) +- 数值验证工具:`python tools/balance_verifier.py`(直入口基线带判定+零强化止步节点归因+营地循环爬升战役——采集端 `--script res://smoke/verifier_batch.gd`,报告落 `production/qa/`;元进度换算率标定与曲线调参的自动化评判面,2026-09-22 数值验证工具批) - 交付前验收 = smoke + 全部场景探针全绿(测试细则见 `docs/code-conventions.md`) ## Project Layout diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/adventure_runner.gd b/prototypes/dungeon-sweeper-vertical-slice/core/adventure_runner.gd index 7884339a..49d629b4 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/core/adventure_runner.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/core/adventure_runner.gd @@ -54,6 +54,7 @@ var master_seed := 0 # 本局主种子(摘要第五件事 var summary := {} # 最近一次结算的冒险摘要(idle 后保留——重开才新生成) var _runner: LevelRunner # 当前关卡行进器(每关新建——关卡域状态天然隔离) var _pending_next_level := false # 大奖励已兑付、等 continue_to_next_level 确认 +var _last_level_payload := {} # 最近一次关卡结束载荷(失败结算的败退节点明细来源——数值验证取数面) var _override := [0, 0] # TunePanel 百分比通道 [atk_pct, hp_pct](每关新 runner 都要应用) var _base_override := {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat 注入(元进度实现批——冒险开始参数面 params.hero_override 存入,共享给 LevelRunner;全零 = 白板) @@ -165,6 +166,7 @@ func _on_level_finished(payload: Dictionary) -> void: if state != "running": push_error("[冒险结构] 重复关卡结束宣布拒绝——state=%s(账本与摘要零变化)" % state) return + _last_level_payload = payload.duplicate(true) var result := String(payload.get("result", "")) if result == "defeat": ledger["hero_hp"] = 0 # 倒下记 0(生命账本战后回写口径) @@ -204,6 +206,14 @@ func _settle(result: String) -> void: "keystone_total": keystone, "master_seed": master_seed, } + # 败退明细(数值验证工具取数面 2026-09-22——tools/balance_verifier.py 止步节点分布数据源; + # 仅失败局附加,键缺省 = 未败退,既有五件事消费方零感知) + if result == "failed" and not _last_level_payload.is_empty(): + summary["defeat"] = { + "level_index": level_index, + "node_id": int(_last_level_payload.get("node_id", -1)), + "node_type": String(_last_level_payload.get("node_type", "")), + } if _runner != null: _runner.close_wallet() # GP 冒险收束清零(三结局都清——经转发发 wallet_changed "clear" 供日志/呈现) state = "settling" diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd index 6240c816..95eba40c 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/level_autoplay.gd @@ -35,10 +35,10 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: var st := { "result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0, "rest_healed": 0, "hp_before_rest": -1, - "defeat_node_index": -1, "defeat_node_type": "", + "defeat_node_index": -1, "defeat_node_id": -1, "defeat_node_type": "", "boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false, "log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0, - "visited_ids": [], "choices": 0, + "visited_ids": [], "choices": 0, "last_battle_enemies": [], } runner.step_row.connect(_on_step_row.bind(runner, st, collect_rows)) runner.node_entered.connect(_on_node_entered.bind(runner, st, logger)) @@ -86,24 +86,29 @@ static func _drive_level(runner, st: Dictionary, logger, policy: String, route: # (start_adventure 唯一 new_adventure 调用点)、大奖励立即确认、冒险终局置 result/summary。 # 返回结构 = run_level 同族 + summary / wallet_events / encounter_max_hp_seq / reward_events / # visited_ids(全冒险节点行进序列——跨关累计,非末关 runner 的 visited 随实例更替,此处为唯一全量面)。 +# 2026-09-22 数值验证工具批:增 hero_override 尾参(永久强化 flat 注入透传——缺省 {} 与既有调用方 +# 逐位一致零行为变更)+ run 行携带该参数(分析侧按元进度档切分)+ 败退敌人名采集 +# (last_battle_enemies——末场战斗敌方显示名序列,失败局即死亡遭遇)。 static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_rows: bool = false, - logger = null, policy: String = "normal", route: String = "min") -> Dictionary: + logger = null, policy: String = "normal", route: String = "min", + hero_override: Dictionary = {}) -> Dictionary: var own_session := logger == null if own_session: logger = load("res://presentation/playtest_logger.gd").new() logger.start_session(seed_val) logger.ap_per_round = int(db.action_economy.ap_per_round) - logger.log_run({"seed": seed_val, "adventure": true, "atk_pct": atk_pct, "hp_pct": hp_pct, "policy": policy, "route": route}) + logger.log_run({"seed": seed_val, "adventure": true, "atk_pct": atk_pct, "hp_pct": hp_pct, + "policy": policy, "route": route, "hero_override": hero_override}) var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(seed_val) var adr = load("res://core/adventure_runner.gd").new(db, rng) var st := { "result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0, "rest_healed": 0, "hp_before_rest": -1, - "defeat_node_index": -1, "defeat_node_type": "", + "defeat_node_index": -1, "defeat_node_id": -1, "defeat_node_type": "", "boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false, "log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0, - "visited_ids": [], "choices": 0, + "visited_ids": [], "choices": 0, "last_battle_enemies": [], "summary": {}, "wallet_events": [], "encounter_max_hp_seq": [], "reward_events": [], "shop_gp_seq": [], # 商店房到达余额序(逐关 1 次——GP 跨关延续验收取数面) } @@ -113,7 +118,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_ adr.big_reward_paid.connect(_on_adventure_reward.bind(st, adr)) adr.adventure_finished.connect(_on_adventure_finished.bind(st, logger)) adr.set_hero_override(atk_pct, hp_pct) - if not adr.start_adventure(seed_val): + if not adr.start_adventure(seed_val, {"hero_override": hero_override}): st.result = "start_failed" if own_session: logger.close_session() @@ -142,6 +147,7 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr, st.visited_ids.append(int(r.node_id_of(node))) if t == "battle" or t == "boss": st.battles += 1 + st.last_battle_enemies = [] # 末场战斗敌方名序列按战斗重置(失败局终值 = 死亡遭遇) st.encounter_max_hp_seq.append(int(r.effective_max_hp())) if logger != null and r.current_battle() != null: logger.attach_battle(r.current_battle()) @@ -197,16 +203,33 @@ static func _route_pick(runner, route: String) -> int: static func _on_step_row(row: Dictionary, _runner, st: Dictionary, collect_rows: bool) -> void: if row.type == "damage" and row.source_class == "enemy_action": st.hero_dmg += int(row.value) + _note_enemy(String(row.get("text", "")), st) + elif row.type == "cast" and row.source_class == "enemy_action": + _note_enemy(String(row.get("text", "")), st) # 施放行(毒爆挂毒等——伤害行可能缺席的死亡遭遇仍可归因) if row.source_class == "mine_hit": st.mine_hits += 1 # 踩雷证据行(负向兑付——完美档断言恒 0;盲挖惩罚链埋点) if collect_rows: st.rows.append(row) +# 敌方显示名采集(数值验证工具批 2026-09-22):敌方行动/施放行文本首词 = 单位显示名 +#(攻击行「地精 撕咬(暴击)」、施放行「地精 施放 毒雾」——显示名不含空格,去重连续重复) +static func _note_enemy(text: String, st: Dictionary) -> void: + if text.is_empty(): + return + var ename := text.split(" ")[0] + if ename.is_empty(): + return + var arr: Array = st.last_battle_enemies + if arr.is_empty() or String(arr[arr.size() - 1]) != ename: + arr.append(ename) + + static func _on_node_entered(node: Dictionary, info: Dictionary, runner, st: Dictionary, logger) -> void: var t := String(node.get("type", "")) if t == "battle" or t == "boss": st.battles += 1 + st.last_battle_enemies = [] # 末场战斗敌方名序列按战斗重置(失败局终值 = 死亡遭遇) if logger != null and runner.current_battle() != null: logger.attach_battle(runner.current_battle()) if t == "boss": @@ -228,6 +251,7 @@ static func _on_level_finished(p: Dictionary, st: Dictionary, logger) -> void: st.result = String(p.get("result", "")) st.hp_left = int(p.get("hp", 0)) st.defeat_node_index = int(p.get("node_index", -1)) + st.defeat_node_id = int(p.get("node_id", -1)) # 编号空间解析与行进同源(图版 = node_depth——直入口死亡节点可读面) st.defeat_node_type = String(p.get("node_type", "")) if logger != null: logger.log_level_end(p) diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd index c4577eaf..42fba343 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd @@ -219,6 +219,10 @@ func _smoke_adventure() -> void: check(not bool(sl["cleared"]) and int(sl["levels_cleared"]) == 0 \ and int(sl["keystone_total"]) == 0 and int(sl["stopped_at"]) == 1, "L1 败北摘要:未打穿 · 通关 0 关 · 钥石 0 · 止步第 1 层") + var dl: Dictionary = sl.get("defeat", {}) + check(int(dl.get("level_index", -1)) == 1 and int(dl.get("node_id", -1)) >= 0 \ + and String(dl.get("node_type", "")) in ["battle", "boss"], + "L1 败北败退明细:层号/节点编号/节点类型在案(数值验证取数面 2026-09-22)") var half := {} for s in range(42, 142): var sth: Dictionary = autoplay.run_adventure(_db, s, 50, 0) @@ -232,6 +236,10 @@ func _smoke_adventure() -> void: and int(sh["keystone_total"]) == int(_db.adventure.keystone_per_level) \ and int(sh["stopped_at"]) == 2, "L2 败北摘要:L1 已过(钥石 1×表值)· 止步第 2 层") + var dh: Dictionary = sh.get("defeat", {}) + check(int(dh.get("level_index", -1)) == 2 and int(dh.get("node_id", -1)) >= 0 \ + and String(dh.get("node_type", "")) in ["battle", "boss"], + "L2 败北败退明细:层号=2 · 节点在案(数值验证取数面 2026-09-22)") # ⑥ 重复宣布拒绝(settle 后再调 _on_level_finished——摘要零变化) var adr6 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(7)) adr6.set_hero_override(400, 200) diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/verifier_batch.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/verifier_batch.gd new file mode 100644 index 00000000..0b716e32 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/verifier_batch.gd @@ -0,0 +1,196 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# Date: 2026-09-22(数值验证工具批——判定端 tools/balance_verifier.py 的数据源,本文件零判定逻辑) +# 三段一次采集,stdout 以「VJSON 」前缀行出机器读 JSON(其余行为人读进度/摘要): +# level 段 直入口单关基线(level_autoplay.run_level 零加成)——L1/L2/L3 × 双画像 × n, +# 供验收带判定(分带批 2026-09-20 口径:L1 普通 [35,55] / L2 普通 [25,45] / 完美 [35,65]) +# adventure 段 全冒险连探基线(run_adventure 零永久强化)× 双画像 × na——止步分布与败退节点 +# (摘要 defeat 明细 + 末场战斗敌方名),回答「零加成倒在第几层哪个节点」 +# campaign 段 营地循环战役 × 双画像 × k 局——SaveManager 隔离档 + MetaProgression 真实账本: +# 逐局 hero_override() 注入 → 冒险 → settle_adventure 入账 → 花费策略买锻造, +# 回答「累计几次通关冒险后下一关可过」(元进度换算率标定的模拟读数源) +# 运行:Godot_console.exe --headless --path . --script res://smoke/verifier_batch.gd -- [k=v ...] +# n=60(直入口每格局数) na=40(基线冒险局数) k=40(战役局数) seed0=8000 +# only=level,adventure,campaign(缺省全段) spend=hp_first|atk_first(锻造花费优先序) +# 种子纪律:unique 逐局独立(掷点测量修复批 A 案——seed0 + 全局游标,每掷点恰消费一次)。 +# 日志合规:全段共享 playtest_logger 会话(与真人试玩同日志),分析工具可直接消费。 +# 存档红线:战役档用 user://verifier_campaign_<画像>.json 隔离路径,段首清陈旧段尾清理—— +# 绝不碰 user://save.json(与 smoke 存档隔离纪律同源)。 +extends SceneTree + +const SPEND_ORDERS := { + "hp_first": ["max_hp", "atk", "armor_class"], + "atk_first": ["atk", "max_hp", "armor_class"], +} + +var _cursor := 0 # unique 种子全局游标(三段连续消费,段间不重叠) + + +func _initialize() -> void: + var opt := _parse_args(OS.get_cmdline_user_args()) + var db = load("res://core/config_db.gd").new() + if not db.load_tables("res://data/tables", false): + print("表加载失败") + quit(1) + return + var logger = load("res://presentation/playtest_logger.gd").new() + logger.ap_per_round = int(db.action_economy.ap_per_round) + var log_path: String = logger.start_session(opt.seed0) + print("模拟日志: %s" % log_path) + var autoplay = load("res://smoke/level_autoplay.gd") + var t0 := Time.get_ticks_msec() + var counts := {"level": 0, "adventure": 0, "campaign": 0} + if "level" in opt.only: + _seg_level(db, autoplay, logger, opt, counts) + if "adventure" in opt.only: + _seg_adventure(db, autoplay, logger, opt, counts) + if "campaign" in opt.only: + _seg_campaign(db, autoplay, logger, opt, counts) + logger.close_session() + print("数值验证采集完成:level %d 局 · adventure %d 局 · campaign %d 局,耗时 %.1f 秒" + % [counts.level, counts.adventure, counts.campaign, float(Time.get_ticks_msec() - t0) / 1000.0]) + quit(0) + + +func _parse_args(args: PackedStringArray) -> Dictionary: + var opt := {"n": 60, "na": 40, "k": 40, "seed0": 8000, "only": "level,adventure,campaign", "spend": "hp_first"} + for a in args: + var kv := String(a).split("=", true, 1) + if kv.size() != 2: + continue + var key := String(kv[0]) + if opt.has(key): + if key == "only": + opt[key] = String(kv[1]) + elif key == "spend": + opt[key] = String(kv[1]) + else: + opt[key] = int(kv[1]) + var segs: Array = [] + for s in String(opt.only).split(","): + if s in ["level", "adventure", "campaign"]: + segs.append(s) + opt["only"] = segs + if not SPEND_ORDERS.has(String(opt.spend)): + opt["spend"] = "hp_first" + return opt + + +func _next_seed(seed0: int) -> int: + var s: int = seed0 + _cursor + _cursor += 1 + return s + + +func _emit(row: Dictionary) -> void: + print("VJSON " + JSON.stringify(row)) + + +# ---- level 段:直入口单关基线(零加成——分带批验收带判定的同口径读数) ---- + +func _seg_level(db, autoplay, logger, opt: Dictionary, counts: Dictionary) -> void: + for policy in ["normal", "perfect"]: + for li in [1, 2, 3]: + for i in range(int(opt.n)): + var seed_val := _next_seed(int(opt.seed0)) + var st: Dictionary = autoplay.run_level(db, seed_val, li, 0, 0, false, logger, policy, "min") + _emit({ + "seg": "level", "level": li, "policy": policy, "seed": seed_val, + "result": String(st.result), + "defeat_node_id": int(st.defeat_node_id), "defeat_node_type": String(st.defeat_node_type), + "battles": int(st.battles), "rounds": int(st.rounds), + }) + counts.level += 1 + print("进度 level 第%d层 %s %d/%d" % [li, policy, int(opt.n), int(opt.n)]) + + +# ---- adventure 段:全冒险连探基线(零永久强化——止步分布与败退节点归因) ---- + +func _seg_adventure(db, autoplay, logger, opt: Dictionary, counts: Dictionary) -> void: + for policy in ["normal", "perfect"]: + for i in range(int(opt.na)): + var seed_val := _next_seed(int(opt.seed0)) + var st: Dictionary = autoplay.run_adventure(db, seed_val, 0, 0, false, logger, policy, "min") + _emit(_adventure_row(st, policy, seed_val)) + counts.adventure += 1 + print("进度 adventure %s %d/%d" % [policy, int(opt.na), int(opt.na)]) + + +# 冒险局共用取数行(adventure/campaign 两段同构——summary 缺席 = 编排异常局,原样带 result 供判定端计异常) +func _adventure_row(st: Dictionary, policy: String, seed_val: int) -> Dictionary: + var sum: Dictionary = st.summary + var row := { + "seg": "adventure", "policy": policy, "seed": seed_val, + "result": String(st.result), + "levels_cleared": int(sum.get("levels_cleared", -1)), + "stopped_at": int(sum.get("stopped_at", -1)), + "keystone": int(sum.get("keystone_total", -1)), + "defeat": sum.get("defeat", {}), + "last_enemies": st.last_battle_enemies, + } + if not row.defeat.is_empty() and not st.last_battle_enemies.is_empty(): + row.defeat["enemies"] = st.last_battle_enemies + return row + + +# ---- campaign 段:营地循环战役(隔离档真实账本——逐局注入/收束/购买全走正式侧路径) ---- + +func _seg_campaign(db, autoplay, logger, opt: Dictionary, counts: Dictionary) -> void: + for policy in ["normal", "perfect"]: + var save_path := "user://verifier_campaign_%s.json" % policy + _remove_save(save_path) # 段首清陈旧(新档 = 白板存档) + var save = load("res://core/save_manager.gd").new(save_path) + var meta = load("res://core/meta_progression.gd").new(db, save) + for r in range(int(opt.k)): + var seed_val := _next_seed(int(opt.seed0)) + var ov: Dictionary = meta.hero_override() + var keystone_before: int = meta.keystone_balance() + var st: Dictionary = autoplay.run_adventure(db, seed_val, 0, 0, false, logger, policy, "min", ov) + var row := _adventure_row(st, policy, seed_val) + row["seg"] = "campaign" + row["run_no"] = r + 1 + row["override"] = ov + row["keystone_before"] = keystone_before + if st.summary.is_empty(): + # 冒险未正常结算(timeout/advance_failed——编排异常零入账零购买,判定端计异常) + row["anomaly"] = true + _emit(row) + counts.campaign += 1 + continue + var settle: Dictionary = meta.settle_adventure(st.summary) + row["keystone_income"] = int(settle.total) + row["purchases"] = _spend(meta, String(opt.spend)) + row["keystone_after"] = meta.keystone_balance() + row["forge"] = {"max_hp": meta.forge_level("max_hp"), "atk": meta.forge_level("atk"), + "armor_class": meta.forge_level("armor_class")} + row["new_unlocks"] = settle.new_unlocks + _emit(row) + counts.campaign += 1 + if (r + 1) % 10 == 0: + print("进度 campaign %s %d/%d(锻造 血%d/攻%d/甲%d · 钥石 %d)" % [policy, r + 1, int(opt.k), + meta.forge_level("max_hp"), meta.forge_level("atk"), meta.forge_level("armor_class"), + meta.keystone_balance()]) + _remove_save(save_path) # 段尾清理(与 smoke 存档纪律同源——不遗留隔离档) + + +# 花费策略(贪心——按优先序反复买当前可负担的最高优先项;几何递增定价下单档连买价格上行自然外溢到次档) +func _spend(meta, policy_name: String) -> Array: + var out: Array = [] + var order: Array = SPEND_ORDERS.get(policy_name, SPEND_ORDERS["hp_first"]) + var guard := 200 + while guard > 0: + guard -= 1 + var bought := false + for attr in order: + var price: int = meta.forge_price(attr) + if price > 0 and meta.keystone_balance() >= price and meta.buy_forge(attr): + out.append({"attr": attr, "tier": int(meta.forge_level(attr)), "price": price}) + bought = true + break + if not bought: + break + return out + + +func _remove_save(path: String) -> void: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/verifier_batch.gd.uid b/prototypes/dungeon-sweeper-vertical-slice/smoke/verifier_batch.gd.uid new file mode 100644 index 00000000..63ece885 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/verifier_batch.gd.uid @@ -0,0 +1 @@ +uid://datax1elpwkik diff --git a/tools/balance_verifier.py b/tools/balance_verifier.py new file mode 100644 index 00000000..3f0119a4 --- /dev/null +++ b/tools/balance_verifier.py @@ -0,0 +1,476 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r"""数值验证工具(判定端)——自动化评判 + 元进度爬升测量,替代「亲手试玩再反馈」循环。 + +三段采集(数据源 = smoke/verifier_batch.gd,本文件驱动它并做全部判定): + level 段 直入口单关基线(零加成)——验收带判定 + adventure 段 全冒险连探基线(零永久强化)——止步分布 + 败退节点归因(层/节点/敌人) + campaign 段 营地循环战役(隔离档真实元进度账本:注入→冒险→收束入账→买锻造)——爬升曲线 + +评判标准(溯源): + ① 直入口基线通关率带(分带批 2026-09-20 用户拍板,难度曲线与Boss v0.16): + 第一层 普通 [35,55]% · 第二层 普通 [25,45]% · 完美档 [35,65]%(两关同带);第三层带未定——只测不判。 + ② 元循环目标(用户提案 2026-09-22,数字待确认):累计通过 5 次第 k 层的冒险后,第 k+1 层的 + 条件通关率(滑窗)应进入该层验收带——「5 次通过第一层的冒险后能过第二层;5 次通过第二层后能过第三层」。 + 第三层带未定,只给爬升曲线与首次读数,不出判定。 + ③ 魔法道具面:道具工坊六件按通关次数解锁(阈值 1/1/2/2/3/3),局内效果未实装(归 Alpha 魔法道具批)—— + 本工具只报解锁时点,不入判定。 + +表值硬编码溯源(单一事实源仍在 .tres / GDD,变更须同步): + · 锻造三档 flat(meta.tres):生命 +3 / 攻击 +1 / 护甲 +1 + · 钥石入账(adventure.tres + meta.tres):每层通关 10 + Boss 首杀/重复杀 5/1 · 8/2 · 12/3 + · 工坊阈值(meta.tres):显影尘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 --from-raw # 免重跑,对既有采集重新判定/出报告 +输出:控制台判定结论 + production/qa/balance-verifier-<时间戳>.md(报告)+ 同名 .raw.jsonl(原始逐局数据)。 +""" +import argparse +import json +import math +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": "商店房"} +FORGE_FLAT = {"max_hp": 3, "atk": 1, "armor_class": 1} # meta.tres 锻造三档每级 flat 值 +WORKSHOP_CN = {"probe_dust": "显影尘", "sapper_order": "工兵令", "loot_compass": "探宝罗盘", + "nest_pendulum": "探巢摆钟", "catacomb_scroll": "地穴全图卷", "blast_kit": "爆破工兵包"} + + +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}"] + 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): + """直入口基线:按(画像, 层)聚合计数。""" + 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": {}}) + 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 analyze_campaign(rows): + 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")] + anomalies = [r for r in rows if r.get("seg") == "campaign" and r.get("policy") == policy + 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) + band = LEVEL_BANDS.get((policy, k + 1)) + cp["verdict"], cp["pass"] = judge_band(rate, band) + cp["forge_at"] = chunk[0][2].get("override", {}) + 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 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.na} 局/画像 · 战役 {args.k} 局/画像" + f" · 种子 {args.seed0} 起逐局独立 · 锻造花费策略 = {args.spend}") + A(f"- 切片版本:{meta['commit']} · 表状态:判定带与表值溯源见 tools/balance_verifier.py 文件头") + A("") + + # ① 直入口基线 + A("## ① 直入口基线通关率(验收带判定)") + A("") + lvl = analyze_level(rows) + tbl = [] + for (policy, li), a in sorted(lvl.items()): + 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]) + 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 '—'}") + 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("") + + # ③ 元循环爬升 + 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("- 无数据") + 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,须排查)") + 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: + 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("") + return "\n".join(lines) + + +def console_verdict(args, rows): + """控制台判定结论(先读数后细节)。""" + lvl = analyze_level(rows) + print("\n判定结论(detail 见报告文件)") + for (policy, li), a in sorted(lvl.items()): + 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] + 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}:无数据") + 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']}") + 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"]) + 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 + + 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())