212 lines
9.7 KiB
GDScript
212 lines
9.7 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# Date: 2026-09-22(数值验证工具批——判定端 tools/balance_verifier.py 的数据源,本文件零判定逻辑)
|
||
# 2026-09-23 单关直入口口径退役(用户拍板):原 level 段(直入口单关基线——满血零上下文进关,
|
||
# 与真实冒险的条件通关率脱节,曾误导曲线调参)整段移除;逐关难度读数由 adventure 段行内
|
||
# levels_cleared/stopped_at 派生(判定端 analyze_adventure_levels),零补采。
|
||
# 2026-09-23 换算率标定批:① adventure 段增 inject 注入档(永久强化 flat OFAT——标定换算率表的
|
||
# 边际读数:每档与白板同种子流配对,逐关条件通关率差 = 该档的带平移量实测);
|
||
# ② campaign 段增 armor_first 花费序 + 行带 spend 标签(多轨迹合并采集按序分组)。
|
||
# 两段一次采集,stdout 以「VJSON 」前缀行出机器读 JSON(其余行为人读进度/摘要):
|
||
# adventure 段 全冒险连探基线(run_adventure 零永久强化)× 双画像 × na——止步分布、败退节点
|
||
# (摘要 defeat 明细 + 末场战斗敌方名)与逐关条件通关率派生源;inject 档行带
|
||
# inject_tag/override(flat 三键)
|
||
# campaign 段 营地循环战役 × 双画像 × k 局——SaveManager 隔离档 + MetaProgression 真实账本:
|
||
# 逐局 hero_override() 注入 → 冒险 → settle_adventure 入账 → 花费策略买锻造,
|
||
# 回答「累计几次通关冒险后下一关可过」(元进度换算率标定的模拟读数源)
|
||
# 运行:Godot_console.exe --headless --path . --script res://smoke/verifier_batch.gd -- [k=v ...]
|
||
# na=40(基线冒险局数) k=40(战役局数) seed0=8000
|
||
# only=adventure,campaign(缺省全段) spend=hp_first|atk_first|armor_first(锻造花费优先序)
|
||
# inject=max_hp:3,atk:1(冒险段 flat 注入档——三键 {max_hp, atk, armor_class},缺省全零白板)
|
||
# 种子纪律:unique 逐局独立(掷点测量修复批 A 案——seed0 + 全局游标,每掷点恰消费一次)。
|
||
# 同 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"],
|
||
"armor_first": ["armor_class", "max_hp", "atk"],
|
||
}
|
||
const OVERRIDE_KEYS := ["atk", "max_hp", "armor_class"] # 注入契约三键(同 AdventureRunner 白板形)
|
||
|
||
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 := {"adventure": 0, "campaign": 0}
|
||
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("数值验证采集完成:adventure %d 局 · campaign %d 局,耗时 %.1f 秒"
|
||
% [counts.adventure, counts.campaign, float(Time.get_ticks_msec() - t0) / 1000.0])
|
||
quit(0)
|
||
|
||
|
||
func _parse_args(args: PackedStringArray) -> Dictionary:
|
||
var opt := {"na": 40, "k": 40, "seed0": 8000, "only": "adventure,campaign",
|
||
"spend": "hp_first", "inject": ""}
|
||
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 in ["only", "spend", "inject"]:
|
||
opt[key] = String(kv[1])
|
||
else:
|
||
opt[key] = int(kv[1])
|
||
var segs: Array = []
|
||
for s in String(opt.only).split(","):
|
||
if s in ["adventure", "campaign"]:
|
||
segs.append(s)
|
||
opt["only"] = segs
|
||
if not SPEND_ORDERS.has(String(opt.spend)):
|
||
opt["spend"] = "hp_first"
|
||
opt["override"] = _parse_inject(String(opt.inject))
|
||
return opt
|
||
|
||
|
||
# inject 档解析:"max_hp:3,atk:1" → 三键 flat 字典(缺键 = 0、负值 clamp 0、未知键丢弃)
|
||
func _parse_inject(raw: String) -> Dictionary:
|
||
var out := {"atk": 0, "max_hp": 0, "armor_class": 0}
|
||
for part in raw.split(","):
|
||
var kv := String(part).split(":", true, 1)
|
||
if kv.size() == 2 and String(kv[0]) in OVERRIDE_KEYS:
|
||
out[String(kv[0])] = maxi(0, int(kv[1]))
|
||
return out
|
||
|
||
|
||
# 注入档人读标签(白板 = "白板";行键 inject_tag 供判定端分档聚合)
|
||
func _inject_tag(ov: Dictionary) -> String:
|
||
var parts: Array = []
|
||
for k in OVERRIDE_KEYS:
|
||
if int(ov.get(k, 0)) != 0:
|
||
parts.append("%s+%d" % [k, int(ov[k])])
|
||
return "白板" if parts.is_empty() else ",".join(parts)
|
||
|
||
|
||
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))
|
||
|
||
|
||
# ---- adventure 段:全冒险连探基线(零永久强化——止步分布/败退节点归因/逐关条件通关率派生源);
|
||
# inject 档 = 同种子流配对的 OFAT 边际读数(换算率标定批——行带 inject_tag/override flat 三键) ----
|
||
|
||
func _seg_adventure(db, autoplay, logger, opt: Dictionary, counts: Dictionary) -> void:
|
||
var ov: Dictionary = opt.override
|
||
var tag := _inject_tag(ov)
|
||
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", ov)
|
||
var row := _adventure_row(st, policy, seed_val)
|
||
row["inject_tag"] = tag
|
||
row["override"] = ov.duplicate()
|
||
_emit(row)
|
||
counts.adventure += 1
|
||
print("进度 adventure %s %d/%d(%s)" % [policy, int(opt.na), int(opt.na), tag])
|
||
|
||
|
||
# 冒险局共用取数行(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["spend"] = String(opt.spend)
|
||
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)
|