refactor(波2 目录收尾): game/smoke/ 目录退役——44 件探针三件套迁 game/probe/、30 件测量/诊断/驱动件迁 game/sim/;全仓 100+ 处 res://smoke 与 game/smoke 引用改指(闸件/产品码 tune_sim_controller/宿主 preload/根规则/README/设计文档);熵增闸 RETIRED 收编 game/smoke 防回潮;AGENTS.md Project Layout 换代;static_scan 豁免理由换新宿主;波 2 施工件预计算表全部命中(8d 预扫零红灯);全量域闸验收通过零新增红
This commit is contained in:
1 parent
c5f1383766
commit
16c6f1362e
110 files changed
+139
-7305
No files matched your search
@@ -0,0 +1,155 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 冒险全流程期望扫描(三关连探零加成基线批量试玩——run_adventure 的蒙特卡洛批量工具,
|
||||
# 补 level_monte_carlo 只测单关的空白面):固定 atk+0%/hp+0%(零天赋格加成,关间大奖励
|
||||
# 按冒险结构现行设计自动兑付),逐局独立种子,输出:
|
||||
# ① 每局一行 RUNSUM JSON(stdout,局级摘要:策略/种子/result/summary/战斗轮数/承伤/踩雷/
|
||||
# 刷新/行进序列/逐战上限序列——后续统计分析的取数面);
|
||||
# ② 全量操作日志 = playtest_logger 共享会话 JSONL(与真人试玩同合规口径,每策略一批一文件,
|
||||
# run 行携带种子/策略切分局次,落 user://logs/)。
|
||||
# 本工具只采集与落盘数据,不做统计判读(通关率/败因分布归分析会话)。
|
||||
# 运行:Godot_console.exe --headless --path . --script res://sim/adventure_monte_carlo.gd -- [N] [seed0] [normal|perfect|both] [route min|max]
|
||||
extends SceneTree
|
||||
|
||||
|
||||
var _run_cursor := 0 # 全局局序号(seed = seed0 + cursor,逐局独立消费一次)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var n := 200
|
||||
if args.size() >= 1 and args[0].is_valid_int():
|
||||
n = maxi(5, int(args[0]))
|
||||
var seed0 := 71000
|
||||
if args.size() >= 2 and args[1].is_valid_int():
|
||||
seed0 = int(args[1])
|
||||
var policy_arg := "both"
|
||||
if args.size() >= 3 and args[2] in ["normal", "perfect", "both"]:
|
||||
policy_arg = args[2]
|
||||
var policies: Array = ["normal"]
|
||||
if policy_arg == "perfect":
|
||||
policies = ["perfect"]
|
||||
elif policy_arg == "both":
|
||||
policies = ["normal", "perfect"]
|
||||
var route := "min"
|
||||
if args.size() >= 4 and args[3] in ["min", "max"]:
|
||||
route = args[3]
|
||||
# 同调分层开关(off=现行基线零装配零扰动;on=贪心装配画像,确定性同种子可复放)
|
||||
var attune_mode := "off"
|
||||
if args.size() >= 5 and args[4] in ["off", "on"]:
|
||||
attune_mode = args[4]
|
||||
# 商店分层开关(off=legacy 对照缺省零扰动;on=共享池货架面,终局池读数随行)
|
||||
var shop_mode := "off"
|
||||
if args.size() >= 6 and args[5] in ["off", "on"]:
|
||||
shop_mode = args[5]
|
||||
# 特性/树分层开关(off=基线;formed=成型画像开局注入五事件族 14 件成型集
|
||||
# 〔测量面专用 params.relics_seed——真实局规则改写型特性只走局内随机渠道〕;ab_a/ab_b=Fork posture 两侧
|
||||
# rule 键集——A/B 面两侧差 <~3pp 验收载体;rules=任意树规则键集注入〔逗号分隔 args[7]——V19 三组配对载体〕)
|
||||
var feat_mode := "off"
|
||||
if args.size() >= 7 and args[6] in ["off", "formed", "ab_a", "ab_b", "rules"]:
|
||||
feat_mode = args[6]
|
||||
var feat_profile := {}
|
||||
if feat_mode == "formed":
|
||||
# 成型画像=五事件族覆盖 14 件
|
||||
# (开格 4/封印 3/踩雷 4/节奏 2/战斗 1——每族含跨事件账本件,账本件=攒层/计数面 MC 读数承载)
|
||||
feat_profile = {"mode": "on", "seed_ids": [
|
||||
"twin_pick", "geologist", "hard_hat", "frontier_miner",
|
||||
"seal_echo", "seal_smith", "seal_stake",
|
||||
"pain_memory", "trap_instinct", "chain_traps", "blood_honey",
|
||||
"last_drop", "market_close",
|
||||
"execution_scholar"]}
|
||||
elif feat_mode == "ab_a":
|
||||
feat_profile = {"mode": "off", "tree_rules": ["posture_first_strike"]}
|
||||
elif feat_mode == "ab_b":
|
||||
feat_profile = {"mode": "off", "tree_rules": ["posture_bulwark"]}
|
||||
elif feat_mode == "rules":
|
||||
feat_profile = {"mode": "off", "tree_rules": Array(String(args[7] if args.size() >= 8 else "").split(",", false))} # V19——任意键集配对注入(Array 包裹防 PackedStringArray 被 adventure_runner is Array 守卫整组丢弃)
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
if not db.load_tables("res://data/tables", false):
|
||||
print("表加载失败")
|
||||
quit(1)
|
||||
return
|
||||
var attune_profile := {}
|
||||
if attune_mode == "on":
|
||||
# 贪心画像:Slots=3(槽位判定源=树同调位节点点亮数,模拟画像=三节点
|
||||
# 全点亮常量);装配序 =
|
||||
# 表序 passive 优先至 Slots-1 + 首个 active(slots=3 → [淬毒刃纹, 标中殉爆, 掘进蓄能],
|
||||
# provisional 画像归调参);Y = 营地升级 Lv1 表值 1。autoplay 默认不刷——Y 仅注入容量,
|
||||
# 刷新消费归玩家策略面(钓鱼归玩家)。
|
||||
var slots: int = 3
|
||||
var passives: Array = []
|
||||
var actives: Array = []
|
||||
for r in db.treasure_attunement_rows():
|
||||
if String(r.get("form", "")) == "passive":
|
||||
passives.append(String(r.get("item_id", "")))
|
||||
elif String(r.get("form", "")) == "active_charged":
|
||||
actives.append(String(r.get("item_id", "")))
|
||||
var equipped: Array = []
|
||||
for p in passives:
|
||||
if equipped.size() >= maxi(0, slots - 1):
|
||||
break
|
||||
equipped.append(p)
|
||||
if slots > equipped.size() and not actives.is_empty():
|
||||
equipped.append(actives[0])
|
||||
for p in passives:
|
||||
if equipped.size() >= slots:
|
||||
break
|
||||
if not equipped.has(p):
|
||||
equipped.append(p)
|
||||
attune_profile = {"equipped": equipped, "refresh_y": 1}
|
||||
var autoplay = load("res://sim/level_autoplay.gd")
|
||||
var t0 := Time.get_ticks_msec()
|
||||
print("=== 冒险全流程扫描:三关连探 · N=%d/策略 · 种子 %d 起逐局独立 · 策略=%s · atk+0%%/hp+0%% 零加成 · route=%s ===" % [n, seed0, policy_arg, route])
|
||||
for policy in policies:
|
||||
# 每策略一个共享日志会话(全量 JSONL 体量随局数增长,分文件便于定位)
|
||||
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(seed0)
|
||||
var tally := {}
|
||||
for i in range(n):
|
||||
var seed_val := seed0 + _run_cursor
|
||||
_run_cursor += 1
|
||||
var st = autoplay.run_adventure(db, seed_val, 0, 0, false, logger, policy, route, {}, attune_profile,
|
||||
{"mode": "pool"} if shop_mode == "on" else {}, feat_profile)
|
||||
var sum: Dictionary = st["summary"]
|
||||
var rec := {
|
||||
"policy": policy, "seed": seed_val, "atk_pct": 0, "hp_pct": 0, "route": route,
|
||||
"attune": attune_mode, "equipped": attune_profile.get("equipped", []),
|
||||
"feat_mode": feat_mode,
|
||||
"result": st["result"],
|
||||
"cleared": bool(sum.get("cleared", false)),
|
||||
"levels_cleared": int(sum.get("levels_cleared", -1)),
|
||||
"stopped_at": int(sum.get("stopped_at", -1)),
|
||||
"keystone_total": int(sum.get("keystone_total", -1)),
|
||||
"battles": int(st["battles"]), "rounds": int(st["rounds"]),
|
||||
"hero_dmg": int(st["hero_dmg"]), "mine_hits": int(st["mine_hits"]),
|
||||
"mine_dmg": int(st.get("mine_dmg", 0)),
|
||||
"taken": int(st["hero_dmg"]) + int(st.get("mine_dmg", 0)),
|
||||
"refreshes": int(st["refreshes"]), "choices": int(st["choices"]),
|
||||
# 封印经济五字段(η=seal_hit÷seal_placed、标空率=seal_miss÷seal_placed
|
||||
# 派生归分析侧;gain/penalty=结算流水行值合计(原始行值口径,等价换算归判读侧——登记))
|
||||
"seal_placed": int(st.get("seal_placed", 0)), "seal_hit": int(st.get("seal_hit", 0)),
|
||||
"seal_miss": int(st.get("seal_miss", 0)),
|
||||
"seal_hit_gain": int(st.get("seal_hit_gain", 0)),
|
||||
"seal_miss_penalty": int(st.get("seal_miss_penalty", 0)),
|
||||
"relics": st.get("relics", []),
|
||||
"visited_count": (st["visited_ids"] as Array).size(),
|
||||
"encounter_max_hp_seq": st["encounter_max_hp_seq"],
|
||||
"reward_events": st["reward_events"],
|
||||
"shop_gp_seq": st["shop_gp_seq"],
|
||||
"level_templates": st.get("level_templates", []),
|
||||
"wallet_count": (st["wallet_events"] as Array).size(),
|
||||
}
|
||||
if shop_mode == "on":
|
||||
# pool 面增量字段(off 行零新增保对拍逐位干净)
|
||||
rec["shop_mode"] = "pool"
|
||||
rec["shop_buys"] = int(st.get("shop_buys", 0))
|
||||
rec["pool_stock"] = st.get("pool_stock", {})
|
||||
var rs := String(sum.get("result", ""))
|
||||
tally[rs] = int(tally.get(rs, 0)) + 1
|
||||
print("RUNSUM " + JSON.stringify(rec))
|
||||
if (i + 1) % 20 == 0:
|
||||
print("... %s 进度 %d/%d(耗时 %.1f 秒)" % [policy, i + 1, n, float(Time.get_ticks_msec() - t0) / 1000.0])
|
||||
logger.close_session()
|
||||
print("BATCH %s N=%d 终局计数=%s 日志=%s" % [policy, n, JSON.stringify(tally), log_path])
|
||||
print("(口径注:零加成 = atk/hp 天赋格百分比均 0;关间大奖励(HP 上限提升+回满)按冒险结构设计自动兑付,未额外剥离 · 全程日志见 user://logs/ · 总耗时 %.1f 秒)" % [float(Time.get_ticks_msec() - t0) / 1000.0])
|
||||
quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ckva7jc2mpuyb
|
||||
@@ -0,0 +1,251 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 自动玩家策略集(headless 冒烟/扫描/转储共用的理性玩家近似):
|
||||
# choose_dig = 挖最近可挖格(先移到邻接已开格)。
|
||||
# 无 window_selection(道具窗口确定性选择——道具窗口已下线)。
|
||||
# 读取面:战斗结算.presentation_snapshot() / 排雷玩法 查询面(E1/E2 收口后的成文 API——禁直读核心状态)。
|
||||
# 回归护栏:smoke_all 同种子回放断言(步骤流逐行相同)。
|
||||
extends RefCounted
|
||||
|
||||
|
||||
# 行主序枚举未开格,找与已开集 8 邻接者;小人先移到该邻接已开格(BFS 可达)。无可挖 → {}
|
||||
static func choose_dig(minesweeper) -> Dictionary:
|
||||
var revealed: Array = minesweeper.revealed_cells()
|
||||
var revealed_set := {}
|
||||
for c in revealed:
|
||||
revealed_set[c] = true
|
||||
var board = minesweeper.current_board()
|
||||
for y in board.height:
|
||||
for x in board.width:
|
||||
var c := Vector2i(x, y)
|
||||
if minesweeper.is_revealed(c) or minesweeper.has_seal(c):
|
||||
continue
|
||||
for n in board.neighbors8(c):
|
||||
if revealed_set.has(n):
|
||||
var path: Array = minesweeper.compute_move_path(n)
|
||||
if not path.is_empty() or n == minesweeper.hero_pos():
|
||||
return {"dig_cell": c, "hero_cell": n}
|
||||
return {}
|
||||
|
||||
|
||||
# ==================== 普通策略档 v2·基础定式画像 ====================
|
||||
# 扫雷推理分级(经典定式文献口径):新手级 = 单数字满足规则——「数字 = 邻雷未开数 ⇒ 全是雷(满足即标)」
|
||||
# 「数字 = 已封印数 ⇒ 余邻全安全(满足即开)」,教学一眼会、正常玩家两局即会的最低门槛判断;
|
||||
# 进阶级 = 联动定式(1-1 / 1-2 / 1-2-1 / 1-2-2-1 / 子集缩减)与概率分析——本切片归完美档 subset 推理承载,
|
||||
# 不入普通画像(新手不可靠掌握)。本档语义 = 只做新手级推理 + 无处可推时按既有口径盲挖边界格(新手冲动)+
|
||||
# 不主动刷新(新手无刷新策略意识)。测量定位:教会基础规则后的真实玩家下限近似
|
||||
# (乱点/最近格口径测的是无认知玩家下限,对通关率带判读失真,不作普通画像)。
|
||||
|
||||
# 普通画像单步决策入口(每动作一查——挖掘揭示新数字后重推理):move/dig/seal/stop 四值(无 refresh)。
|
||||
static func novice_action(minesweeper) -> Dictionary:
|
||||
var ded := _deduce_single(minesweeper)
|
||||
for c in ded["mines"]:
|
||||
if not minesweeper.is_revealed(c) and not minesweeper.has_seal(c):
|
||||
return _plan_on(minesweeper, c, "seal")
|
||||
for c in ded["safe"]:
|
||||
if not minesweeper.is_revealed(c) and not minesweeper.has_seal(c):
|
||||
return _plan_on(minesweeper, c, "dig")
|
||||
var plan := choose_dig(minesweeper)
|
||||
if plan.is_empty():
|
||||
return {"kind": "stop"}
|
||||
return _plan_on(minesweeper, plan.dig_cell, "dig")
|
||||
|
||||
|
||||
# 新手级推理不动点:仅 single 规则(约束余雷=0⇒未知全安全 / 余雷=未知数⇒全雷),无 subset 联动——
|
||||
# 满足规则连锁(标一雷使邻数字余雷清零)由 while-changed 不动点承载。
|
||||
static func _deduce_single(minesweeper) -> Dictionary:
|
||||
var board = minesweeper.current_board()
|
||||
var safe := {}
|
||||
var mines := {}
|
||||
var changed := true
|
||||
while changed:
|
||||
changed = false
|
||||
for con in _constraints(minesweeper, board, mines):
|
||||
if int(con["mines"]) == 0:
|
||||
for c in con["unknown"].keys():
|
||||
if not safe.has(c):
|
||||
safe[c] = true
|
||||
changed = true
|
||||
elif int(con["mines"]) == (con["unknown"] as Dictionary).size():
|
||||
for c in con["unknown"].keys():
|
||||
if not mines.has(c):
|
||||
mines[c] = true
|
||||
changed = true
|
||||
return {"safe": _row_major(safe), "mines": _row_major(mines)}
|
||||
|
||||
|
||||
# ==================== 完美策略档(OQ7 技能轴上界测量仪——只作测量,非玩法数值) ====================
|
||||
# 策略语义:零盲挖(只挖已证安全格 ⇒ 构造保证零踩雷)· 已证危险格全封印(封印先于挖——封印 = 纯收益
|
||||
# 〔轮末 weaken + 魔法 首次充能〕且雷上封印不阻连锁)· 卡死且刷新资源可用 → 轮内刷新
|
||||
# (经 settlement.player_request_refresh 核心编排)· 否则停手(残 AP 1:1 转存 RAP——下轮刷新资源)。
|
||||
# 推理规则集 = 棋盘生成 F1 measure_inferability 同族(single + subset——solver_rules=single_subset 冻结域);
|
||||
# 口径差异 = unknown 集不含 棋盘生成 生成期 safe_set oracle(真实玩家约束的未知邻格含危险格)——比生成期闸
|
||||
# 保守,卡死率略高属口径诚实。输入只经 排雷玩法 查询面(reveal_record/is_revealed/has_seal/current_board
|
||||
# 的邻居与可达面),禁读棋盘雷区真值(测推理上界,非全知作弊)。
|
||||
# 待拍板(未实现):非卡死主动刷新、囤刀倾泻 vs 即用即耗、完美板目标加权。
|
||||
|
||||
# 单步决策入口(每动作一查——挖掘揭示新数字后重推理):move/dig/seal/refresh/stop 五值。
|
||||
static func perfect_action(minesweeper, settlement) -> Dictionary:
|
||||
var ded := _deduce(minesweeper)
|
||||
for c in ded["mines"]:
|
||||
if not minesweeper.is_revealed(c) and not minesweeper.has_seal(c):
|
||||
return _plan_on(minesweeper, c, "seal")
|
||||
for c in ded["safe"]:
|
||||
if not minesweeper.is_revealed(c) and not minesweeper.has_seal(c):
|
||||
return _plan_on(minesweeper, c, "dig")
|
||||
if settlement != null and bool(settlement.can_refresh()):
|
||||
return {"kind": "refresh"}
|
||||
return {"kind": "stop"}
|
||||
|
||||
|
||||
# 目标格需英雄 8 邻——不可达先移到目标的已开 8 邻格(行主序首个可达者;同 choose_dig 寻路口径)
|
||||
static func _plan_on(minesweeper, c: Vector2i, kind: String) -> Dictionary:
|
||||
var d := Vector2i(absi(c.x - minesweeper.hero_pos().x), absi(c.y - minesweeper.hero_pos().y))
|
||||
if maxi(d.x, d.y) == 1:
|
||||
return {"kind": kind, "cell": c}
|
||||
var board = minesweeper.current_board()
|
||||
for n in board.neighbors8(c):
|
||||
if minesweeper.is_revealed(n) and (n == minesweeper.hero_pos() or not minesweeper.compute_move_path(n).is_empty()):
|
||||
return {"kind": "move", "cell": n}
|
||||
return {"kind": "stop"} # 不可达(理论不可达——已证格必邻接已开区);防御性停手防死循环
|
||||
|
||||
|
||||
# 推理不动点:single(约束余雷=0⇒未知全安全 / 余雷=未知数⇒全雷)+ subset(U_a ⊂ U_b ⇒ 差集约束)。
|
||||
# 约束只依赖 revealed/seals/mines 推断集——mines 集增长才需重建约束(safe 标记不改正约约束)。
|
||||
static func _deduce(minesweeper) -> Dictionary:
|
||||
var board = minesweeper.current_board()
|
||||
var safe := {}
|
||||
var mines := {}
|
||||
var changed := true
|
||||
while changed:
|
||||
changed = false
|
||||
var cons := _constraints(minesweeper, board, mines)
|
||||
for con in cons:
|
||||
if int(con["mines"]) == 0:
|
||||
for c in con["unknown"].keys():
|
||||
if not safe.has(c):
|
||||
safe[c] = true
|
||||
changed = true
|
||||
elif int(con["mines"]) == (con["unknown"] as Dictionary).size():
|
||||
for c in con["unknown"].keys():
|
||||
if not mines.has(c):
|
||||
mines[c] = true
|
||||
changed = true
|
||||
var cons2 := _constraints(minesweeper, board, mines)
|
||||
for i in cons2.size():
|
||||
for j in range(i + 1, cons2.size()):
|
||||
var der := _subset_derived(cons2[i], cons2[j])
|
||||
if der.is_empty():
|
||||
continue
|
||||
if int(der["mines"]) == 0:
|
||||
for c in der["unknown"].keys():
|
||||
if not safe.has(c):
|
||||
safe[c] = true
|
||||
changed = true
|
||||
elif int(der["mines"]) == (der["unknown"] as Dictionary).size():
|
||||
for c in der["unknown"].keys():
|
||||
if not mines.has(c):
|
||||
mines[c] = true
|
||||
changed = true
|
||||
return {"safe": _row_major(safe), "mines": _row_major(mines)}
|
||||
|
||||
|
||||
# 约束集 = 已开数字格:mines_left = digit − 已知雷封印(封印位 + 已证危险格),unknown = 未开无封印 8 邻。
|
||||
# 已爆危险格(kind=mine)不带数字约束——其贡献已含在邻格 digit 内(数字恒真不变式 inv3)。
|
||||
static func _constraints(minesweeper, board, mines: Dictionary) -> Array:
|
||||
var out: Array = []
|
||||
for c in minesweeper.revealed_cells():
|
||||
var rec: Dictionary = minesweeper.reveal_record(c)
|
||||
if String(rec.get("kind", "")) != "safe":
|
||||
continue
|
||||
var d := int(rec.get("digit", -1))
|
||||
if d < 0:
|
||||
continue
|
||||
var unknown := {}
|
||||
var known := 0
|
||||
for n in board.neighbors8(c):
|
||||
if minesweeper.has_seal(n):
|
||||
known += 1
|
||||
elif not minesweeper.is_revealed(n):
|
||||
if mines.has(n):
|
||||
known += 1
|
||||
else:
|
||||
unknown[n] = true
|
||||
var d_eff := d - known
|
||||
if unknown.is_empty() or d_eff < 0:
|
||||
continue # 零未知 = 无信息;负余雷 = 矛盾态(非法板/误封印)——不产约束
|
||||
out.append({"mines": d_eff, "unknown": unknown})
|
||||
return out
|
||||
|
||||
|
||||
# pair 差集约束:U_a ⊂ U_b ⇒ {unknown = U_b∖U_a, mines = mines_b−mines_a}(互不包含/相等集 → 空)
|
||||
static func _subset_derived(ca: Dictionary, cb: Dictionary) -> Dictionary:
|
||||
var res := _subset_one_way(ca, cb)
|
||||
if not res.is_empty():
|
||||
return res
|
||||
return _subset_one_way(cb, ca)
|
||||
|
||||
|
||||
static func _subset_one_way(ca: Dictionary, cb: Dictionary) -> Dictionary:
|
||||
var ua: Dictionary = ca["unknown"]
|
||||
var ub: Dictionary = cb["unknown"]
|
||||
if ua.is_empty() or ub.is_empty() or ua.size() >= ub.size():
|
||||
return {}
|
||||
for k in ua.keys():
|
||||
if not ub.has(k):
|
||||
return {}
|
||||
var diff := {}
|
||||
for k in ub.keys():
|
||||
if not ua.has(k):
|
||||
diff[k] = true
|
||||
var dm := int(cb["mines"]) - int(ca["mines"])
|
||||
if dm < 0:
|
||||
return {} # 矛盾态防御(完美档无误封印——理论不可达)
|
||||
return {"unknown": diff, "mines": dm}
|
||||
|
||||
|
||||
static func _row_major(cells: Dictionary) -> Array:
|
||||
var out: Array = cells.keys()
|
||||
out.sort_custom(func(a, b): return a.y * 1000 + a.x < b.y * 1000 + b.x)
|
||||
return out
|
||||
|
||||
|
||||
# 卡死赌挖选格(普通档 v3.1):概率启发近似真人「挑看起来最安全的一格」。
|
||||
# 危险度 = 该格对每个邻接已证数字约束取(余雷数/未知数)比值中的最小值(= 任一邻数字视角下的
|
||||
# 最低雷占比——single 已证安全格在进入本函数前已被 perfect_action 消化,故卡死态比值恒 >0);
|
||||
# 同危险度行主序先到先得。纯查询面零 RNG(确定性保回放);寻路同 choose_dig(目标已开 8 邻
|
||||
# 首个可达者为 hero_cell)。
|
||||
static func gamble_pick(minesweeper) -> Dictionary:
|
||||
var cons := _constraints(minesweeper, minesweeper.current_board(), {})
|
||||
var best_h := 2.0
|
||||
var best := Vector2i(-1, -1)
|
||||
var revealed: Array = minesweeper.revealed_cells()
|
||||
var revealed_set := {}
|
||||
for c in revealed:
|
||||
revealed_set[c] = true
|
||||
var board = minesweeper.current_board()
|
||||
for y in board.height:
|
||||
for x in board.width:
|
||||
var c := Vector2i(x, y)
|
||||
if minesweeper.is_revealed(c) or minesweeper.has_seal(c):
|
||||
continue
|
||||
var adj := false
|
||||
for n in board.neighbors8(c):
|
||||
if revealed_set.has(n):
|
||||
adj = true
|
||||
break
|
||||
if not adj:
|
||||
continue
|
||||
var h := 1.0
|
||||
for con in cons:
|
||||
if (con["unknown"] as Dictionary).has(c):
|
||||
var ratio := float(con["mines"]) / float((con["unknown"] as Dictionary).size())
|
||||
h = minf(h, ratio)
|
||||
if h < best_h - 0.0001:
|
||||
best_h = h
|
||||
best = c
|
||||
if best.x < 0:
|
||||
return {}
|
||||
for n in board.neighbors8(best):
|
||||
if minesweeper.is_revealed(n) and (n == minesweeper.hero_pos() or not minesweeper.compute_move_path(n).is_empty()):
|
||||
return {"dig_cell": best, "hero_cell": n}
|
||||
return {}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjws5lgencguh
|
||||
@@ -0,0 +1,37 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 战斗驱动循环单点——smoke 族探针共用的「挖掘至停手」整轮驱动。
|
||||
# 策略 = AutoPlayer.choose_dig(O4 单一实现):挖最近可挖格(异格先移位)/ 无可挖或相位离 Active 停手。
|
||||
# 语义锚(monte_carlo 原型逐位保持):guard 400 步防挂死;移位后相位/轮次离场即 break;
|
||||
# 轮末仍 Active 且未换轮 → settle_batch stop。smoke_all._run_battle/_auto_play_round 为冒烟本体
|
||||
# 不经本面(含回归断言,禁并入);fx_shot_probe 为带墙钟抓帧的异步变体亦不在列。
|
||||
extends RefCounted
|
||||
|
||||
const AutoPlayer := preload("res://sim/auto_player.gd")
|
||||
|
||||
const GUARD_ACTIONS := 400 # 单轮动作步防挂死上限(被收口探针原值逐一同值)
|
||||
|
||||
|
||||
# 驱动一整轮挖掘。still_running = 调用方战斗活性谓词(缺省恒真):
|
||||
# monte_carlo / skill_rhythm 传 `func(): return out.result == ""`(battle_finished 已发的轮内不再动手),
|
||||
# damage_probe 关卡驱动传 runner 绑定 + battle_result 谓词,battle 级探针传空即可。
|
||||
static func drive_round(settlement, still_running: Callable = Callable()) -> void:
|
||||
var start_round: int = settlement.round_index
|
||||
var minesweeper = settlement.minesweeper
|
||||
var guard := GUARD_ACTIONS
|
||||
while _alive(still_running) and settlement.action_economy.phase == "Active" \
|
||||
and settlement.round_index == start_round and guard > 0:
|
||||
guard -= 1
|
||||
var plan := AutoPlayer.choose_dig(minesweeper)
|
||||
if plan.is_empty():
|
||||
break
|
||||
if plan.hero_cell != minesweeper.hero_pos():
|
||||
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
|
||||
if settlement.action_economy.phase != "Active" or settlement.round_index != start_round:
|
||||
break
|
||||
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
|
||||
if _alive(still_running) and settlement.action_economy.phase == "Active" and settlement.round_index == start_round:
|
||||
minesweeper.settle_batch([{"action": "stop"}])
|
||||
|
||||
|
||||
static func _alive(still_running: Callable) -> bool:
|
||||
return still_running.is_null() or bool(still_running.call())
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbattledrv13r
|
||||
@@ -0,0 +1,57 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 定位:分析工具·非验收件——零断言取数工具,不出入任何闸。
|
||||
# 棋盘板形统计探针(判式"实测板形"口径的数据源):
|
||||
# 对 board_gen.tres 每行 (L, D) 生成 N 张板,统计幸存雷数 |M| 与安全格数 |S|
|
||||
# 的均值 / 最小 / 最大("先布后删"后的落地值——名义值 density×(W×H−9) 不含删除段)。
|
||||
# 只读诊断,零游戏代码改动;装载面与冒险扫描工具同源(config_db.load_tables)。
|
||||
# 运行:Godot_console.exe --headless --path . --script res://sim/board_stat_probe.gd -- [N] [seed0]
|
||||
extends SceneTree
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var n := 200
|
||||
if args.size() >= 1 and args[0].is_valid_int():
|
||||
n = maxi(10, int(args[0]))
|
||||
var seed0 := 9000
|
||||
if args.size() >= 2 and args[1].is_valid_int():
|
||||
seed0 = int(args[1])
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
if not db.load_tables("res://data/tables", false):
|
||||
print("表加载失败")
|
||||
quit(1)
|
||||
return
|
||||
var gen = load("res://core/board_generator.gd").new()
|
||||
print("=== 板形统计:N=%d/行 · 种子基 %d ===" % [n, seed0])
|
||||
print("%-4s %-4s %-8s %-8s %-10s %-10s %-10s %-10s %-10s %-10s" % ["L", "D", "宽x高", "类型", "|M|均", "|M|min", "|M|max", "|S|均", "雷密度实测", "达标率"])
|
||||
for row in db.board_gen.rows:
|
||||
var L: int = int(row.get("level_index", 0))
|
||||
var D: int = int(row.get("node_depth", -1))
|
||||
var w: int = int(row.get("board_width", 0))
|
||||
var h: int = int(row.get("board_height", 0))
|
||||
var btype := String(row.get("board_type", ""))
|
||||
var mine_counts: Array = []
|
||||
var accepts := 0
|
||||
for i in range(n):
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(seed0 + L * 1000 + D * 100 + i)
|
||||
var stream: RandomNumberGenerator = rng.open_stream("board_gen")
|
||||
var board = gen.generate(L, D, stream, db)
|
||||
if board == null:
|
||||
continue
|
||||
mine_counts.append(board.mines.size())
|
||||
if not board.illegal_product:
|
||||
accepts += 1
|
||||
if mine_counts.is_empty():
|
||||
print("%-4d %-4d 生成全败" % [L, D])
|
||||
continue
|
||||
mine_counts.sort()
|
||||
var total := 0
|
||||
for m in mine_counts:
|
||||
total += int(m)
|
||||
var mean_m := float(total) / float(mine_counts.size())
|
||||
var mean_s := float(w * h) - mean_m
|
||||
print("%-4d %-4d %-8s %-8s %-10.2f %-10d %-10d %-10.2f %-10.4f %-10.3f" % [
|
||||
L, D, "%dx%d" % [w, h], btype, mean_m, int(mine_counts[0]), int(mine_counts[mine_counts.size() - 1]),
|
||||
mean_s, mean_m / float(w * h), float(accepts) / float(mine_counts.size())])
|
||||
quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4bmmnuwpld77
|
||||
@@ -0,0 +1,100 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 棋盘口径校准(分析会话取数工具——冒险零加成完成度分母实证):
|
||||
# 逐 board_gen 行真实生成 N 板,统计 实际雷数/安全格数/R₀ 初始展开占比——
|
||||
# 生成期「先布后删+可推理率重试」使实际雷数远低于表口径 round(d×(W×H−9)),
|
||||
# 日志侧只有清图板可反推 safe_total(未清图深度无样本),本工具给全网格精确分母。
|
||||
# 运行:Godot_console.exe --headless --path . --script res://sim/calibrate_boards.gd -- [N]
|
||||
# 输出:stdout CSV 行 level,depth,w,h,n,mines_mean,mines_med,safe_mean,safe_med,r0_mean,inf_mean
|
||||
extends SceneTree
|
||||
|
||||
|
||||
class FakeDB:
|
||||
var row: Dictionary
|
||||
func resolve_board_gen(_L: int, _D: int) -> Dictionary:
|
||||
return row
|
||||
func status_effect_row(_id: String) -> Dictionary:
|
||||
return {}
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var n := 200
|
||||
if args.size() >= 1 and args[0].is_valid_int():
|
||||
n = maxi(10, int(args[0]))
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
if not db.load_tables("res://data/tables", false):
|
||||
print("表加载失败")
|
||||
quit(1)
|
||||
return
|
||||
var gen = load("res://core/board_generator.gd").new()
|
||||
print("level,depth,w,h,n,mines_mean,mines_med,safe_mean,safe_med,r0_mean,r0_sd,r0_p90,r0_max,inf_mean,cap_rej_mean")
|
||||
for row in db.board_gen.rows:
|
||||
var rd := {
|
||||
"level_index": int(row.level_index), "node_depth": int(row.node_depth),
|
||||
"board_width": int(row.board_width), "board_height": int(row.board_height),
|
||||
"mine_density": float(row.mine_density), "inferability_target": float(row.inferability_target),
|
||||
"solver_rules": String(row.solver_rules), "min_safe_count": int(row.min_safe_count),
|
||||
"max_gen_attempts": int(row.max_gen_attempts), "board_type": String(row.board_type),
|
||||
# 初始展开收窄两参数透传(.get 缺省与生成器防御口径一致)
|
||||
"shave_ring_radius": int(row.get("shave_ring_radius", 1)),
|
||||
"r0_max_cap": int(row.get("r0_max_cap", 0)),
|
||||
}
|
||||
var fdb := FakeDB.new()
|
||||
fdb.row = rd
|
||||
var mines: Array = []
|
||||
var safes: Array = []
|
||||
var r0s: Array = []
|
||||
var infs: Array = []
|
||||
var cap_rejs: Array = []
|
||||
for i in range(n):
|
||||
var rg := RandomNumberGenerator.new()
|
||||
rg.seed = 900000 + i
|
||||
var b = gen.generate(int(row.level_index), int(row.node_depth), rg, fdb)
|
||||
if b == null:
|
||||
continue
|
||||
mines.append(b.mines.size())
|
||||
safes.append(b.safe_cells().size())
|
||||
r0s.append(int(b.metrics.r0_count))
|
||||
infs.append(float(b.metrics.inferability))
|
||||
cap_rejs.append(int(b.metrics.get("r0_cap_rejected", 0)))
|
||||
if mines.is_empty():
|
||||
printerr("L%d d%d 生成全部失败" % [int(row.level_index), int(row.node_depth)])
|
||||
continue
|
||||
print("%d,%d,%d,%d,%d,%.1f,%d,%.1f,%d,%.1f,%.2f,%d,%d,%.3f,%.2f" % [
|
||||
int(row.level_index), int(row.node_depth), int(row.board_width), int(row.board_height),
|
||||
mines.size(), _mean(mines), _med(mines), _mean(safes), _med(safes), _mean(r0s),
|
||||
_sd(r0s), _p90(r0s), _max(r0s), _mean(infs), _mean(cap_rejs)])
|
||||
quit(0)
|
||||
|
||||
|
||||
func _mean(a: Array) -> float:
|
||||
var t := 0.0
|
||||
for v in a:
|
||||
t += float(v)
|
||||
return t / float(a.size())
|
||||
|
||||
|
||||
func _sd(a: Array) -> float:
|
||||
var m := _mean(a)
|
||||
var t := 0.0
|
||||
for v in a:
|
||||
t += (float(v) - m) * (float(v) - m)
|
||||
return sqrt(t / float(a.size()))
|
||||
|
||||
|
||||
func _p90(a: Array) -> int:
|
||||
var b := a.duplicate()
|
||||
b.sort()
|
||||
return int(b[mini(a.size() - 1, int(ceil(a.size() * 0.9)) - 1)])
|
||||
|
||||
|
||||
func _max(a: Array) -> int:
|
||||
var b := a.duplicate()
|
||||
b.sort()
|
||||
return int(b[b.size() - 1])
|
||||
|
||||
|
||||
func _med(a: Array) -> int:
|
||||
var b := a.duplicate()
|
||||
b.sort()
|
||||
return int(b[b.size() / 2])
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctkxq74fp3667
|
||||
@@ -0,0 +1,427 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 定位:诊断工具·非验收件——不出入任何闸(probe_gate/render_gate/run_smoke 全不涉)。
|
||||
# playtest-2026-09-12 报告 Top1——零承伤根因分割探针
|
||||
# 检查面:H1 hero_defense 池仅 off_guard 正向行(×0 墙结构面不存在);
|
||||
# H2 Boss/敌攻击链健康——fresh 池下非蓄力拍 d_tick = max(下限, 池乘区(掷点档位基线))。
|
||||
# 快照盲区说明:settlement_snapshot 取在衰减段之后,时效 1 的 ac_up 当轮授予当轮衰减——
|
||||
# 真人局 JSONL buffs[] 恒空 ≠ 状态为空,本探针改从结算行(授予/续存 effect 行的 pool_value)重建战斗段时点池值。
|
||||
# 观测面:全部读战斗结算步骤行 / 表值 / 呈现快照(零核心穿透);池归属/档位/下限全读表(数据配置 Rule 1——零数值字面量)。
|
||||
# 模式(-- 后参数):boss [seed](默认 42:Run A 零挖掘 fresh 池 + Run B 自动挖掘授予行为)
|
||||
# level [seed](全关遭遇回放 + 多种子 spawn 扫描——报告 Bug#1 证据面)
|
||||
# 运行:Godot_console.exe --headless --path . --script res://sim/damage_probe.gd -- boss 42
|
||||
# Godot_console.exe --headless --path . --script res://sim/damage_probe.gd -- level 42
|
||||
extends SceneTree
|
||||
|
||||
const BattleDriver := preload("res://sim/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
|
||||
|
||||
var _checks := 0
|
||||
var _fails := 0
|
||||
|
||||
|
||||
func check(cond: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if cond:
|
||||
print(" [PASS] " + label)
|
||||
else:
|
||||
_fails += 1
|
||||
print(" [FAIL] " + label)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
print("=== 零承伤根因分割探针(playtest-2026-09-12 报告 Top1)===")
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
if not db.load_tables("res://data/tables", false):
|
||||
print("表加载失败")
|
||||
quit(1)
|
||||
return
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var mode := "boss"
|
||||
if args.size() >= 1 and (args[0] == "boss" or args[0] == "level"):
|
||||
mode = args[0]
|
||||
var seed_val := 42
|
||||
if args.size() >= 2 and args[1].is_valid_int():
|
||||
seed_val = int(args[1])
|
||||
if mode == "boss":
|
||||
_probe_boss(db, seed_val)
|
||||
else:
|
||||
_probe_level(db, seed_val)
|
||||
print("=== 探针完成:%d 项检查,%d 项失败 ===" % [_checks, _fails])
|
||||
quit(1 if _fails > 0 else 0)
|
||||
|
||||
|
||||
# ==================== boss 模式:逐结算行打印 + 根因判定 ====================
|
||||
|
||||
func _probe_boss(db, seed_val: int) -> void:
|
||||
var math = load("res://core/damage_math.gd")
|
||||
var clamp_lo: int = int(db.settlement.pool_clamp_lo)
|
||||
var clamp_hi: int = int(db.settlement.pool_clamp_hi)
|
||||
var hero_def_negative := 0
|
||||
for eff_id in db.settlement.effect_pool_map.keys():
|
||||
if String(db.settlement.effect_pool_map[eff_id]) != "hero_defense":
|
||||
continue
|
||||
var row: Dictionary = db.status_effect_row(eff_id)
|
||||
if not row.is_empty() and String(row.get("unit", "")) == "percent":
|
||||
hero_def_negative = mini(hero_def_negative, int(row.get("magnitude", 0)))
|
||||
print("表值面:hero_defense 池最负 percent 行 %d · pool_clamp [%d, %d] · damage_floor %d · AC 基准 %d(+ac_up×%d 层)" % [
|
||||
hero_def_negative, clamp_lo, clamp_hi, int(db.settlement.damage_floor), int(db.hero.armor_class), int(db.status_effect_row("ac_up").get("max_stack", 0))])
|
||||
|
||||
print("--- Run A:fresh 池(零挖掘停手轮——无授予,攻击链裸验)---")
|
||||
var run_a := _run_boss(db, seed_val, false)
|
||||
_print_rounds(db, math, run_a)
|
||||
var a_ok := _assert_rounds(db, math, run_a, true)
|
||||
check(a_ok.fresh_and_full, "A1 攻击链健康(fresh 池·掷点口径):非蓄力拍 d_tick = max(下限, 掷点档位基线+附加)(×%d 拍)——H2 攻击链缺陷否证" % a_ok.n_attack)
|
||||
check(a_ok.arith_ok, "A2 池算术完整:全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % a_ok.n_attack)
|
||||
check(String(run_a.result) == "defeat", "A3 攻击链致死可达:fresh 池 Boss 在 %d 轮内击倒英雄(result=%s——伤害确实落地)" % [run_a.snaps.size(), str(run_a.result)])
|
||||
var run_a2 := _run_boss(db, seed_val, false)
|
||||
check(JSON.stringify(run_a.rows) == JSON.stringify(run_a2.rows), "A4 同种子回放:Run A 两遍结算行逐行相同(含 armor_roll 掷点流)")
|
||||
|
||||
print("--- Run B:自动挖掘(授予照常发生——当前数据承伤行为实测)---")
|
||||
var run_b := _run_boss(db, seed_val, true)
|
||||
_print_rounds(db, math, run_b)
|
||||
var b_ok := _assert_rounds(db, math, run_b, false)
|
||||
check(b_ok.arith_ok, "B1 池算术完整(含授予轮):全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % b_ok.n_attack)
|
||||
check(b_ok.wall_tick_positive, "B2 负池轮不归零(伤害下限):hero_def<0 的轮 d_tick 恒 ≥ damage_floor(×%d 轮,最深实测池 %d)" % [b_ok.n_wall, b_ok.deepest])
|
||||
check(hero_def_negative * 16 > clamp_lo or hero_def_negative >= 0, "B3 墙归零不可达(表值判定):hero_defense 池最负行 %d——叠满不可触池底 %d(×0 免伤墙结构面已随 AC 换装消失)" % [hero_def_negative, clamp_lo])
|
||||
|
||||
|
||||
func _run_boss(db, seed_val: int, dig: bool) -> Dictionary:
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(seed_val)
|
||||
var settlement = load("res://core/battle_orchestrator.gd").new(db, rng)
|
||||
var cap := {"rows": [], "snaps": [], "result": ""}
|
||||
settlement.step_row.connect(func(row): cap["rows"].append(row))
|
||||
settlement.settlement_snapshot.connect(func(s): cap["snaps"].append(s))
|
||||
settlement.battle_finished.connect(func(p): cap["result"] = String(p.get("result", "")))
|
||||
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.hero.max_hp), "node_type": "boss"}):
|
||||
cap["result"] = "begin_failed"
|
||||
return cap
|
||||
var rounds := 0
|
||||
while settlement.battle_result == "" and rounds < 40:
|
||||
rounds += 1
|
||||
if dig:
|
||||
BattleDriver.drive_round(settlement)
|
||||
else:
|
||||
settlement.minesweeper.settle_batch([{"action": "stop"}])
|
||||
return cap
|
||||
|
||||
|
||||
# 逐轮:授予行汇总 → 重建战斗段时点池值(effect_pool_map 表驱动归属)→ 敌行动行逐行打印
|
||||
func _print_rounds(db, math, run: Dictionary) -> void:
|
||||
var groups := _round_groups(run.rows)
|
||||
var beats := _played_beats(run, db)
|
||||
var rounds: Array = groups.keys()
|
||||
rounds.sort()
|
||||
for ri in rounds:
|
||||
var rows: Array = groups[ri]
|
||||
var an := _analyze_round(db, math, rows, beats)
|
||||
var grant_txt := "(无)"
|
||||
if not an.grants.is_empty():
|
||||
grant_txt = " · ".join(an.grants)
|
||||
print(" [R%d] 授予: %s" % [ri, grant_txt])
|
||||
for line in an.lines:
|
||||
print(" " + String(line))
|
||||
var hp_txt := ""
|
||||
if int(ri) - 1 < run.snaps.size():
|
||||
var s: Dictionary = run.snaps[int(ri) - 1]
|
||||
var hero: Dictionary = s.get("hero", {})
|
||||
hp_txt = " · hero %d/%d · verdict %s" % [int(hero.get("hp", -1)), int(hero.get("max_hp", -1)), String(s.get("verdict", ""))]
|
||||
print(" [R%d] 池值@战斗段: hero_def=%d enemy_out=%d%s" % [ri, an.pd, an.pe, hp_txt])
|
||||
|
||||
|
||||
func _assert_rounds(db, math, run: Dictionary, fresh_mode: bool) -> Dictionary:
|
||||
var groups := _round_groups(run.rows)
|
||||
var beats := _played_beats(run, db)
|
||||
var rounds: Array = groups.keys()
|
||||
rounds.sort()
|
||||
var out := {"fresh_and_full": true, "arith_ok": true, "wall_tick_positive": true,
|
||||
"n_attack": 0, "n_wall": 0, "deepest": 0}
|
||||
for ri in rounds:
|
||||
var an := _analyze_round(db, math, groups[ri], beats)
|
||||
for t in an.ticks:
|
||||
out.n_attack += 1
|
||||
if fresh_mode and (an.pd != 0 or an.pe != 0):
|
||||
out.fresh_and_full = false
|
||||
if not bool(t.ok):
|
||||
out.arith_ok = false
|
||||
if fresh_mode and (int(t.tick) <= 0 or int(t.raw) <= 0):
|
||||
out.fresh_and_full = false
|
||||
if an.pd < 0:
|
||||
out.n_wall += 1
|
||||
out.deepest = mini(out.deepest, an.pd)
|
||||
if int(t.tick) <= 0:
|
||||
out.wall_tick_positive = false
|
||||
return out
|
||||
|
||||
|
||||
func _round_groups(rows: Array) -> Dictionary:
|
||||
var groups := {}
|
||||
for row in rows:
|
||||
var ri := int(row.get("round_index", 0))
|
||||
if not groups.has(ri):
|
||||
groups[ri] = []
|
||||
groups[ri].append(row)
|
||||
return groups
|
||||
|
||||
|
||||
func _analyze_round(db, math, rows: Array, beats: Dictionary) -> Dictionary:
|
||||
var last_pool := {}
|
||||
var grants: Array = []
|
||||
for row in rows:
|
||||
if String(row.get("type", "")) == "effect":
|
||||
var eid := String(row.get("effect_id", ""))
|
||||
var pv := int(row.get("pool_value", 0))
|
||||
last_pool[eid] = pv
|
||||
grants.append("%s→池%d" % [String(row.get("display_name", "")), pv])
|
||||
# 池归属表驱动(settlement.effect_pool_map),percent 池加算后 clamp(战斗结算 F2 同口径)
|
||||
var pools := {"hero_defense": 0, "enemy_output": 0, "hero_offense": 0, "flat_attach": 0}
|
||||
for eid in last_pool:
|
||||
var pool_name: String = String(db.settlement.effect_pool_map.get(eid, ""))
|
||||
if pools.has(pool_name):
|
||||
pools[pool_name] += int(last_pool[eid])
|
||||
var clamp_lo: int = int(db.settlement.pool_clamp_lo)
|
||||
var clamp_hi: int = int(db.settlement.pool_clamp_hi)
|
||||
var pd: int = clampi(int(pools["hero_defense"]), clamp_lo, clamp_hi)
|
||||
var pe: int = clampi(int(pools["enemy_output"]), clamp_lo, clamp_hi)
|
||||
var lines: Array = []
|
||||
var ticks: Array = []
|
||||
for row in rows:
|
||||
var t := String(row.get("type", ""))
|
||||
if t == "cast":
|
||||
lines.append("拍=cast(施放行零兑付——增益技不读池,技能池批由蓄力行改型)")
|
||||
elif t == "damage" and String(row.get("source_class", "")) == "enemy_action":
|
||||
var baseline: int = int(row.get("baseline", 0))
|
||||
var attach: int = int(row.get("attach", 0))
|
||||
# 掷点口径重建:基线按行留档档位打折(暴击 ×倍率),附加必中
|
||||
var tier: int = int(row.get("tier", 100))
|
||||
var crit: bool = bool(row.get("crit", false))
|
||||
var base_factor: float = float(tier) * (float(db.settlement.crit_multiplier) / 100.0 if crit else 1.0)
|
||||
var rolled_base := int(floor(float(baseline) * base_factor / 100.0))
|
||||
var raw: int = rolled_base + attach
|
||||
var tick: int = int(row.get("value", 0))
|
||||
var expected: int = maxi(int(db.settlement.damage_floor), math.pool_damage(raw, pd, pe))
|
||||
var roll_txt := "掷%s" % str(int(row.get("roll", -1)))
|
||||
var zeroed := "✓" if tick == expected else "✗ 算术不符!"
|
||||
lines.append("拍=%s %s/档%d%s 基线%d→掷后%d 附加%d hero_def=%d enemy_out=%d d_tick=%d 期望=%d %s" % [
|
||||
String(beats.get(int(row.get("round_index", 0)), "?")), roll_txt, tier, "暴击" if crit else "",
|
||||
baseline, rolled_base, attach, pd, pe, tick, expected, zeroed])
|
||||
ticks.append({"raw": raw, "tick": tick, "expected": expected, "ok": tick == expected})
|
||||
elif t == "damage" and String(row.get("source_class", "")) == "hero_attack":
|
||||
lines.append("主角攻击 %d(普通 %d + 穿刺 %d + 魔法 %d)→ %s" % [
|
||||
int(row.get("value", 0)), int(row.get("normal", 0)), int(row.get("pierce", 0)), int(row.get("magic", 0)), String(row.get("target", ""))])
|
||||
return {"pd": pd, "pe": pe, "grants": grants, "lines": lines, "ticks": ticks}
|
||||
|
||||
|
||||
# 拍名重建:pending_skill 字段 = 该结算开轮摇定的动作 id(快照取在结算末尾)——
|
||||
# round r 的拍 = 快照 r 的 enemies[0].pending_skill。快照 enemies 空时(敌死亡轮)回退 "?"——该轮敌已无行动行。
|
||||
func _played_beats(run: Dictionary, db) -> Dictionary:
|
||||
var beats := {}
|
||||
for r in range(1, run.snaps.size() + 1):
|
||||
var s: Dictionary = run.snaps[r - 1]
|
||||
var en: Array = s.get("enemies", [])
|
||||
if en.size() > 0:
|
||||
beats[r] = String(en[0].get("pending_skill", "?"))
|
||||
return beats
|
||||
|
||||
|
||||
# ==================== level 模式:全关遭遇回放(Bug#1 证据面) ====================
|
||||
|
||||
func _probe_level(db, seed_val: int) -> void:
|
||||
print("--- 全关回放 seed %d(战×2→道具→战×2→恢复→Boss)---" % seed_val)
|
||||
var prediction := _encounter_prediction(db, seed_val)
|
||||
var cap := _run_level(db, seed_val)
|
||||
var battles: Array = cap.battles
|
||||
for b in battles:
|
||||
var depth: int = int(b.get("depth", -1))
|
||||
var spawn: int = int(b.get("spawn", -1))
|
||||
var predicted: int = int(prediction.get(depth, -1))
|
||||
var verdict_txt := _battle_verdict_txt(cap, depth)
|
||||
var match_txt := "✓" if spawn == predicted else "✗ 与遭遇骰预测不符!"
|
||||
print(" d%d %s: spawn=%d(遭遇骰预测 %d %s)%s" % [depth, String(b.get("type", "")), spawn, predicted, match_txt, verdict_txt])
|
||||
var all_match := true
|
||||
var all_ge_1 := true
|
||||
for b in battles:
|
||||
var spawn2: int = int(b.get("spawn", -1))
|
||||
if spawn2 < 1:
|
||||
all_ge_1 = false
|
||||
if spawn2 != int(prediction.get(int(b.get("depth", -1)), -2)):
|
||||
all_match = false
|
||||
check(all_ge_1, "全部战斗 spawn ≥ 1(难度曲线与Boss Rule 8 区间下限——空遭遇在当前实现不可达)")
|
||||
check(all_match, "spawn 与独立遭遇骰预测逐战斗一致(encounter 封闭流同种子可回放)")
|
||||
var expect_battles := _battle_nodes_upto(db, 1, int(cap.get("end_node", -1)))
|
||||
check(battles.size() == expect_battles, "战斗推进数与节点链一致(实测 %d/预期 %d——败北截断或全链跑通)" % [battles.size(), expect_battles])
|
||||
print(" 关卡结果: %s" % str(cap.result))
|
||||
print("--- 多种子 spawn 扫描(报告 Bug#1 回归面:20 种子 × 5 战斗节点)---")
|
||||
var scan := _scan_spawns(db, seed_val, 20)
|
||||
check(scan.bad == 0, "%d 场战斗 spawn 全域内(∈[unit_count, unit_count_max] 且 ≥1)——越域 %d 场" % [scan.total, scan.bad])
|
||||
|
||||
|
||||
func _encounter_prediction(db, seed_val: int) -> Dictionary:
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(seed_val)
|
||||
var out := {}
|
||||
var scope := 0
|
||||
var lrow := _level_row(db, 1)
|
||||
for node in lrow.get("nodes", []):
|
||||
var t := String(node.get("type", ""))
|
||||
if t != "battle" and t != "boss":
|
||||
continue
|
||||
var depth := int(node.get("node_depth", -1))
|
||||
var row: Dictionary = db.resolve_enemy_rowset(1, depth, t)[0] # 行集首行(row_kind 随节点类型)
|
||||
var lo := int(row.get("unit_count", 1))
|
||||
var hi := int(row.get("unit_count_max", lo))
|
||||
var rolled := lo
|
||||
if hi > lo:
|
||||
var rg := RandomNumberGenerator.new()
|
||||
rg.seed = rng.derive_at(scope)
|
||||
scope += 1
|
||||
rolled = lo + rg.randi_range(0, hi - lo)
|
||||
out[depth] = rolled
|
||||
return out
|
||||
|
||||
|
||||
func _level_row(db, level_index: int) -> Dictionary:
|
||||
for row in db.level.rows:
|
||||
if int(row.level_index) == level_index:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
func _run_level(db, seed_val: int) -> Dictionary:
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(seed_val)
|
||||
var runner = load("res://core/level_runner.gd").new(db, rng)
|
||||
var cap := {"battles": [], "rows": [], "result": "", "end_node": -1}
|
||||
runner.step_row.connect(func(row): cap["rows"].append(row))
|
||||
runner.node_entered.connect(_on_level_node_entered.bind(runner, cap))
|
||||
runner.round_started.connect(_on_level_round_started.bind(runner, cap))
|
||||
runner.level_finished.connect(_on_level_finished.bind(cap))
|
||||
runner.start_level(1, seed_val)
|
||||
var guard_battles := 20
|
||||
while cap["result"] == "" and guard_battles > 0:
|
||||
guard_battles -= 1
|
||||
var settlement = runner.current_battle()
|
||||
if settlement == null:
|
||||
break
|
||||
_drive_battle(runner, settlement, cap)
|
||||
return cap
|
||||
|
||||
|
||||
func _on_level_finished(p: Dictionary, cap: Dictionary) -> void:
|
||||
cap["result"] = String(p.get("result", ""))
|
||||
cap["end_node"] = int(p.get("node_index", -1))
|
||||
|
||||
|
||||
func _battle_nodes_upto(db, level_index: int, node_idx: int) -> int:
|
||||
var n := 0
|
||||
var nodes: Array = _level_row(db, level_index).get("nodes", [])
|
||||
for i in range(mini(node_idx + 1, nodes.size())):
|
||||
var t := String(nodes[i].get("type", ""))
|
||||
if t == "battle" or t == "boss":
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func _on_level_node_entered(node: Dictionary, _info: Dictionary, runner, cap: Dictionary) -> void:
|
||||
var t := String(node.get("type", ""))
|
||||
if t == "battle" or t == "boss":
|
||||
cap["battles"].append({"type": t, "depth": int(node.get("node_depth", -1)), "spawn": -1})
|
||||
else:
|
||||
runner.continue_after_node()
|
||||
|
||||
|
||||
func _on_level_round_started(ri: int, runner, cap: Dictionary) -> void:
|
||||
if ri != 1 or cap["battles"].is_empty():
|
||||
return
|
||||
var settlement = runner.current_battle()
|
||||
if settlement == null:
|
||||
return
|
||||
var s: Dictionary = settlement.presentation_snapshot()
|
||||
cap["battles"][cap["battles"].size() - 1]["spawn"] = s.enemies.size()
|
||||
|
||||
|
||||
func _drive_battle(runner, settlement, cap: Dictionary) -> void:
|
||||
var round_guard := 60
|
||||
while cap["result"] == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
|
||||
round_guard -= 1
|
||||
BattleDriver.drive_round(settlement, func() -> bool:
|
||||
return runner.current_battle() == settlement and settlement.battle_result == "")
|
||||
|
||||
|
||||
# 战斗判定摘要:round-1 victory 的战斗必须能从结算行分辨「首轮全灭」vs「空场」——
|
||||
# 判据:首轮存在伤害行 = 有敌且被杀(wipe);零战斗行 = 空场(Bug#1 特征)
|
||||
func _battle_verdict_txt(cap: Dictionary, depth: int) -> String:
|
||||
# 逐战斗行段切分:round_index 回卷 = 新战斗(defense_scan 同口径)
|
||||
var battle_rows: Array = []
|
||||
var segs: Array = []
|
||||
var last_round := 0
|
||||
for row in cap.rows:
|
||||
var r: int = int(row.get("round_index", 0))
|
||||
if r < last_round:
|
||||
segs.append(battle_rows)
|
||||
battle_rows = []
|
||||
last_round = r
|
||||
battle_rows.append(row)
|
||||
if not battle_rows.is_empty():
|
||||
segs.append(battle_rows)
|
||||
var target := _battle_index_of_depth(depth, cap)
|
||||
if target >= 0 and target < segs.size():
|
||||
return _summarize_rows(segs[target])
|
||||
return ""
|
||||
|
||||
|
||||
func _battle_index_of_depth(depth: int, cap: Dictionary) -> int:
|
||||
for i in cap["battles"].size():
|
||||
if int(cap["battles"][i].get("depth", -1)) == depth:
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
func _summarize_rows(rows: Array) -> String:
|
||||
var verdict := ""
|
||||
var r1_rows: Array = []
|
||||
for row in rows:
|
||||
if String(row.get("type", "")) == "verdict":
|
||||
verdict = String(row.get("display_name", ""))
|
||||
if int(row.get("round_index", 0)) == 1:
|
||||
r1_rows.append(row)
|
||||
if verdict != "战斗胜利" or r1_rows.is_empty():
|
||||
return "· %s(%d 行)" % [verdict, rows.size()]
|
||||
var combat_rows := 0
|
||||
var kills := 0
|
||||
for row in r1_rows:
|
||||
var t := String(row.get("type", ""))
|
||||
if t == "damage":
|
||||
combat_rows += 1
|
||||
if bool(row.get("killed", false)):
|
||||
kills += 1
|
||||
if combat_rows > 0:
|
||||
return "· round-1 胜利 = 首轮全灭(wipe):战斗行 ×%d、击杀 ×%d——非空场" % [combat_rows, kills]
|
||||
return "· round-1 胜利且零战斗行 = 空场特征(Bug#1)!"
|
||||
|
||||
|
||||
func _scan_spawns(db, seed0: int, n_seeds: int) -> Dictionary:
|
||||
var depths: Array = []
|
||||
for node in _level_row(db, 1).get("nodes", []):
|
||||
var t := String(node.get("type", ""))
|
||||
if t == "battle" or t == "boss":
|
||||
depths.append({"depth": int(node.get("node_depth", -1)), "kind": t})
|
||||
var total := 0
|
||||
var bad := 0
|
||||
for i in range(n_seeds):
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(seed0 + i)
|
||||
for dep in depths:
|
||||
var depth: int = int(dep.get("depth", -1))
|
||||
var kind: String = String(dep.get("kind", "battle"))
|
||||
total += 1
|
||||
var settlement = load("res://core/battle_orchestrator.gd").new(db, rng)
|
||||
if not settlement.begin_battle({"level_index": 1, "node_depth": depth, "node_type": kind, "hero_hp": int(db.hero.max_hp)}):
|
||||
bad += 1
|
||||
continue
|
||||
var n: int = settlement.presentation_snapshot().enemies.size()
|
||||
var row: Dictionary = db.resolve_enemy_rowset(1, depth, kind)[0] # 行集首行(同上,row_kind 随节点类型)
|
||||
var lo := int(row.get("unit_count", 1))
|
||||
var hi := int(row.get("unit_count_max", lo))
|
||||
if n < 1 or n < lo or n > hi:
|
||||
bad += 1
|
||||
return {"total": total, "bad": bad}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cl02pun3o8wcj
|
||||
@@ -0,0 +1,84 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# playtest-2026-09-12 Q1 复核——"安全格减伤是否过量导致满血打完 Boss"
|
||||
# 口径:逐结算轮统计 ac_up 授予层数(AC10→11→12)与该轮敌方实伤,
|
||||
# 分割"高 AC 轮"(叠满 = AC12,期望减免 28.75%)与基线轮(AC10,期望减免 21.25%)的承伤读数。
|
||||
# 运行:Godot_console.exe --headless --path . --script res://sim/defense_scan.gd -- [N] [seed0]
|
||||
extends SceneTree
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var n := 8
|
||||
if args.size() >= 1 and args[0].is_valid_int():
|
||||
n = maxi(2, int(args[0]))
|
||||
var seed0 := 42
|
||||
if args.size() >= 2 and args[1].is_valid_int():
|
||||
seed0 = int(args[1])
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
if not db.load_tables("res://data/tables", false):
|
||||
print("表加载失败")
|
||||
quit(1)
|
||||
return
|
||||
var autoplay = load("res://sim/level_autoplay.gd")
|
||||
print("=== 盾牌加护叠层扫描(AC 口径):第一关 · N=%d · 种子 %d+%d ===" % [n, seed0, n])
|
||||
print("%-6s %-6s %-8s %-12s %-14s %-14s" % ["seed", "轮数", "战斗轮", "满层轮(AC12)", "满层轮承伤", "非满层轮承伤"])
|
||||
var full_rounds := 0
|
||||
var open_rounds := 0
|
||||
var full_dmg := 0
|
||||
var open_dmg := 0
|
||||
var stack_total := 0
|
||||
var combat_rounds := 0
|
||||
for i in range(n):
|
||||
var st = autoplay.run_level(db, seed0 + i, 1, 0, 0, true)
|
||||
# 逐轮聚合(按战斗分段——round_index 跨战斗重置,直接用轮号会串桶):
|
||||
# effect 行 effect_id=ac_up 计盾牌加护层;enemy_action damage 计承伤
|
||||
var ac_by_round := {}
|
||||
var dmg_by_round := {}
|
||||
var battle_seq := 0
|
||||
var last_round := 0
|
||||
for row in st.rows:
|
||||
var r: int = int(row.get("round_index", 0))
|
||||
if r < last_round:
|
||||
battle_seq += 1 # 轮号回卷 = 新战斗
|
||||
last_round = r
|
||||
var key := battle_seq * 1000 + r
|
||||
if String(row.get("type", "")) == "effect" and String(row.get("effect_id", "")) == "ac_up" \
|
||||
and String(row.get("source_class", "")) == "safe_cell":
|
||||
ac_by_round[key] = int(ac_by_round.get(key, 0)) + 1
|
||||
elif String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
|
||||
dmg_by_round[key] = int(dmg_by_round.get(key, 0)) + int(row.get("value", 0))
|
||||
for k in ac_by_round:
|
||||
combat_rounds += 1
|
||||
stack_total += int(ac_by_round[k])
|
||||
var d: int = int(dmg_by_round.get(k, 0))
|
||||
if int(ac_by_round[k]) >= 2:
|
||||
full_rounds += 1
|
||||
full_dmg += d
|
||||
else:
|
||||
open_rounds += 1
|
||||
open_dmg += d
|
||||
print("%-6d %-6d %-8d %-12d %-14s %-14s" % [seed0 + i, st.rounds, ac_by_round.size(),
|
||||
_full_count(ac_by_round), str(dmg_in(dmg_by_round, ac_by_round, 2)), str(dmg_in(dmg_by_round, ac_by_round, 1))])
|
||||
if combat_rounds > 0:
|
||||
print("---- 战斗轮 %d:满层轮 %d(%.0f%%,均值 %.2f 层/轮)· 满层轮承伤合计 %d(AC12——期望减免 28.75%%)· 非满层轮承伤合计 %d(均 %.1f/轮,AC10/11)" % [
|
||||
combat_rounds, full_rounds, 100.0 * float(full_rounds) / float(combat_rounds),
|
||||
float(stack_total) / float(combat_rounds), full_dmg, open_dmg, float(open_dmg) / float(maxi(1, open_rounds))])
|
||||
quit(0)
|
||||
|
||||
|
||||
func _full_count(ac_by_round: Dictionary) -> int:
|
||||
var c := 0
|
||||
for r in ac_by_round:
|
||||
if int(ac_by_round[r]) >= 2:
|
||||
c += 1
|
||||
return c
|
||||
|
||||
|
||||
func dmg_in(dmg_by_round: Dictionary, ac_by_round: Dictionary, mode: int) -> int:
|
||||
# mode=2 满层轮承伤 / mode=1 非满层轮承伤
|
||||
var t := 0
|
||||
for r in ac_by_round:
|
||||
var full: bool = int(ac_by_round[r]) >= 2
|
||||
if (mode == 2 and full) or (mode == 1 and not full):
|
||||
t += int(dmg_by_round.get(r, 0))
|
||||
return t
|
||||
@@ -0,0 +1 @@
|
||||
uid://drne7y5gn62xe
|
||||
@@ -0,0 +1,36 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
|
||||
# 一次性检查脚本:打印一场完整战斗的结算步骤流 + 棋盘摘要(肉眼验收 P2 归因质量)。
|
||||
# 运行:Godot_console.exe --headless --path . --script res://sim/dump_battle.gd
|
||||
extends SceneTree
|
||||
|
||||
const BattleDriver := preload("res://sim/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
|
||||
|
||||
func _initialize() -> void:
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
db.load_tables("res://data/tables", false)
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(42) # 种子 = 42;第二个命令行参数可覆盖(关卡 种子)
|
||||
var settlement = load("res://core/battle_orchestrator.gd").new(db, rng)
|
||||
var stream: Array = []
|
||||
settlement.step_row.connect(func(row): stream.append(row))
|
||||
var level := 2
|
||||
if OS.get_cmdline_user_args().size() > 0:
|
||||
level = int(OS.get_cmdline_user_args()[0])
|
||||
if OS.get_cmdline_user_args().size() > 1:
|
||||
rng.set_master_seed(int(OS.get_cmdline_user_args()[1]))
|
||||
settlement.begin_battle({"level_index": level, "node_depth": 0, "hero_hp": db.hero.max_hp})
|
||||
print("=== 棋盘摘要 L%d ===" % level)
|
||||
var bd = settlement.minesweeper.current_board()
|
||||
print("尺寸 %dx%d · 雷 %d · 安全格 %d · R₀ %d" % [bd.width, bd.height, bd.mines.size(), bd.safe_cells().size(), bd.metrics.r0_count])
|
||||
print("指标: inferability=%.3f attempts=%s accept=%s" % [bd.metrics.inferability, bd.metrics.attempts_used, bd.metrics.accept_mode])
|
||||
var rounds := 0
|
||||
while settlement.battle_result == "" and rounds < 40:
|
||||
rounds += 1
|
||||
BattleDriver.drive_round(settlement) # T6 收口:原内联循环语义同型(移动期免 AP——移动不换轮,移位后重查 break 分支为防御性等价)
|
||||
print("战斗结果: %s(%d 轮)" % [settlement.battle_result, rounds])
|
||||
print("=== 结算步骤流(%d 行)===" % stream.size())
|
||||
for row in stream:
|
||||
print(JSON.stringify(row))
|
||||
quit(0)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfe2mdbuakpk4
|
||||
@@ -0,0 +1,68 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 一次性实验:不同 mine_density 下的 R₀ 占比 / 幸存雷数 / 残余挖掘量 / 推理率——为切片临时调参提供数据。
|
||||
extends SceneTree
|
||||
|
||||
func _initialize() -> void:
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
db.load_tables("res://data/tables", true)
|
||||
var board_generator = load("res://core/board_generator.gd").new()
|
||||
print("=== 密度实验(L1 10×10 target0.88 / L2 12×12 target0.90,各 8 种子)===")
|
||||
print("d\t|M|/N\tR₀\tR₀/|S|\t残余挖掘\tinf\tatt")
|
||||
for d in [0.22, 0.25, 0.28, 0.30, 0.33]:
|
||||
for cfg in [[10, 10, 0.88, 50], [12, 12, 0.90, 60]]:
|
||||
var w: int = cfg[0]
|
||||
var h: int = cfg[1]
|
||||
var t: float = cfg[2]
|
||||
var ms: int = cfg[3]
|
||||
var agg := {"m": 0, "r0": 0, "rem": 0, "inf": 0.0, "att": 0, "n": 0}
|
||||
for s in range(8):
|
||||
var row := {
|
||||
"level_index": 1, "node_depth": 0, "board_width": w, "board_height": h,
|
||||
"mine_density": d, "inferability_target": t, "solver_rules": "single_subset",
|
||||
"min_safe_count": ms, "max_gen_attempts": 16,
|
||||
"board_type": "dungeon",
|
||||
}
|
||||
_patch_row(row, d)
|
||||
var params := _params_from(row)
|
||||
var rg := RandomNumberGenerator.new()
|
||||
rg.seed = 1000 + s
|
||||
var b = board_generator.generate(1, 0, rg, _db(db, row))
|
||||
if b == null:
|
||||
continue
|
||||
agg.m += b.mines.size()
|
||||
agg.r0 += int(b.metrics.r0_count)
|
||||
agg.rem += b.safe_cells().size() - int(b.metrics.r0_count)
|
||||
agg.inf += float(b.metrics.inferability)
|
||||
agg.att += int(b.metrics.attempts_used)
|
||||
agg.n += 1
|
||||
if agg.n > 0:
|
||||
var n := float(agg.n)
|
||||
var n_placed: int = int(round(d * float(w * h - 9)))
|
||||
print("%.2f %dx%d\t%d/%d\t%d\t%.2f\t%d\t%.3f\t%.1f" % [d, w, h, int(agg.m / n + 0.5), n_placed, int(agg.r0 / n + 0.5), agg.r0 / n / (w * h - int(agg.m / n + 0.5)), int(agg.rem / n + 0.5), agg.inf / n, agg.att / n])
|
||||
quit(0)
|
||||
|
||||
# --- 简易 db 替身:只供给本实验的行 ---
|
||||
var _row_override: Dictionary = {}
|
||||
|
||||
func _patch_row(row: Dictionary, _d: float) -> void:
|
||||
_row_override = row
|
||||
|
||||
func _params_from(row: Dictionary) -> Dictionary:
|
||||
return {
|
||||
"L": 1, "D": 0, "w": int(row.board_width), "h": int(row.board_height),
|
||||
"density": float(row.mine_density), "target": float(row.inferability_target),
|
||||
"solver_rules": "single_subset", "min_safe": int(row.min_safe_count),
|
||||
"max_attempts": int(row.max_gen_attempts), "board_type": "dungeon",
|
||||
}
|
||||
|
||||
class FakeDB:
|
||||
var row: Dictionary
|
||||
func resolve_board_gen(_L: int, _D: int) -> Dictionary:
|
||||
return row
|
||||
func status_effect_row(_id: String) -> Dictionary:
|
||||
return {}
|
||||
|
||||
func _db(_real, row: Dictionary):
|
||||
var f := FakeDB.new()
|
||||
f.row = row
|
||||
return f
|
||||
@@ -0,0 +1 @@
|
||||
uid://cg7jkrsc03rar
|
||||
@@ -0,0 +1,620 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 日志合规:任何模拟运行与真人试玩同日志——操作记录 / 轮次状态 / 战斗结束 / 通关结束 全量 JSONL。
|
||||
# logger=null 时自建会话(每局一文件),批量调用方传共享 logger(log_run 局次标记切分)。
|
||||
# 关卡自动通关策略(理性玩家近似)——呈现层数值调整板的批量模拟与 headless 扫描
|
||||
# 共用同一实现,保证"面板模拟读数"与"扫描表读数"同口径。策略与 smoke_all 战斗自动玩家同族:
|
||||
# 挖最近可挖格 / 无可挖停手 / 奖励恢复点立即确认。
|
||||
# run_level = 机制夹具/单战锚点专用(smoke_all 机制断言、rhythm/defense/damage 单战扫描)——
|
||||
# 通关率/难度类测量禁用本入口(满血零上下文进关,与真实冒险条件通关率脱节),一律走 run_adventure。
|
||||
# 策略档 policy:"normal"(默认)/ "perfect"(零盲挖/全封印/卡死刷新——上界测量仪,求解器与
|
||||
# 动作语义见 auto_player.gd 完美策略档节)。
|
||||
# 选路驱动(零自动推进):兑付确认/战斗胜利后 runner 进选路态,驱动循环按 route 策略显式
|
||||
# pick_next_node;route = "min"/"max"(候选节点编号最小/最大——确定性路径轴,零随机流自开;默认 "min")。
|
||||
# run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、通关兑付停点确认、adventure_finished
|
||||
# 置 result/summary;外层每关重取 runner 实例经 _drive_level 复用单关驱动)。
|
||||
# 大奖励停点 = 三选一驱动(候选取首件/替换放弃新件)。
|
||||
# 普通档 v3 = 完美策略 + 受控失误注入(失误流走独立策略 RNG——种子派生自局种子,逐局确定性/
|
||||
# 可回放保持,核心七流零消费零扰动;novice_action 留库不删——历史读数对账面)。
|
||||
# 完美档上界:选路目标价值评分(rest 伤重优先/shop 有钱优先/boss 缓冲)+ 商店贪心买齐。
|
||||
# route 轴:评分同分 tie-break 仍按 min/max 编号方向——verifier 双窗轴不塌。
|
||||
# 运行前提:db 已 load_tables;run_level/run_adventure 均须全新 RngService(同种子 ⇒ 全程逐步可回放)。
|
||||
# 实现注:信号处理体全部为静态方法 + bind 传状态——GDScript 多行 lambda 内嵌 match/控制流的解析不可靠。
|
||||
extends RefCounted
|
||||
|
||||
const AutoPlayer := preload("res://sim/auto_player.gd") # 自动玩家策略单一实现
|
||||
|
||||
# ── 普通档 v3 失误注入参数(踩雷期望=完美+2/板、每板少挖几个正确格)──
|
||||
# 标雷→误挖转化率:主踩雷源=卡死赌挖(不确定性概率命中),误挖只作偶发真手滑——
|
||||
# 真人几乎不挖自己封印的格(p 过高会让首轮已证封印变保底命中,非失误形态)
|
||||
const NORMAL_MINE_SLIP_P := 0.10
|
||||
# 安全挖跳过率:每板安全格 ~70-90 × p ≈ 3-5 次漏挖(信息与推进双损失)
|
||||
const NORMAL_SKIP_DIG_P := 0.05
|
||||
# 空闲提前赌挖率:已证安全格在手时小概率先赌一格——完美系把不确定性攒到残局
|
||||
# (残局 hazard→1=扫雷),真人早赌滚信息(低 hazard 期吃掉不确定性);p×~40 安全 AP/板 ≈ 4 次
|
||||
const NORMAL_IDLE_GAMBLE_P := 0.10
|
||||
|
||||
|
||||
static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: int, collect_rows: bool = false, logger = null, policy: String = "normal", route: String = "min", template_pick: int = -1) -> 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, "level_index": level_index, "atk_pct": atk_pct, "hp_pct": hp_pct, "policy": policy, "route": route})
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(seed_val)
|
||||
var runner = load("res://core/level_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_id": -1, "defeat_node_type": "",
|
||||
"boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false,
|
||||
"log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0, "mine_dmg": 0,
|
||||
"visited_ids": [], "choices": 0, "last_battle_enemies": [],
|
||||
# 封印经济五字段(runsum 读数面;聚合自结算流水 source_class,
|
||||
# 零新随机源零核心改动;η=seal_hit÷seal_placed 与标空率派生归分析侧)
|
||||
"seal_placed": 0, "seal_hit": 0, "seal_miss": 0, "seal_hit_gain": 0, "seal_miss_penalty": 0,
|
||||
"prng": _policy_rng(seed_val), "mine_slips": 0, "skip_digs": 0, "shop_buys": 0,
|
||||
}
|
||||
runner.step_row.connect(_on_step_row.bind(runner, st, collect_rows))
|
||||
runner.node_entered.connect(_on_node_entered.bind(runner, st, logger))
|
||||
runner.level_finished.connect(_on_level_finished.bind(st, logger))
|
||||
runner.set_hero_override(atk_pct, hp_pct)
|
||||
if not runner.start_level(level_index, seed_val, {}, template_pick):
|
||||
st.result = "start_failed"
|
||||
runner.restore_hero_base()
|
||||
if own_session:
|
||||
logger.close_session()
|
||||
return st
|
||||
_drive_level(runner, st, logger, policy, route)
|
||||
if st.result == "":
|
||||
st.result = "timeout"
|
||||
st.timeout = true
|
||||
for v in runner.visited_ids():
|
||||
st.visited_ids.append(int(v))
|
||||
runner.restore_hero_base()
|
||||
st.log_path = logger.log_path()
|
||||
if own_session:
|
||||
logger.close_session()
|
||||
return st
|
||||
|
||||
|
||||
# 单关驱动段(run_level 抽取——零自动推进停点 + 战斗自动玩家;policy 经 st 入 _autoplay_battle)
|
||||
# 入口停点(node_index 尚 -1 = 进节点前无 current_node)也经本分支驱动——
|
||||
# 读 next_candidate_ids 候选(入口单候选)→ pick_next_node,同一 _enter_node 等价(零 RNG 漂移);
|
||||
# 入口 pick 不计 st.choices(单候选强制选路,计数口径与测量面同构)。
|
||||
static func _drive_level(runner, st: Dictionary, logger, policy: String, route: String) -> void:
|
||||
st["policy"] = policy
|
||||
var guard_battles := 20
|
||||
var guard_actions := 200
|
||||
while st.result == "" and guard_battles > 0 and guard_actions > 0:
|
||||
guard_actions -= 1
|
||||
if runner.awaiting_choice():
|
||||
# 选路态(入口停点 / 兑付确认 / 战斗胜利后的零自动推进停点)——route 策略显式选下一跳
|
||||
if runner.node_index >= 0:
|
||||
st.choices = int(st.choices) + 1
|
||||
runner.pick_next_node(_route_pick(runner, route, st))
|
||||
continue
|
||||
guard_battles -= 1
|
||||
var this_settlement = runner.current_battle()
|
||||
if this_settlement == null:
|
||||
break # level_finished 已发(settlement 置 null)而信号尚未置 result 的防御
|
||||
_autoplay_battle(runner, this_settlement, st, logger)
|
||||
|
||||
|
||||
# 三关连探冒险驱动——AdventureRunner 编排:主种子恰一次
|
||||
# (start_adventure 唯一 new_adventure 调用点)、大奖励立即确认、冒险终局置 result/summary。
|
||||
# 返回结构 = run_level 同族 + summary / wallet_events / encounter_max_hp_seq / reward_events /
|
||||
# visited_ids(全冒险节点行进序列——跨关累计,非末关 runner 的 visited 随实例更替,此处为唯一全量面)。
|
||||
# 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",
|
||||
hero_override: Dictionary = {}, attune: Dictionary = {}, shop: Dictionary = {},
|
||||
relic: 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, "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_id": -1, "defeat_node_type": "",
|
||||
"boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false,
|
||||
"log_path": "", "policy": policy, "refreshes": 0, "mine_hits": 0, "mine_dmg": 0,
|
||||
"visited_ids": [], "choices": 0, "last_battle_enemies": [],
|
||||
"summary": {}, "wallet_events": [], "encounter_max_hp_seq": [], "reward_events": [],
|
||||
"feat_mode": String(relic.get("mode", "off")), # 特性分层开关(off=316-f 通用竞争/on=成型画像优先捕捞 relic)
|
||||
"shop_gp_seq": [], # 商店房到达余额序(逐关 1 次——GP 跨关延续验收取数面)
|
||||
# 封印经济五字段(与 run_level 同口径,见彼处注释)
|
||||
"seal_placed": 0, "seal_hit": 0, "seal_miss": 0, "seal_hit_gain": 0, "seal_miss_penalty": 0,
|
||||
"prng": _policy_rng(seed_val), "mine_slips": 0, "skip_digs": 0, "shop_buys": 0,
|
||||
"adv": true, # v3 档位行为开关(选路评分/失误注入/商店购买只挂冒险测量面——单关夹具语义不变)
|
||||
|
||||
"level_templates": [], # 逐关图模板名(关卡节点图生成 v0.4 分层出数)
|
||||
}
|
||||
adr.step_row.connect(_on_step_row.bind(adr, st, collect_rows))
|
||||
adr.node_entered.connect(_on_adventure_node_entered.bind(adr, st, logger))
|
||||
adr.wallet_changed.connect(_on_adventure_wallet.bind(st, logger))
|
||||
# 三选一停点驱动(确定性策略候选取首件、
|
||||
# 兑付后推进下一关——零随机流自开,同种子逐位可复放;无满位替换策略)
|
||||
adr.curio_choice_presented.connect(_on_adventure_bonus.bind(st, adr))
|
||||
adr.curio_resolved.connect(_on_adventure_curio_resolved.bind(st, adr))
|
||||
adr.adventure_finished.connect(_on_adventure_finished.bind(st, logger))
|
||||
adr.set_hero_override(atk_pct, hp_pct)
|
||||
# 同调分层注入(缺省 {} 与既有调用方逐位一致零行为变更)
|
||||
var adv_params := {"hero_override": hero_override, "first_script": false}
|
||||
if not attune.is_empty():
|
||||
adv_params["attunement"] = attune.get("equipped", [])
|
||||
adv_params["attune_refresh_y"] = int(attune.get("refresh_y", 0))
|
||||
# 特性注入(formed 画像=开局注入七流派各 2 件档一成型集;测量面专用)
|
||||
if relic.has("seed_ids"):
|
||||
adv_params["relics_seed"] = relic.get("seed_ids", [])
|
||||
# 树规则键注入(A/B 面读数载体:fork 激活侧 effects rule 键集透传)
|
||||
if relic.has("tree_rules"):
|
||||
adv_params["tree_rules"] = relic.get("tree_rules", [])
|
||||
# 商店分层注入(缺省 legacy 与既有调用方逐位一致零行为变更;pool=共享池货架面)
|
||||
var pool_shop: bool = String(shop.get("mode", "")) == "pool"
|
||||
if pool_shop:
|
||||
adv_params["shop_mode"] = "pool"
|
||||
st["shop_mode"] = "pool"
|
||||
if not adr.start_adventure(seed_val, adv_params): # 测量夹具恒抽签(v0.4——首次脚本分支不进任何读数)
|
||||
st.result = "start_failed"
|
||||
if own_session:
|
||||
logger.close_session()
|
||||
return st
|
||||
var guard_drives := 10 # 每次外层迭代 = 驱动一关(关卡更替由三选一兑付确认与 runner 重取承载)
|
||||
while st.result == "" and guard_drives > 0:
|
||||
guard_drives -= 1
|
||||
_drive_level(adr.runner(), st, logger, policy, route)
|
||||
if st.result == "":
|
||||
st.result = "timeout"
|
||||
st.timeout = true
|
||||
if adr.runner() != null:
|
||||
adr.runner().restore_hero_base()
|
||||
if pool_shop:
|
||||
# 终局池读数(摘要 additive 键 pool_stock:_settle 清账前快照,
|
||||
# 商店买走 vs 奖励领走 vs 枯竭的终局面;事后读 adr.pool_stock 会被收束清账重置=满库假象)
|
||||
st["pool_stock"] = (st.get("summary", {}).get("pool_stock", {}) as Dictionary).duplicate(true)
|
||||
st["relics"] = (st.get("summary", {}).get("relics", []) as Array).duplicate() # 特性终局持有集(摘要 additive 键清账前快照,同 pool_stock 模式)
|
||||
st.log_path = logger.log_path()
|
||||
if own_session:
|
||||
logger.close_session()
|
||||
return st
|
||||
|
||||
|
||||
# 冒险模式节点采集(_on_node_entered 的 AdventureRunner 门面版——runner 逐关更替,经 adr.runner() 重取;
|
||||
# encounter_max_hp_seq 抓 effective_max_hp:node_entered 先于 begin_battle,encounter 尚为空字典,
|
||||
# 该值 = begin_battle 将收到 hero_max_hp 同一表达式〔规划书抓取点与代码事实冲突,按代码为准已登记〕)
|
||||
static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr, st: Dictionary, logger) -> void:
|
||||
var r = adr.runner()
|
||||
var t := String(node.get("type", ""))
|
||||
st.visited_ids.append(int(r.node_id_of(node)))
|
||||
if not st.has("level_templates"):
|
||||
st["level_templates"] = []
|
||||
st["level_templates"].append(r.active_template_name) # MC 分层出数(关卡×图模板——v0.4 测量对账条款)
|
||||
|
||||
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())
|
||||
if t == "boss":
|
||||
st.boss_depth = int(node.get("node_depth", -1))
|
||||
st.hp_at_boss = int(adr.ledger["hero_hp"])
|
||||
elif t == "reward":
|
||||
# 奖励节点三选一(共享池确定性策略;TP0 起 316-f 判读:1 级最稀有、
|
||||
# 同档并列取最左;替换放弃新件;兑付后 continue_after_node 回图选路)
|
||||
var ndraw: Dictionary = adr.node_curio_candidates()
|
||||
var ncands: Array = ndraw.get("candidates", []) as Array
|
||||
st.reward_events.append({"node_curio_pick": ncands.duplicate(true)})
|
||||
if ncands.is_empty():
|
||||
r.continue_after_node() # 牌库空合法态直通(池耗即枯竭——防御保留)
|
||||
else:
|
||||
adr.present_node_curio(ncands)
|
||||
var npick: Dictionary = _pick_token(ncands, String(st.get("feat_mode", "off")))
|
||||
if adr.apply_node_curio(String(npick.get("kind", "")), String(npick.get("tier", "")), String(npick.get("feat_id", ""))):
|
||||
# 专长凭证拿到即开——候选已被替换为专长三选一,就地再选一轮(确定性策略同款 316-f);
|
||||
# 不作此分支则节点停点悬空→选路卡死(MC timeout 形态)
|
||||
if String(npick.get("kind", "")) == "feat_ticket":
|
||||
var fcands: Array = (adr.node_curio_pending_state().get("candidates", []) as Array)
|
||||
if not fcands.is_empty():
|
||||
var fpick: Dictionary = _pick_token(fcands, String(st.get("feat_mode", "off")))
|
||||
adr.apply_node_curio(String(fpick.get("kind", "")), String(fpick.get("tier", "")), String(fpick.get("feat_id", "")))
|
||||
else:
|
||||
st.result = "advance_failed"
|
||||
elif t == "rest":
|
||||
st.rest_healed = int(info.get("healed", 0))
|
||||
st.hp_before_rest = int(info.get("hp", 0)) - st.rest_healed
|
||||
r.continue_after_node()
|
||||
elif t == "shop":
|
||||
st.shop_gp_seq.append(int(info.get("gp", -1)))
|
||||
if String(st.get("shop_mode", "legacy")) == "pool":
|
||||
_autoplay_shop_pool(r, st) # 共享池货架贪心(买一件=奖励面少一件的机会成本,画像维持贪心语义)
|
||||
else:
|
||||
_autoplay_shop(r, st) # 贪心买齐(GP 局内死钱——真人实证两店全购)
|
||||
r.continue_after_node()
|
||||
if logger != null:
|
||||
logger.log_node(node, info, r.node_index)
|
||||
|
||||
|
||||
# 冒险钱包事件采集(wallet 行族——GP 跨关延续与冒险收束 clear 的验收取数面;同日志合规转发)
|
||||
static func _on_adventure_wallet(event: String, gp_val: int, extra: Dictionary, st: Dictionary, logger) -> void:
|
||||
st.wallet_events.append({"event": String(event), "gp": int(gp_val),
|
||||
"amount": int(extra.get("amount", 0)), "product": String(extra.get("product", "")),
|
||||
"source": String(extra.get("source", ""))})
|
||||
if logger != null:
|
||||
logger.log_wallet(event, gp_val, extra)
|
||||
|
||||
|
||||
# 三选一停点驱动(确定性策略;TP0 起 316-f 判读:1 级最稀有、同档并列取最左;
|
||||
# 奖励事件随 reward_events 留档)
|
||||
static func _on_adventure_bonus(payload: Dictionary, st: Dictionary, adr) -> void:
|
||||
var pl := payload.duplicate(true)
|
||||
pl["bonus_max_hp"] = int(adr.run_ledger.bonus.get("max_hp", 0)) # 观测面:停点时刻加算区快照(回血精确断言用)
|
||||
st.reward_events.append({"curio_pick": pl})
|
||||
var candidates: Array = payload.get("candidates", []) as Array
|
||||
if candidates.is_empty():
|
||||
st.result = "advance_failed"
|
||||
return
|
||||
var pick: Dictionary = _pick_token(candidates, String(st.get("feat_mode", "off")))
|
||||
if not adr.pick_curio(String(pick.get("kind", "")), String(pick.get("tier", "")), String(pick.get("feat_id", ""))):
|
||||
st.result = "advance_failed"
|
||||
|
||||
|
||||
# 三选一确定性选件(TP0 316-f——1 级优先度=最稀有〔四级:legendary>rare>excellent>common〕、2 级=最左;
|
||||
# 候选并列同档取最左=稳定序保证。空候选/未知档位 = 回退最左防御口径。
|
||||
# 成型画像(relic 模式 on:候选含 relic 令牌时优先在 relic 集内按 316-f 择一
|
||||
# 〔贪心捕捞→特性入局→成型画像全流派覆盖——R3 乙单锚承载〕;off=relic 与属性加成型特性同规则竞争)
|
||||
static func _pick_token(candidates: Array, feat_mode: String = "off") -> Dictionary:
|
||||
var pool: Array = candidates
|
||||
if feat_mode == "on":
|
||||
var feats_only: Array = []
|
||||
for tok in candidates:
|
||||
if String((tok as Dictionary).get("kind", "")) == "relic":
|
||||
feats_only.append(tok)
|
||||
if not feats_only.is_empty():
|
||||
pool = feats_only
|
||||
var best: Dictionary = {}
|
||||
var best_rank := -1
|
||||
for tok in pool:
|
||||
var t: Dictionary = tok as Dictionary
|
||||
var rank := 0
|
||||
match String(t.get("tier", "")):
|
||||
"legendary":
|
||||
rank = 4
|
||||
"rare":
|
||||
rank = 3
|
||||
"excellent":
|
||||
rank = 2
|
||||
"common":
|
||||
rank = 1
|
||||
if best.is_empty() or rank > best_rank:
|
||||
best = t
|
||||
best_rank = rank
|
||||
return best
|
||||
|
||||
|
||||
# 三选一兑付完成 → 上下文分派(通关停点=推进下一关 / 奖励节点=回图选路)
|
||||
static func _on_adventure_curio_resolved(result: Dictionary, st: Dictionary, adr) -> void:
|
||||
st.reward_events.append({"curio_resolved": result.duplicate(true)})
|
||||
if String(result.get("context", "")) == "reward_node":
|
||||
var r = adr.runner()
|
||||
if r == null or not r.continue_after_node():
|
||||
st.result = "advance_failed"
|
||||
return
|
||||
if not adr.continue_to_next_level():
|
||||
st.result = "advance_failed"
|
||||
|
||||
|
||||
# 商店贪心买齐:GP 局内死钱——买得起就买( affordable 集里挑最便宜的
|
||||
# 反复买到达不上任何商品为止,guard 防死循环)。确定性(零随机流);购买出账/加算区入账全走
|
||||
# runner.buy_product 核心编排(与呈现层购买同一出账路径,wallet_changed 信号照常采集)。
|
||||
static func _autoplay_shop(r, st: Dictionary) -> void:
|
||||
var guard := 30
|
||||
while guard > 0:
|
||||
guard -= 1
|
||||
var affordable: Array = []
|
||||
for p in r.shop_products():
|
||||
if r.gp >= int(p.get("price", 1 << 30)):
|
||||
affordable.append(p)
|
||||
if affordable.is_empty():
|
||||
return
|
||||
var pick: Dictionary = affordable[0]
|
||||
for p in affordable:
|
||||
if int(p.get("price", 1 << 30)) < int(pick.get("price", 1 << 30)):
|
||||
pick = p
|
||||
if not r.buy_product(String(pick.get("product_id", ""))):
|
||||
return
|
||||
st.shop_buys = int(st.get("shop_buys", 0)) + 1
|
||||
|
||||
|
||||
# 商店共享池贪心:货架三选一同构面贪心买齐——affordable 集挑最便宜反复买
|
||||
# (每次买后全重抽、货架随买随换,枯竭/不可负担即止,guard 防死循环)。购买走 runner.buy_pool_item
|
||||
# 核心编排(与呈现层购买同一出账+池消费路径,wallet_changed 信号照常采集)。确定性(零随机流)。
|
||||
static func _autoplay_shop_pool(r, st: Dictionary) -> void:
|
||||
var guard := 30
|
||||
while guard > 0:
|
||||
guard -= 1
|
||||
var affordable: Array = []
|
||||
for c in r.shop_shelf():
|
||||
if r.gp >= int(c.get("price", 1 << 30)):
|
||||
affordable.append(c)
|
||||
if affordable.is_empty():
|
||||
return
|
||||
var pick: Dictionary = affordable[0]
|
||||
for c in affordable:
|
||||
if int(c.get("price", 1 << 30)) < int(pick.get("price", 1 << 30)):
|
||||
pick = c
|
||||
if not r.buy_pool_item(String(pick.get("kind_id", ""))):
|
||||
return
|
||||
st.shop_buys = int(st.get("shop_buys", 0)) + 1
|
||||
|
||||
|
||||
# 冒险终局(completed/failed/abandoned——result 载 summary.result,外层驱动循环随之收敛)
|
||||
static func _on_adventure_finished(summary: Dictionary, st: Dictionary, logger) -> void:
|
||||
st.summary = summary.duplicate(true)
|
||||
st.result = String(summary.get("result", ""))
|
||||
if logger != null:
|
||||
logger.log_level_end(summary)
|
||||
|
||||
|
||||
# 策略流 RNG:种子派生自局种子 × 黄金比率常数 + 偏移——与核心统一流
|
||||
# (rng_service 单盐派生)零共享零扰动;逐局确定性保持(同种子 ⇒ 同决策序列 ⇒ 同回放)
|
||||
static func _policy_rng(seed_val: int) -> RandomNumberGenerator:
|
||||
var prng := RandomNumberGenerator.new()
|
||||
prng.seed = seed_val * 2654435761 + 912931
|
||||
return prng
|
||||
|
||||
|
||||
# 路径轴选路(目标价值评分制):候选按节点类型评分取最高,
|
||||
# 同分 tie-break 沿 route 方向(min=小编号/max=大编号——verifier 双窗轴保持)。
|
||||
# 评分语义(确定性,零随机流):rest=伤重高值(缺口比例×100)/shop=买得起高值(GP 局内死钱,
|
||||
# 真人实证两店全购)/reward=占位低值(MC 夹具解锁空集=零兑付直通)/boss=缓冲低值(有店有_rest
|
||||
# 先补给再打)/battle=基线。编号取 runner.next_candidate_ids()(编号空间解析单一出口)。
|
||||
static func _route_pick(runner, route: String, st: Dictionary) -> int:
|
||||
var ids: Array = runner.next_candidate_ids()
|
||||
if ids.is_empty():
|
||||
return -1
|
||||
if not bool(st.get("adv", false)): # 单关机制夹具:旧机械选路保持(smoke 断言零扰动)
|
||||
ids.sort()
|
||||
return int(ids[0]) if route != "max" else int(ids[ids.size() - 1])
|
||||
var nodes: Array = runner.next_candidates()
|
||||
var scored: Array = []
|
||||
for i in nodes.size():
|
||||
var node: Dictionary = nodes[i]
|
||||
var t := String(node.get("type", ""))
|
||||
var s := 30.0
|
||||
if t == "rest":
|
||||
var max_hp := float(runner.effective_max_hp())
|
||||
s = 5.0 + (1.0 - float(runner.hero_hp) / maxf(max_hp, 1.0)) * 100.0 # 满血 rest=5 低于 battle(白 rest 不去);伤重可到 105
|
||||
elif t == "shop":
|
||||
s = 10.0
|
||||
var cheapest := 1 << 30
|
||||
if String(st.get("shop_mode", "legacy")) == "pool":
|
||||
for c in runner.shop_shelf(): # pool 面:货架现价
|
||||
cheapest = mini(cheapest, int(c.get("price", 1 << 30)))
|
||||
else:
|
||||
for p in runner.shop_products():
|
||||
cheapest = mini(cheapest, int(p.get("price", 1 << 30)))
|
||||
if cheapest < (1 << 30) and runner.gp >= cheapest:
|
||||
s = 80.0
|
||||
elif t == "reward":
|
||||
s = 5.0
|
||||
elif t == "boss":
|
||||
s = 20.0
|
||||
scored.append({"id": int(ids[i]), "s": s})
|
||||
var best: Dictionary = scored[0]
|
||||
for i in range(1, scored.size()):
|
||||
var c: Dictionary = scored[i]
|
||||
var better: bool
|
||||
if absf(c.s - best.s) > 0.0001:
|
||||
better = c.s > best.s
|
||||
else:
|
||||
better = (c.id < best.id) if route != "max" else (c.id > best.id)
|
||||
if better:
|
||||
best = c
|
||||
return int(best.id)
|
||||
|
||||
|
||||
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 == "damage" and row.source_class == "mine_hit" and "全体敌人" not in String(row.get("text", "")):
|
||||
st.mine_dmg += int(row.value) # 踩雷直伤承伤(k 系数分母补算字段——口径与真人分析端「敌方行动+踩雷直伤」对齐)
|
||||
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;盲挖惩罚链埋点)
|
||||
# 封印经济聚合(自结算流水聚合,零新随机源):
|
||||
# ⚠ 行侧判据考证实录:结算段对存续增益实例每轮复发行 effect 行且复用实例 source_class
|
||||
#(battle_orchestrator 状态列举行)——source_class=seal_hit 的行 ≠ 标中事件。事件行唯一判据
|
||||
# = 授予行专带 highlight="seal_hit"(实测 87 行 == 受理封印 87 动作,1:1 精确);
|
||||
# 雷区图谱追加分grant 无高光,以 display_name 后缀补捕(计入事件与合计——同封印第二份兑付)。
|
||||
# 满层拒收行(reward_ineffective)零兑付不入。标空事件=归因行(type=seal_miss 每格恰一次);
|
||||
# 标空惩罚合计只入一次性兑付行(flat 直伤 damage/heal + gp_loss escrow 行)——sapped 授予行
|
||||
# 与其状态复发行行侧不可区分不入合计(登记注记,判读侧按事件数补算)。
|
||||
if row.source_class == "seal_hit" and row.type != "reward_ineffective":
|
||||
if String(row.get("highlight", "")) == "seal_hit" \
|
||||
or String(row.get("display_name", "")).contains("雷区图谱"):
|
||||
st.seal_hit += 1
|
||||
st.seal_hit_gain += int(row.value)
|
||||
if row.type == "seal_miss":
|
||||
st.seal_miss += 1
|
||||
elif row.source_class == "seal_miss" and (row.type == "damage" or row.type == "heal" \
|
||||
or String(row.get("effect_id", "")) == "gp_loss"):
|
||||
st.seal_miss_penalty += int(row.value)
|
||||
if collect_rows:
|
||||
st.rows.append(row)
|
||||
|
||||
|
||||
# 敌方显示名采集:敌方行动/施放行文本首词 = 单位显示名
|
||||
#(攻击行「地精 撕咬(暴击)」、施放行「地精 施放 毒雾」——显示名不含空格,去重连续重复)
|
||||
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":
|
||||
st.boss_depth = int(node.get("node_depth", -1))
|
||||
st.hp_at_boss = runner.hero_hp
|
||||
elif t == "reward":
|
||||
runner.continue_after_node() # 奖励点 = 内容未定直通节点
|
||||
elif t == "rest":
|
||||
st.rest_healed = int(info.get("healed", 0))
|
||||
st.hp_before_rest = int(info.get("hp", 0)) - st.rest_healed
|
||||
runner.continue_after_node()
|
||||
elif t == "shop":
|
||||
runner.continue_after_node() # 单关机制夹具(run_level)零购离开保持——smoke 商店/战斗断言面
|
||||
# 不掺购买行为;冒险口径(run_adventure)已在 _on_adventure_node_entered 改贪心买齐
|
||||
if logger != null:
|
||||
logger.log_node(node, info, runner.node_index)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void:
|
||||
# 策略档经 _decide_act 单点分派(单循环承载三档——三档决策语义逐位不变,见 _decide_act)。
|
||||
# 普通策略档 v3:完美策略 + 受控失误注入(标定依据=真人实证:
|
||||
# 零加成真人首通三关踩 23 雷)。
|
||||
# v3 语义 = 会玩的普通玩家:推理与完美档同源(subset 解算/封印/卡死刷新),失误只注入两手——
|
||||
# 标雷误挖(NORMAL_MINE_SLIP_P)+ 安全挖跳过(NORMAL_SKIP_DIG_P)。连续拒绝 ≥3 = 编排违约防御。
|
||||
# 完美策略档:单步决策循环——move/dig/seal 经 排雷玩法 settle_batch 提交,
|
||||
# refresh 经 settlement.player_request_refresh(战斗结算 核心编排:行动经济 扣 RAP + 退役 + 当场换新板),
|
||||
# stop 出内环走统一停手提交。连续拒绝 ≥3 = 编排违约防御(fail-fast 语义,停手防死循环)。
|
||||
# 上界口径:选路=目标价值评分(_route_pick)+ 商店贪心买齐(_autoplay_shop)——
|
||||
# 「完美」是元游戏意义上的上界(会购物会补给),不只扫雷子游戏上界。
|
||||
var round_guard := 60
|
||||
while st.result == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
|
||||
round_guard -= 1
|
||||
st.rounds += 1
|
||||
var start_round: int = settlement.round_index
|
||||
var action_guard := 400
|
||||
var rejects := 0
|
||||
while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \
|
||||
and settlement.round_index == start_round and action_guard > 0 and rejects < 3:
|
||||
action_guard -= 1
|
||||
var act: Dictionary = _decide_act(st, settlement)
|
||||
var kind := String(act.get("kind", "stop"))
|
||||
if kind == "stop":
|
||||
break
|
||||
var ok := false
|
||||
if kind == "move":
|
||||
ok = _submit_intent(settlement, "move", act.get("cell"), logger, start_round)
|
||||
elif kind == "dig":
|
||||
ok = _submit_intent(settlement, "dig", act.get("cell"), logger, start_round)
|
||||
elif kind == "seal":
|
||||
ok = _submit_intent(settlement, "seal_place", act.get("cell"), logger, start_round)
|
||||
elif kind == "refresh":
|
||||
ok = bool(settlement.player_request_refresh())
|
||||
if logger != null:
|
||||
logger.log_action("refresh", Vector2i(-1, -1), ok, {"round": start_round})
|
||||
if ok:
|
||||
if kind == "refresh":
|
||||
st.refreshes = int(st.refreshes) + 1
|
||||
elif kind == "seal":
|
||||
st.seal_placed = int(st.get("seal_placed", 0)) + 1 # 封印数(η 分母=全部封印数〔Q5〕;失误注入转化封印不计数=转化后实为挖)
|
||||
rejects = 0
|
||||
else:
|
||||
rejects += 1
|
||||
if runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" and settlement.round_index == start_round:
|
||||
var out_s: Array = settlement.minesweeper.settle_batch([{"action": "stop"}])
|
||||
if logger != null:
|
||||
logger.log_action("stop", Vector2i(-1, -1), _accepted(out_s), {"round": start_round})
|
||||
|
||||
|
||||
# 普通档 v3 失误注入:三类——
|
||||
# ①标雷误挖:把「封印已证雷」动作直接转成「挖该格」(真人手滑原型:知道是雷、位置点错,
|
||||
# 踩雷吃伤且该雷奖励报废);期望踩雷增量 ≈ 每板已证雷数 × NORMAL_MINE_SLIP_P(目标完美+2/板)
|
||||
# ②安全挖跳过:把「挖已证安全格」动作转成本轮停手(少挖几个正确的雷——信息与推进双损失)
|
||||
# ③卡死赌挖:完美档无已证动作时刷新/停手拖轮次吃敌方垫刀(实证=完美档 41% 通关全败于慢玩
|
||||
# 垫刀非踩雷)——真人会按概率赌一个边界格(用户实证口径「有些地方因为我看不到数字,也是盲挖
|
||||
# 的」);普通档不刷新(用户自报全程没用刷新),赌挖替代刷新/停手
|
||||
# 失误流 = st.prng(策略流 RNG,种子派生自局种子——同种子逐位可回放;核心七流零消费零扰动)
|
||||
static func _apply_normal_slips(act: Dictionary, settlement, st: Dictionary) -> Dictionary:
|
||||
var prng: RandomNumberGenerator = st.get("prng")
|
||||
if prng == null:
|
||||
return act
|
||||
var kind := String(act.get("kind", "stop"))
|
||||
if kind == "seal" and prng.randf() < NORMAL_MINE_SLIP_P:
|
||||
st.mine_slips = int(st.get("mine_slips", 0)) + 1
|
||||
return {"kind": "dig", "cell": act.get("cell")}
|
||||
if kind == "dig":
|
||||
var roll := prng.randf()
|
||||
if roll < NORMAL_SKIP_DIG_P:
|
||||
st.skip_digs = int(st.get("skip_digs", 0)) + 1
|
||||
return {"kind": "stop"}
|
||||
if roll < NORMAL_SKIP_DIG_P + NORMAL_IDLE_GAMBLE_P:
|
||||
var idle := AutoPlayer.gamble_pick(settlement.minesweeper)
|
||||
if not idle.is_empty():
|
||||
return _gamble_action(idle, settlement)
|
||||
if kind == "refresh" or kind == "stop":
|
||||
var plan := AutoPlayer.gamble_pick(settlement.minesweeper) # v3.2:概率启发赌挖(choose_dig 行主序=概率最差格,实证 199/200 死第二战——退役)
|
||||
if not plan.is_empty():
|
||||
return _gamble_action(plan, settlement)
|
||||
return act
|
||||
|
||||
|
||||
# 赌挖动作组装(v3.4 抽出):目标邻接=直接挖,否则先移到目标的已开邻格(下一拍再挖)
|
||||
static func _gamble_action(plan: Dictionary, settlement) -> Dictionary:
|
||||
var ms = settlement.minesweeper
|
||||
var hero: Vector2i = ms.hero_pos()
|
||||
var target: Vector2i = plan.dig_cell
|
||||
var d := Vector2i(absi(target.x - hero.x), absi(target.y - hero.y))
|
||||
if maxi(d.x, d.y) == 1:
|
||||
return {"kind": "dig", "cell": target}
|
||||
return {"kind": "move", "cell": plan.hero_cell}
|
||||
|
||||
|
||||
# 战斗决策单行分派:
|
||||
# perfect 档 = 完美策略单步决策(上界口径);
|
||||
# normal 档 v3 = 完美+受控失误注入(冒险测量面 adv=true);单关机制夹具保持 v2 新手画像(adv 缺省)。
|
||||
# 决策函数纯读板面/编排面(零 RNG 消费——失误流独走 st.prng,见 _apply_normal_slips)。
|
||||
static func _decide_act(st: Dictionary, settlement) -> Dictionary:
|
||||
if String(st.get("policy", "normal")) == "perfect":
|
||||
return AutoPlayer.perfect_action(settlement.minesweeper, settlement)
|
||||
if bool(st.get("adv", false)):
|
||||
return _apply_normal_slips(AutoPlayer.perfect_action(settlement.minesweeper, settlement), settlement, st) # v3:完美+失误
|
||||
return AutoPlayer.novice_action(settlement.minesweeper) # 单关机制夹具:v2 新手画像保持
|
||||
|
||||
|
||||
static func _submit_intent(settlement, action: String, cell, logger, round_index: int) -> bool:
|
||||
var out: Array = settlement.minesweeper.settle_batch([{"action": action, "cell": cell}])
|
||||
if logger != null:
|
||||
logger.log_action(action, cell, _accepted(out), {"round": round_index})
|
||||
return _accepted(out)
|
||||
|
||||
|
||||
static func _accepted(outcomes: Array) -> bool:
|
||||
return not outcomes.is_empty() and bool(outcomes[0].accepted)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://jpfa1hm7risn
|
||||
@@ -0,0 +1,119 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 战斗期望蒙特卡洛:当前参数下"胜轮数 / 每轮承伤 / 清图次数 / 开格数"的分布实测。
|
||||
# 政策 = 理性玩家近似:挖最近可挖格(AutoPlayer 单一实现)。
|
||||
# 运行:Godot_console.exe --headless --path . --script res://sim/monte_carlo.gd
|
||||
extends SceneTree
|
||||
|
||||
const BattleDriver := preload("res://sim/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
|
||||
|
||||
const N := 30
|
||||
const SEED0 := 42
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
db.load_tables("res://data/tables", false)
|
||||
for L in [1, 2]:
|
||||
_run_encounter(db, L)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _run_encounter(db, L: int) -> void:
|
||||
var rounds_v: Array = [] # 胜利战斗的轮数
|
||||
var dmg_per_round: Array = [] # 每战斗 每轮承伤
|
||||
var dmg_total: Array = []
|
||||
var safe_opened: Array = []
|
||||
var clears: Array = [] # 每战斗清图(换新板)次数
|
||||
var buff_grants: Array = [] # attack_up+ac_up 授予数
|
||||
var losses := 0
|
||||
var timeouts := 0
|
||||
for i in range(N):
|
||||
var seed_val: int = SEED0 + i
|
||||
var r := _run_battle(db, seed_val, L)
|
||||
if r.result == "victory":
|
||||
rounds_v.append(r.rounds)
|
||||
dmg_total.append(r.hero_dmg)
|
||||
dmg_per_round.append(float(r.hero_dmg) / float(r.rounds))
|
||||
safe_opened.append(r.safe_opened)
|
||||
clears.append(r.board_swaps)
|
||||
buff_grants.append(r.buffs)
|
||||
elif r.result == "defeat":
|
||||
losses += 1
|
||||
else:
|
||||
timeouts += 1
|
||||
print("=== L%d · %d 种子(败 %d / 超时 %d)===" % [L, N, losses, timeouts])
|
||||
print(" 胜利轮数 均值 %.2f 中位 %d p10 %.1f p90 %.1f 分布 %s" % [_mean(rounds_v), _median(rounds_v), _pct_f(rounds_v, 0.1), _pct_f(rounds_v, 0.9), _hist(rounds_v)])
|
||||
print(" 每轮承伤 均值 %.2f 中位 %.1f p90 %.1f" % [_mean(dmg_per_round), _median_f(dmg_per_round), _pct_f(dmg_per_round, 0.9)])
|
||||
print(" 整场承伤 均值 %.1f 中位 %.0f(主角 max_hp=%d)" % [_mean(dmg_total), _median_f(dmg_total), int(db.hero.max_hp)])
|
||||
print(" 开安全格 均值 %.1f 中位 %d" % [_mean(safe_opened), _median(safe_opened)])
|
||||
print(" 清图(换板) 均值 %.2f 中位 %d" % [_mean(clears), _median(clears)])
|
||||
print(" 攻防buff授予 均值 %.2f" % [_mean(buff_grants)])
|
||||
|
||||
|
||||
func _run_battle(db, seed_val: int, L: int) -> Dictionary:
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(seed_val)
|
||||
var settlement = load("res://core/battle_orchestrator.gd").new(db, rng)
|
||||
var out := {"rounds": 0, "result": "", "hero_dmg": 0, "safe_opened": 0, "board_swaps": 0, "buffs": 0}
|
||||
settlement.battle_finished.connect(func(payload): out.result = payload.get("result", ""))
|
||||
settlement.board_changed.connect(func(): out.board_swaps += 1)
|
||||
settlement.step_row.connect(func(row):
|
||||
if row.type == "damage" and row.source_class == "enemy_action":
|
||||
out.hero_dmg += int(row.value)
|
||||
elif row.type == "effect" and row.source_class == "safe_cell" and (row.effect_id == "attack_up" or row.effect_id == "ac_up"):
|
||||
out.buffs += 1)
|
||||
settlement.minesweeper.cell_revealed.connect(func(evt):
|
||||
if evt.kind == "safe" and evt.channel != "initial":
|
||||
out.safe_opened += 1)
|
||||
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}):
|
||||
return out
|
||||
var guard_rounds := 40
|
||||
while out.result == "" and guard_rounds > 0:
|
||||
guard_rounds -= 1
|
||||
out.rounds += 1
|
||||
BattleDriver.drive_round(settlement, func() -> bool: return out.result == "")
|
||||
if out.result == "":
|
||||
out.result = "timeout"
|
||||
return out
|
||||
|
||||
|
||||
|
||||
func _mean(a: Array) -> float:
|
||||
if a.is_empty():
|
||||
return 0.0
|
||||
var t := 0.0
|
||||
for v in a:
|
||||
t += float(v)
|
||||
return t / a.size()
|
||||
|
||||
|
||||
func _median(a: Array) -> int:
|
||||
return int(_median_f(a))
|
||||
|
||||
|
||||
func _median_f(a: Array) -> float:
|
||||
if a.is_empty():
|
||||
return 0.0
|
||||
var b := a.duplicate()
|
||||
b.sort()
|
||||
return float(b[b.size() / 2])
|
||||
|
||||
|
||||
func _pct_f(a: Array, p: float) -> float:
|
||||
if a.is_empty():
|
||||
return 0.0
|
||||
var b := a.duplicate()
|
||||
b.sort()
|
||||
return float(b[mini(b.size() - 1, int(p * b.size()))])
|
||||
|
||||
|
||||
func _hist(a: Array) -> String:
|
||||
var h := {}
|
||||
for v in a:
|
||||
h[v] = int(h.get(v, 0)) + 1
|
||||
var keys := h.keys()
|
||||
keys.sort()
|
||||
var parts: Array = []
|
||||
for k in keys:
|
||||
parts.append("%d轮×%d" % [k, h[k]])
|
||||
return "{" + ", ".join(parts) + "}"
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkech7og6a6od
|
||||
@@ -0,0 +1,95 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 奖励期望测量:逐深度实测 |非封锁格|(safe ∪ mines ∖ R₀)分布、格面资源类型频次
|
||||
# (应涌现自正向行九型权重 attack 14 / defense 11 / blast 16 / heal 4 / weaken 8 / pierce 8 / magic 8 / action 6 / gp 2,合计 77——reward.tres 现行)、
|
||||
# 封锁格零泄漏(R₀ 格不在摇号清单)——走真实 地图战斗资源.roll_board 管线。
|
||||
# 运行:Godot_console.exe --headless --path . --script res://sim/reward_expectation.gd
|
||||
extends SceneTree
|
||||
|
||||
const N := 200
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
if not db.load_tables("res://data/tables", false):
|
||||
print("表加载失败")
|
||||
quit(1)
|
||||
return
|
||||
var board_generator = load("res://core/board_generator.gd").new()
|
||||
var reward_economy_factory = load("res://core/reward_economy.gd")
|
||||
var rng_factory = load("res://core/rng_service.gd")
|
||||
var types := ["attack", "defense", "heal", "blast", "weaken"]
|
||||
print("=== 奖励期望测量(格面资源批 · N=%d 板/深度)===" % N)
|
||||
print("depth | 非封锁格均/min/max | attack% | defense% | heal% | blast% | weaken% | 封锁格泄漏板")
|
||||
for depth in range(5):
|
||||
var elig: Array = []
|
||||
var type_counts := {"attack": 0.0, "defense": 0.0, "heal": 0.0, "blast": 0.0, "weaken": 0.0}
|
||||
var leak := 0
|
||||
for i in range(N):
|
||||
var rng = rng_factory.new()
|
||||
rng.set_master_seed(90000 + depth * 1000 + i)
|
||||
var board = board_generator.generate(1, depth, rng.open_stream("board_gen"), db)
|
||||
if board == null:
|
||||
continue
|
||||
var r0 := {}
|
||||
for c in board.compute_r0():
|
||||
r0[c] = true
|
||||
var eligible: Array = []
|
||||
for c in board.safe_cells():
|
||||
if not r0.has(c):
|
||||
eligible.append(c)
|
||||
for c in board.mines.keys():
|
||||
if not r0.has(c):
|
||||
eligible.append(c)
|
||||
elig.append(eligible.size())
|
||||
var reward_economy = reward_economy_factory.new(db, rng)
|
||||
var list: Array = []
|
||||
for c in eligible:
|
||||
list.append({"cell": c})
|
||||
if not reward_economy.roll_board(list):
|
||||
continue
|
||||
# 封锁格零泄漏:R₀ 格必不在映射(is_mapped 静默查询——零诊断噪音)
|
||||
var r0_listed := false
|
||||
for c in r0.keys():
|
||||
if reward_economy.is_mapped(c):
|
||||
r0_listed = true
|
||||
if r0_listed:
|
||||
leak += 1
|
||||
for c in eligible:
|
||||
var ident = reward_economy.query_identity(c)
|
||||
if ident != null:
|
||||
type_counts[String(ident.cell_type)] += 1.0
|
||||
var total := 0.0
|
||||
for t in types:
|
||||
total += type_counts[t]
|
||||
print("D%d %dx%d | %6.1f / %3d / %3d | %5.1f%% | %6.1f%% | %4.1f%% | %5.1f%% | %6.1f%% | %d" % [
|
||||
depth, 10 if depth <= 1 else 12, 10 if depth <= 1 else 12,
|
||||
_mean(elig), _min(elig), _max(elig),
|
||||
100.0 * type_counts.attack / total, 100.0 * type_counts.defense / total,
|
||||
100.0 * type_counts.heal / total, 100.0 * type_counts.blast / total,
|
||||
100.0 * type_counts.weaken / total, leak])
|
||||
print("(口径:类型频次 = 正向行权重涌现——attack 25.3%% / defense 20.3%% / blast 20.3%% / heal 19.0%% / weaken 15.2%%;")
|
||||
print(" 非封锁格 = safe ∪ mines ∖ R₀,雷安同池同分布;封锁格泄漏 = R₀ 格 query_identity 非 null 的板数,恒应为 0)")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _mean(a: Array) -> float:
|
||||
if a.is_empty():
|
||||
return 0.0
|
||||
var t := 0.0
|
||||
for v in a:
|
||||
t += float(v)
|
||||
return t / a.size()
|
||||
|
||||
|
||||
func _min(a: Array) -> int:
|
||||
var m := 99999
|
||||
for v in a:
|
||||
m = mini(m, int(v))
|
||||
return m
|
||||
|
||||
|
||||
func _max(a: Array) -> int:
|
||||
var m := 0
|
||||
for v in a:
|
||||
m = maxi(m, int(v))
|
||||
return m
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2kly46dskpqr
|
||||
@@ -0,0 +1,37 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 技能池节奏读数批量采集(06 待办「技能池节奏体验验证」机器半边正式读数):
|
||||
# 60 distinct seeds × 双策略 × 两关,atk/hp 零加成基线——单网格组合避免种子重复
|
||||
# (4200 系 0/484 假象教训:节奏/EV 读数须 ≥50 distinct seeds,见台账 89)。
|
||||
# 运行:Godot_console.exe --headless --path . --script res://sim/rhythm_batch.gd -- [seeds] [seed0]
|
||||
extends SceneTree
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var n := 60
|
||||
if args.size() >= 1 and args[0].is_valid_int():
|
||||
n = int(args[0])
|
||||
var seed0 := 4400
|
||||
if args.size() >= 2 and args[1].is_valid_int():
|
||||
seed0 = int(args[1])
|
||||
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(seed0)
|
||||
print("节奏批日志: %s" % log_path)
|
||||
var autoplay = load("res://sim/level_autoplay.gd")
|
||||
var t0 := Time.get_ticks_msec()
|
||||
var total := 0
|
||||
for L in [1, 2]:
|
||||
for policy in ["normal", "perfect"]:
|
||||
for i in range(n):
|
||||
var r = autoplay.run_level(db, seed0 + i, L, 0, 0, false, logger, policy, "min")
|
||||
total += 1
|
||||
if i % 10 == 0:
|
||||
print("进度 L%d %s %d/%d" % [L, policy, i, n])
|
||||
logger.close_session()
|
||||
print("节奏批完成:%d 局,耗时 %.1f 秒\n日志: %s" % [total, (Time.get_ticks_msec() - t0) / 1000.0, log_path])
|
||||
quit(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cc2yhv5d20gfk
|
||||
@@ -0,0 +1,59 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 定位:分析工具·非验收件——零断言取数工具,不出入任何闸。
|
||||
# 技能池节奏探针:地精一伙 (1,1) 750 出招零突刺异常的定位——
|
||||
# ① 表侧:resolve_enemy_rowset(1, 1, "battle") 的池快照内容;
|
||||
# ② 摇号侧:隔离 SkillLottery.draw 大样本份额;
|
||||
# ③ 战斗侧:真实 (1,1) 战斗逐执行行按 skill_id 计数。
|
||||
# 运行:Godot_console.exe --headless --path . --script res://sim/skill_rhythm_probe.gd
|
||||
extends SceneTree
|
||||
|
||||
const BattleDriver := preload("res://sim/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
|
||||
|
||||
func _initialize() -> void:
|
||||
var db = load("res://core/config_db.gd").new()
|
||||
db.load_tables("res://data/tables", false)
|
||||
|
||||
# ① 表侧(行集数组取首行,战斗节点查 battle 键域)
|
||||
var row = db.resolve_enemy_rowset(1, 1, "battle")[0]
|
||||
print("[1] row(1,1) display=", row.get("display_name"), " unit_count=", row.get("unit_count"),
|
||||
" pool=", row.get("pool"))
|
||||
|
||||
# ② 摇号侧隔离
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(20260919)
|
||||
var lot = load("res://core/skill_lottery.gd")
|
||||
var counter := {}
|
||||
for i in range(10000):
|
||||
var stream: RandomNumberGenerator = rng.open_stream("behavior_roll")
|
||||
var s: String = lot.draw(row.get("pool"), stream)
|
||||
counter[s] = counter.get(s, 0) + 1
|
||||
print("[2] SkillLottery 10000 抽(统一流顺序取号——并流后子流复用退役): ", counter)
|
||||
|
||||
# ③ 战斗侧(真实路径):(1,0) 对照 + (1,1) 异常组
|
||||
for depth in [0, 1]:
|
||||
var casts := {}
|
||||
var battles := 0
|
||||
for i in range(10):
|
||||
var r = _run_battle(db, 4200 + i, 1, depth, casts)
|
||||
battles += 1
|
||||
print("[3] 战斗侧 (1,%d) %d 场执行出招 skill_id 计数: " % [depth, battles], casts)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _run_battle(db, seed_val: int, L: int, depth: int, casts: Dictionary) -> Dictionary:
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.set_master_seed(seed_val)
|
||||
var settlement = load("res://core/battle_orchestrator.gd").new(db, rng)
|
||||
var out := {"result": ""}
|
||||
settlement.battle_finished.connect(func(payload): out.result = payload.get("result", ""))
|
||||
settlement.step_row.connect(func(row):
|
||||
if row.type == "damage" and row.source_class == "enemy_action":
|
||||
var sid: String = str(row.get("skill_id", "?"))
|
||||
casts[sid] = casts.get(sid, 0) + 1)
|
||||
if not settlement.begin_battle({"level_index": L, "node_depth": depth, "hero_hp": int(db.hero.max_hp)}):
|
||||
return out
|
||||
var guard_rounds := 40
|
||||
while out.result == "" and guard_rounds > 0:
|
||||
guard_rounds -= 1
|
||||
BattleDriver.drive_round(settlement, func() -> bool: return out.result == "")
|
||||
return out
|
||||
@@ -0,0 +1 @@
|
||||
uid://b5vagf2sq2suk
|
||||
@@ -0,0 +1,208 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# 判定端 tools/balance_verifier.py 的数据源,本文件零判定逻辑
|
||||
# 两段一次采集,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://sim/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
|
||||
|
||||
# 花费策略主语=树脊柱两节点(钥石单币;攻/AC 无树来源
|
||||
# 〔契约 9——唯一入口=锻造工坊路线,未实装〕)
|
||||
const SPEND_ORDERS := {
|
||||
"hp_first": ["sp_hp", "sp_hit"],
|
||||
"atk_first": ["sp_hit", "sp_hp"],
|
||||
"armor_first": ["sp_hp", "sp_hit"],
|
||||
}
|
||||
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://sim/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["tree"] = {"sp_hp": meta.save.tree_level("sp_hp"), "sp_hit": meta.save.tree_level("sp_hit")}
|
||||
row["new_unlocks"] = settle.new_unlocks
|
||||
_emit(row)
|
||||
counts.campaign += 1
|
||||
if (r + 1) % 10 == 0:
|
||||
print("进度 campaign %s %d/%d(树 血%d/命中%d · 钥石 %d)" % [policy, r + 1, int(opt.k),
|
||||
meta.save.tree_level("sp_hp"), meta.save.tree_level("sp_hit"),
|
||||
meta.keystone_balance()])
|
||||
_remove_save(save_path) # 段尾清理(与 smoke 存档纪律同源——不遗留隔离档)
|
||||
|
||||
|
||||
# 花费策略(贪心——按优先序反复买当前可负担的最高优先项;树行门槛随买随涨自然外溢到次节点
|
||||
# ——购买原语 tree_buy,行门槛=节点等级轴)
|
||||
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 nid in order:
|
||||
var price: int = int(meta.db.tree_node(nid).get("price", 0))
|
||||
if price > 0 and meta.keystone_balance() >= price and meta.tree_buy(nid):
|
||||
out.append({"node_id": nid, "level": meta.save.tree_level(nid), "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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://datax1elpwkik
|
||||
Reference in new issue
Block a user