Files
gplan-2/game/smoke/smoke_all.gd
T

4586 lines
278 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# headless 冒烟入口(承数据配置冒烟框架:extends SceneTree + autoload 显式实例化)。
# 运行:Godot_console.exe --headless --path <proj> --script res://smoke/smoke_all.gd
extends SceneTree
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现
const ConfigDBScript := preload("res://core/config_db.gd") # T6 收口:表宿主 preload 常量(原 _initialize 运行期 load)
var _failures := 0
var _checks := 0
func check(cond: bool, label: String, quiet: bool = false) -> void:
_checks += 1
if cond:
if not quiet:
print(" [PASS] " + label)
elif quiet:
_failures += 1
print(" [FAIL] (silent check)") # 原 check_silent 输出逐位保持
else:
_failures += 1
print(" [FAIL] " + label)
func _initialize() -> void:
print("=== Dungeon Sweeper 垂直切片冒烟 ===")
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
check(load_ok, "全部表加载 + 校验通过(含跨表护栏)")
if not load_ok:
for d in db.diagnostics():
if d.severity == "error":
print(" error: [%s] %s" % [d.category, d.message])
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(42)
# 统一流黄金向量(数据配置 AC-3——ADR-0007 裁丙 2026-09-27 单盐重登记:单盐沿用并流前 board_gen 盐,
# scope 0-3 与旧 R3 board_gen 向量逐位连续;Python 参考实现逐位交叉验证一致;并流前分盐向量随批作废)
check(rng.derive_at(0) == 8672514112661456564, "统一流向量 m=42 scope=0(=旧 board_gen scope0 连续性锚)")
check(rng.derive_at(1) == 8834291164406037215, "统一流向量 m=42 scope=1")
check(rng.derive_at(2) == 7714373262647006282, "统一流向量 m=42 scope=2")
check(rng.derive_at(3) == 1280102845971471347, "统一流向量 m=42 scope=3")
check(rng.derive_at(4) == 82585501446482399, "统一流向量 m=42 scope=4")
check(rng.derive_at(5) == 8078924421474470922, "统一流向量 m=42 scope=5")
check(rng.derive_at(6) == 6638052740430622424, "统一流向量 m=42 scope=6")
check(rng.derive_at(7) == 8264196488417721294, "统一流向量 m=42 scope=7")
check(rng.derive_at(0) == rng.derive_at(0), "derive_at 纯函数恒等")
# scope 取号语义:首个 open_stream = 全局 scope 0;计数器单调;用途标签不参与派生(只进事件日志)
var s0 = rng.open_stream("board_gen")
var s1 = rng.open_stream("encounter")
check(rng.counter() == 2, "open_stream 全局计数器单调 +1 ×2")
check(s0.seed == rng.derive_at(0) and s1.seed == rng.derive_at(1), "首个取号 = scope 0(顺序确定)")
rng.new_adventure(4242)
var s2 = rng.open_stream("gp_drop")
check(rng.counter() == 1 and s2.seed == rng.derive_at(0), "new_adventure 重置全局计数器")
check(FileAccess.file_exists("user://logs/rng_log_4242.jsonl"), "事件日志会话落盘(ADR-0007 丙案)")
if load_ok:
_run_core_smoke(db, rng)
print("=== 冒烟完成:%d 项检查,%d 项失败 ===" % [_checks, _failures])
quit(1 if _failures > 0 else 0)
# ---- Core 集成冒烟(表加载成功才执行)----
var _db
var _rng
func _run_core_smoke(db, rng) -> void:
_db = db
_rng = rng
_smoke_ac_swap_guard()
_smoke_armor_roll()
_smoke_pool_wiring()
_smoke_board_generator()
_smoke_layout_cell_formula()
_smoke_variation_framework()
_smoke_variant_placement(1)
_smoke_variant_placement(2)
_smoke_global_variant()
_smoke_full_battle(1, 0, "L1 · 地精")
_smoke_full_battle(2, 0, "L2 · 熊地精")
_smoke_level()
_smoke_flag_ap_exhausted()
_smoke_reward_identity_unit()
_smoke_power_interval()
_smoke_active_refresh()
_smoke_difficulty_table()
_smoke_damage_types()
_smoke_playtest_logger()
_smoke_battle_log_view()
_smoke_encounter_roll()
_smoke_attack_chain_fresh_pool()
_smoke_clear_reward_chain()
_smoke_refresh_provenance_guard()
_smoke_polarity_routing()
_smoke_flag_irreversible_quadrants()
_smoke_negative_dictionary()
_smoke_resource_surface()
_smoke_settlement_segments()
_smoke_skill_pool()
_smoke_level_graph()
_smoke_gp_wallet()
_smoke_gp_escrow()
_smoke_shop_room()
_smoke_shop_graph()
_smoke_shop_pool()
_smoke_nine_types_gp_stream()
_smoke_adventure_table()
_smoke_adventure_api()
_smoke_adventure()
_smoke_relics()
_smoke_save()
_smoke_meta()
_smoke_tree()
_smoke_node_map_templates()
# ---- 关卡节点图生成·模板池冒烟(M-3 迭代 5 T5——v0.4 §P0.6 断言面)----
func _smoke_node_map_templates() -> void:
# ① 扩签:node_map 入用途标签封闭词表(派生零改动——标签只进事件日志)
var svc = load("res://core/rng_service.gd").new()
check((svc.PURPOSE_TAGS as Array).has("node_map"), "node_map 标签入 rng_service 封闭词表(扩签通道=改代码+冒烟守卫)")
# ② 池参数:三关各 5 模板、模板 0 存在(回滚档成员)
for li in range(1, 4):
var row := {}
for r in _db.level.rows:
if int(r.level_index) == li:
row = r
var tpls: Array = row.get("templates", [])
check(tpls.size() == 5, "L%d 模板池 = 5 张(拍板点④——首版池规模)" % li)
check(not tpls.is_empty() and String(tpls[0].get("display_name", "")).ends_with("(现行)"),
"L%d 模板 0 = 现行固定图(回滚档成员)" % li)
# ③ 强制取 0 = 模板 0 节点表逐位相等 + 零 scope 消费
var rng0 = load("res://core/rng_service.gd").new()
rng0.new_adventure(910001)
var runner0 = load("res://core/level_runner.gd").new(_db, rng0)
var c_before: int = rng0.counter()
check(runner0.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, {},
Callable(self, "_smoke_rest_stub"), {}, true, 0), "强制 template_pick=0 起关受理")
check(rng0.counter() == c_before, "强制取模板零 RNG 消耗(零抽签——首次脚本语义)")
var tpl0: Dictionary = {}
for r in _db.level.rows:
if int(r.level_index) == 2:
tpl0 = r.get("templates", [])[0]
check(runner0.level_row.get("nodes", []) == tpl0.get("nodes", []),
"强制 0 的生效节点表 = 模板 0 逐位相等(回滚档成立)")
# ④ 抽签确定性:同种子两实例同模板(同种子同操作序列 → 逐位同图)
var names := []
for i in range(2):
var rg = load("res://core/rng_service.gd").new()
rg.new_adventure(910002)
var rn = load("res://core/level_runner.gd").new(_db, rg)
rn.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, {},
Callable(self, "_smoke_rest_stub"), {}, true, -1)
names.append(rn.active_template_name)
check(names[0] == names[1] and String(names[0]) != "",
"抽签确定性:同种子逐位同图(实测 %s)" % String(names[0]))
# ⑤ N=1 池与现行固定图等价:单模板行过图校验(抽签取模 1 恒 0 = 回滚档)
var lt = load("res://data/schemas/level_table.gd").new()
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30,
"templates": [tpl0]}])
check(lt.validate().is_empty(), "N=1 池表通过图校验(回滚档形态合法)")
# ⑥ 首次脚本分支:first_script=true 全关模板 0;false = 抽签(scope 恰一差异)
var rngF = load("res://core/rng_service.gd").new()
rngF.new_adventure(910003)
var adrF = load("res://core/adventure_runner.gd").new(_db, rngF)
adrF.start_adventure(910003, {"first_script": true})
check(String(adrF.runner().active_template_name).ends_with("(现行)"),
"首次脚本模式:L1 取模板 0(全关零抽签——存档布尔位 false 语义)")
var cF: int = rngF.counter()
var rngT = load("res://core/rng_service.gd").new()
rngT.new_adventure(910003)
var adrT = load("res://core/adventure_runner.gd").new(_db, rngT)
adrT.start_adventure(910003, {"first_script": false})
check(rngT.counter() == cF + 1, "抽签模式恰多消费 1 scope(node_map 逐关恰一——L1 起关抽签)")
check(String(adrT.runner().active_template_name) != "",
"抽签模式 active_template_name 有值(MC 分层出数取数面在位)")
# ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等)
DirAccess.remove_absolute("user://smoke_node_map_save.json") # 测试隔离——上轮 settle 写盘的残留档先删
var sv = load("res://core/save_manager.gd").new("user://smoke_node_map_save.json")
sv.load_from_disk()
check(sv.is_first_adventure_done() == false, "新档首次冒险布尔位 = false(默认域)")
var mp = load("res://core/meta_progression.gd").new(_db, sv)
mp.settle_adventure({"levels_cleared": 1, "keystone_total": 10, "cleared": false, "result": "failed"})
check(sv.is_first_adventure_done() == true, "冒险收束点置位(三结局同写——写入只在收束挂点)")
mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"})
check(sv.is_first_adventure_done() == true, "置位幂等(重复收束不翻转)")
# 冒烟恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位)
func _smoke_rest_stub(pct: int, hp: int, max_hp: int) -> int:
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
# ---- 冒险结构实现批冒烟(2026-09-21——AdventureTable 建表 + AdventureRunner + 冒险验收断言)----
# 令牌候选匹配(TP0——kind+tier 复合身份;冒烟断言面共用)
func _smoke_find_token(candidates: Array, kind: String, tier: String) -> Dictionary:
for t in candidates:
var tok: Dictionary = t as Dictionary
if String(tok.get("kind", "")) == kind and String(tok.get("tier", "")) == tier:
return tok
return {}
# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池换代:配额结构/件值对/池总量域检)
func _smoke_adventure_table() -> void:
print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---")
check(_db.adventure != null, "db.adventure 已加载(adventure.tres 入 manifest)")
if _db.adventure == null:
return
check(_db.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)")
check(int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100,
"clear_heal_pct ∈ [0,100](%d——通关部分回血比例)" % int(_db.adventure.clear_heal_pct))
for tier in _db.adventure.TIERS:
check(int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier]),
"档位配额结构锚(v2.2 追认 b):%s = %d 张" % [tier, int(_db.adventure.TIER_QUOTAS[tier])])
check(int(_db.adventure.deck_total()) == 98, "令牌池总量 = 98 张(普通 70/稀有 25/罕见 3——罕见档随道具件池种退役收窄)")
check(int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "rare")) == 50
and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "rare")) == 2
and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "rare")) == 10
and int(_db.adventure.curio_value("armor", "common")) == 1 and int(_db.adventure.curio_value("armor", "rare")) == 2,
"数值对(v2.2):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2")
check(int(_db.adventure.curio_value("heal", "rare")) == 6 and int(_db.adventure.curio_value("ap", "rarest")) == 1,
"恢复件 +6 当即回血(新入池种类,rare 档)· AP +1(rarest 档)")
check(_db.attunement_growth != null and int(_db.attunement_slot_cap()) == 3
and Attunement.slots(_db, null) == 0,
"同调位读数源=attunement_growth 谓词表(slot_cap 3;未注入档求值 0——transitional_sync_slots 过渡键已退役,迭代 11 第①批 T7)")
# ② 冒险结构 API 闸(状态机受理面——重复开始拒绝 / 入口停点 / 放弃两态)
func _smoke_adventure_api() -> void:
print("--- 冒险结构 API 闸(重复开始拒绝 · 入口停点 · 放弃两态受理)---")
var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(42))
adr.set_hero_override(900, 400) # 首战速胜档(放弃的非战斗受理要走到选路态)
# 停点前移批 2026-09-23:入口停点观测(信号序 + 入口候选快照——容器写回规避 lambda 按值捕获)
var sig_seq: Array = []
var obs := {"entry_cands": []}
adr.level_started.connect(_api_mark_started.bind(sig_seq))
adr.choices_presented.connect(_api_mark_choices.bind(sig_seq, obs))
adr.node_entered.connect(_api_mark_node.bind(sig_seq))
check(adr.start_adventure(42, {"first_script": true}), "冒险启动受理(主种子注入恰一次——state=running;first_script=固定模板 0——精确路径断言需要现行图,零抽签)")
check(not adr.start_adventure(43), "进行中重复 start_adventure 拒绝(重开须待结算完毕)")
check(sig_seq.size() == 2 and String(sig_seq[0]) == "level_started" and String(sig_seq[1]) == "choices_presented",
"停点前移次序契约:level_started 先于入口候选呈现(反序选路口会被呈现层 runner 空防御丢弃)")
check(adr.runner().awaiting_choice() and (obs["entry_cands"] as Array).size() == 1 \
and adr.runner().current_battle() == null,
"入口停点:选路态 + 入口单候选 + 首战未开(呈现零 RNG 消耗——pick 前零战斗域)")
check(adr.runner().pick_next_node(int(adr.runner().next_candidate_ids()[0])),
"pick 入口候选受理(同一 _enter_node——与旧自动进入逐位等价)")
check(sig_seq.size() == 3 and String(sig_seq[2]) == "node_entered" and adr.runner().current_battle() != null,
"pick 后 node_entered 到达 + 首战在飞(接口①开始关卡——停点闭合直达战斗节点)")
check(not adr.abandon(), "战斗中 abandon 拒绝(战斗域单事务在飞)")
# 驱动首战到胜利 → 选路态(非战斗期)→ abandon 受理
var autoplay = load("res://smoke/level_autoplay.gd")
var battle = adr.runner().current_battle()
var st := _battle_st("normal")
var guard := 30
while adr.runner().current_battle() == battle and guard > 0:
guard -= 1
autoplay._autoplay_battle(adr.runner(), battle, st, null)
check(adr.runner().current_battle() == null and adr.runner().awaiting_choice(),
"首战胜后选路态(非战斗期——current_battle 置空)")
check(adr.abandon(), "非战斗期 abandon 受理(选路态——同失败型结算)")
var sum: Dictionary = adr.summary
check(String(sum.get("result", "")) == "abandoned" and not bool(sum.get("cleared", true)),
"放弃结算:result=abandoned · cleared=false(已获面不追回——钥石累计 %d)" % int(sum.get("keystone_total", -1)))
check(not adr.abandon(), "结算后重复 abandon 拒绝(结算受理只算一次——state=idle)")
# 停点前移批 2026-09-23 观测桩(信号序采集——bind 容器写回,签名按各信号实参定制)
func _api_mark_started(_li: int, _dn: String, seq: Array) -> void:
seq.append("level_started")
func _api_mark_choices(cands: Array, seq: Array, obs: Dictionary) -> void:
seq.append("choices_presented")
obs["entry_cands"] = cands.duplicate()
func _api_mark_node(_node: Dictionary, _info: Dictionary, seq: Array) -> void:
seq.append("node_entered")
# ③ 冒险验收断言(映射 冒险结构.md 验收标准——表覆盖/满配通关/种子复现/大奖励/败北线/
# 重复拒绝/GP 跨关延续/加算区同账/恢复数学/放弃)
func _smoke_adventure() -> void:
print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---")
var autoplay = load("res://smoke/level_autoplay.gd")
# ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行——覆盖义务断言)
check(int(_db.level.rows.size()) == 3, "序列关数 = 行集关数(三关正式序列生效)")
var bg_keys := {}
for row in _db.board_gen.rows:
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var en_keys := {}
for row in _db.enemy.rows:
en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var miss := 0
for row in _db.level.rows:
for n in _level_active_nodes(int(row.level_index)):
var nt := String(n.get("type", ""))
if nt != "battle" and nt != "boss":
continue
var key := "%d_%d" % [int(row.level_index), int(n.get("node_depth", -1))]
if not bg_keys.has(key) or not en_keys.has(key):
miss += 1
check(miss == 0, "行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行(缺失 %d)" % miss)
# ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复)
var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
var sum: Dictionary = st["summary"]
check(String(st["result"]) == "completed" and bool(sum.get("cleared", false)),
"满配通关(42 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)")
check(int(sum.get("levels_cleared", -1)) == 3, "通关几关 = 3(levels_cleared)")
var ks_exp := 0
for li in range(1, int(_db.level.rows.size()) + 1):
ks_exp += int(_db.meta_boss_keystone_row(li).get("first_kill", 0))
check(int(sum.get("keystone_total", -1)) == ks_exp,
"钥石合计 = ΣBoss 首杀行值(%d——逐杀掉落新口径;断点未注入恒首杀)" % int(sum.get("keystone_total", -1)))
var drops: Array = sum.get("keystone_drops", []) as Array
check(drops.size() == 3, "逐杀掉落明细恰 3 笔(L1/L2/L3——additive 摘要键 keystone_drops)")
check(int(sum.get("master_seed", -1)) == 42, "摘要携带主种子(42)")
var picks: Array = []
var resolves: Array = []
var node_picks := 0
for ev in (st["reward_events"] as Array):
if (ev as Dictionary).has("curio_pick"):
picks.append((ev as Dictionary)["curio_pick"])
elif (ev as Dictionary).has("node_curio_pick"):
node_picks += 1 # 奖励节点三选一(追加批——每关奖励节点各 1 次)
elif (ev as Dictionary).has("curio_resolved"):
resolves.append((ev as Dictionary)["curio_resolved"])
check(picks.size() == 2 and resolves.size() == 2 + node_picks,
"通关三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)")
check(node_picks >= 1, "奖励节点三选一 ≥1 次(L1 链必经 1 个;L2/L3 图可选路经若干——实际经停 %d)" % node_picks)
var node_resolves := 0
for r0 in resolves:
if String((r0 as Dictionary).get("context", "")) == "reward_node":
node_resolves += 1
check(node_resolves == node_picks, "奖励节点兑付回执与经停逐笔相等(context=reward_node——同账计数 %d/%d)" % [node_resolves, node_picks])
var level_resolves: Array = []
for r0 in resolves:
if String((r0 as Dictionary).get("context", "")) != "reward_node":
level_resolves.append(r0)
var heal_ok := picks.size() == 2
var cand_ok := picks.size() == 2
var ks_gain_ok := picks.size() == 2
var resolve_ok := level_resolves.size() == 2
var max_flat := int(_db.adventure.curio_value_max_hp_common)
var heal_pct := int(_db.adventure.clear_heal_pct)
var hp_acc := 0 # max_hp 件累加(回血封顶断言的生效上限推导——TP0 起按兑付令牌实值,大小档分别计)
var ks_prefix := 0
for i in picks.size():
var p: Dictionary = picks[i]
var cands: Array = p.get("candidates", []) as Array
if cands.size() < 2 or cands.size() > 3:
cand_ok = false
var seen := {}
for c in cands:
# 身份键 = kind|tier|relic_id(迭代14① T4 断言基线复核——TP0 v2.2 relic 按 relic_id 分列
# 不参与同 kind 去重〔_token_identity relic: 前缀〕,同 tier 三枚不同流派件=三个不同选项合法态)
var ckey := "%s|%s|%s" % [String((c as Dictionary).get("kind", "")), String((c as Dictionary).get("tier", "")), String((c as Dictionary).get("relic_id", ""))]
if seen.has(ckey):
cand_ok = false
seen[ckey] = true
var heal: int = int(p.get("heal_gained", -1))
var hp_before: int = int(p.get("hp_before", -1))
var hp_after: int = int(p.get("hp_after", -1))
# 模拟档 v3.4 商店贪心购血合法抬升(L2/L3 店在 Boss 前)——回血口径窗口 = 加算区件 + 购血余量 0..15
# T17 账本口径修复随批:置满/回血封顶改生效上限口径(flat 后乘调试浮窗 pct)——窗口同步 ×5(本段 pct=400)
var heal_ok_i := false
for extra in 46:
var m := int(round(float(int(_db.hero.max_hp) + hp_acc + extra) * 5.0))
if heal == mini(int(floor(float(m) * float(heal_pct) / 100.0)), m - hp_before):
heal_ok_i = true
break
if not heal_ok_i or hp_after != hp_before + heal or hp_after > int(round(float(int(_db.hero.max_hp) + hp_acc + 45) * 5.0)):
heal_ok = false
ks_prefix += int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0))
if int(p.get("keystone_gained", -1)) != int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) or int(p.get("keystone_total", -1)) != ks_prefix:
ks_gain_ok = false
if resolve_ok:
var r: Dictionary = level_resolves[i]
var rk := String(r.get("picked", ""))
var rt := String(r.get("tier", ""))
if _smoke_find_token(p.get("candidates", []) as Array, rk, rt).is_empty() or int(r.get("value", -1)) != int(_db.adventure.curio_value(rk, rt)):
resolve_ok = false
if rk == "max_hp":
hp_acc += int(r.get("value", max_flat))
check(cand_ok, "三选一候选 2–3 枚且零重复(令牌洗牌不放回——kind+tier 复合身份)")
check(heal_ok, "通关部分回血封顶(floor(上限×比例) 封顶缺口——血回满退役;购血容差窗口)")
check(ks_gain_ok, "钥石逐杀入账(首杀档位 + 累计逐位)")
check(resolve_ok, "点选恰兑付选中一件(值=令牌档位表值;未选项零生效)")
var seq: Array = st["encounter_max_hp_seq"]
var k := 5.0 # hp+400 ⇒ 调试浮窗百分比后乘 ×5(加算先加、百分比后乘——生效次序口径)
var exp_base := maxi(1, int(round(float(int(_db.hero.max_hp)) * k)))
# 模拟档 v3.4 商店贪心购血(数值修复批)合法抬升——上限窗口 = 基础 + 加算区 3 件 + 购血余量;
# TP0 数值对后 max_hp 件有大生命 +10(rare 档)——上界按大件最坏构成 3×10+购血 15=+45(TP0 放宽)
var hp_ceiling := maxi(1, int(round(float(int(_db.hero.max_hp) + 3 * int(_db.adventure.curio_value("max_hp", "rare")) + 15) * k)))
var seq_ok := seq.size() > 0 and int(seq[0]) == exp_base
for v in (seq as Array):
if int(v) < exp_base or int(v) > hp_ceiling:
seq_ok = false
check(seq_ok,
"各战上限入参 ∈ [基础, 基础+3×件值+购血余量](覆盖值通道——双渠道共享上限 3;模拟档贪心购血合法叠加〔数值修复批 v3.4〕;首战=基础 %d)" % exp_base)
# ③ 种子域可复现(同种子两局:summary + 上限序列 + 行进序列 + 三选一停点载荷逐位一致)
var st2: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
check(st2["summary"] == sum and st2["encounter_max_hp_seq"] == seq
and st2["visited_ids"] == st["visited_ids"] and st2["reward_events"] == st["reward_events"],
"同种子两局逐位可复现(summary/上限序列/行进序列/三选一载荷——reward_pick 封闭流)")
# ⑤ 败北线(零配 L1 败北 + 半配 L2 败北——种子扫描,仿商店房 range(42,92) 先例)
var lose := {}
for s in range(42, 92):
var stl: Dictionary = autoplay.run_adventure(_db, s, 0, 0)
if String(stl["result"]) == "failed" and int(stl["summary"].get("stopped_at", -1)) == 1:
lose = {"s": s, "st": stl}
break
check(not lose.is_empty(), "零配 L1 败北种子可达(seed=%s)" % str(lose.get("s", -1)))
if not lose.is_empty():
var sl: Dictionary = lose["st"]["summary"]
check(not bool(sl["cleared"]) and int(sl["levels_cleared"]) == 0 \
and int(sl["keystone_total"]) == 0 and int(sl["stopped_at"]) == 1,
"L1 败北摘要:未打穿 · 通关 0 关 · 钥石 0 · 止步第 1 层")
var dl: Dictionary = sl.get("defeat", {})
check(int(dl.get("level_index", -1)) == 1 and int(dl.get("node_id", -1)) >= 0 \
and String(dl.get("node_type", "")) in ["battle", "boss"],
"L1 败北败退明细:层号/节点编号/节点类型在案(数值验证取数面 2026-09-22)")
var half := {}
for s in range(42, 142):
var sth: Dictionary = autoplay.run_adventure(_db, s, 50, 0)
if String(sth["result"]) == "failed" and int(sth["summary"].get("stopped_at", -1)) == 2:
half = {"s": s, "st": sth}
break
check(not half.is_empty(), "半配(atk+50)L2 败北种子可达(seed=%s)" % str(half.get("s", -1)))
if not half.is_empty():
var sh: Dictionary = half["st"]["summary"]
check(not bool(sh["cleared"]) and int(sh["levels_cleared"]) == 1 \
and int(sh["keystone_total"]) == int(_db.meta_boss_keystone_row(1).get("first_kill", 0)) \
and int(sh["stopped_at"]) == 2,
"L2 败北摘要:L1 已过(钥石=L1 首杀掉落)· 止步第 2 层")
var dh: Dictionary = sh.get("defeat", {})
check(int(dh.get("level_index", -1)) == 2 and int(dh.get("node_id", -1)) >= 0 \
and String(dh.get("node_type", "")) in ["battle", "boss"],
"L2 败北败退明细:层号=2 · 节点在案(数值验证取数面 2026-09-22)")
# ⑥ 重复宣布拒绝(settle 后再调 _on_level_finished——摘要零变化)
var adr6 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(7))
adr6.set_hero_override(900, 400)
check(adr6.start_adventure(7, {"first_script": true}), "重复拒绝场景:冒险启动(seed 7——固定模板 0 同上)")
adr6.runner().pick_next_node(int(adr6.runner().next_candidate_ids()[0])) # 停点前移批 2026-09-23:入口停点 pick 进首战
var b6 = adr6.runner().current_battle()
var st6 := _adv_st()
var g6 := 30
while adr6.runner().current_battle() == b6 and g6 > 0:
g6 -= 1
autoplay._autoplay_battle(adr6.runner(), b6, st6, null)
check(adr6.abandon(), "重复拒绝场景就位:选路态放弃 → 已结算")
var sum6_before: Dictionary = adr6.summary.duplicate(true)
adr6._on_level_finished({"result": "victory", "hp": 10, "node_index": 1, "node_id": 1, "node_type": "battle"})
check(adr6.summary == sum6_before, "重复关卡结束宣布拒绝(账本与摘要零变化——契约 7)")
# ⑦ GP 跨关延续(L1 收束无 clear · 每战入账恰一次 · L2 商店房余额 > 0 · 冒险收束 clear 恰一次为末事件)
var wallet: Array = st["wallet_events"]
var clears := 0
var gains := 0
for ev in wallet:
if String(ev["event"]) == "clear":
clears += 1
elif String(ev["event"]) == "gain" and String(ev.get("source", "")) != "curio_pick":
gains += 1 # 战斗完成入账计数(三选一 GP 袋件入账另有 source 标记——不计战斗口径)
check(gains == int(st["battles"]), "每战完成入账恰一次(gain %d = battles %d——GP 袋件入账单列)" % [gains, int(st["battles"])])
check(clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear",
"钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)")
var shop_seq: Array = st["shop_gp_seq"]
check(shop_seq.size() == 2 and int(shop_seq[0]) > 0,
"L2 商店房到达余额 > 0(%d——L1 积累跨关延续;两关商店房必经各 1)" % (shop_seq[0] if shop_seq.size() > 0 else -1))
# ⑧ 加算区同账(手工场景:L2 商店购买 → 三选一直调——同一本账累加 + 部分回血封顶)
var same := {}
for s in range(42, 92):
var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s))
a8.set_hero_override(900, 400)
if not a8.start_adventure(s, {"first_script": true}): # 固定模板 0——种子扫描语义按现行图
continue
if _adv_drive_to_shop(a8, _adv_st(), autoplay):
same = {"a": a8, "s": s}
break
check(not same.is_empty(), "同账场景种子可达(seed=%s——L2 商店房就位)" % str(same.get("s", -1)))
if not same.is_empty():
var a8 = same["a"]
var r8 = a8.runner()
var gp0: int = int(r8.gp)
var hp_price := -1
for p in _db.shop.products:
if String(p.get("product_id", "")) == "hp_up":
hp_price = int(p.get("base_price", -1))
var l2_price_bonus := 0
for pb in _db.shop.price_level_bonus:
if int(pb.get("level_index", -1)) == 2:
l2_price_bonus = int(pb.get("bonus", 0))
check(gp0 >= hp_price + l2_price_bonus,
"进店余额 ≥ hp_up L2 首价(%d = 基价 %d + 关卡加成 %d——配置推导)" % [hp_price + l2_price_bonus, hp_price, l2_price_bonus])
check(r8.buy_product("hp_up"), "购买 hp_up 受理(出账 + 加算区)")
var acc_shop := int(a8.bonus.get("max_hp", -1)) # L1 三选一后 + 商店 3(首件随种子)
check(acc_shop >= 3, "统一冒险加算区含商店值(max_hp=%d ≥ 商店 3——L1 三选一件值另计)" % acc_shop)
a8._on_level_finished({"result": "victory", "hp": int(r8.hero_hp), "node_index": r8.node_index,
"node_id": r8.current_node_id(), "node_type": "shop"}) # 模拟 L2 通关(victory 载荷直调——非末关)
check(not a8._curio_pending.is_empty(), "L2 通关三选一停点在案(直调兑付前——零自动推进)")
check(not a8.continue_to_next_level(), "未兑付推进拒绝(选择停点零自动推进契约)")
var l2_cands: Array = a8._curio_pending["candidates"]
var l2_pick: Dictionary = l2_cands[0]
check(a8.pick_curio(String(l2_pick.get("kind", "")), String(l2_pick.get("tier", ""))), "三选一点选兑付受理(候选取首件)")
var picked2: String = String(a8._curio_result.get("picked", ""))
var expect_acc: int = acc_shop + (int(a8._curio_result.get("value", 0)) if picked2 == "max_hp" else 0)
check(int(a8.bonus.get("max_hp", -1)) == expect_acc,
"三选一属性件与商店购买同账累加(max_hp=%d = %d + 三选一%s)" % [int(a8.bonus.get("max_hp", -1)), acc_shop, picked2])
var max_eff8: int = a8.effective_max_hp() # T17 口径修复随批:账本生效上限含调试浮窗 pct(a8 已 set 400)
check(int(a8.ledger["hero_hp"]) <= max_eff8,
"通关部分回血封顶(hp %d ≤ 生效上限 %d——血回满语义退役)" % [int(a8.ledger["hero_hp"]), max_eff8])
check(a8.continue_to_next_level(), "兑付后推进受理(下一关开始)")
# ⑨ 恢复点数学(接口③封顶公式三读数 + 同式两落点一致)
var a9 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(99))
check(int(a9.apply_rest(30, 70, 100)) == 30 and int(a9.apply_rest(30, 95, 100)) == 5 \
and int(a9.apply_rest(30, 100, 100)) == 0,
"恢复点数学:floor(100×30%)=30 · 封顶缺口 5 · 满血回 0 合法")
var lr9 = load("res://core/level_runner.gd").new(_db, _fresh_rng(99))
check(int(lr9.rest_heal_amount(30, 70, 100)) == 30 and int(lr9.rest_heal_amount(30, 95, 100)) == 5,
"同一公式两个落点逐位一致(LevelRunner.rest_heal_amount 内置默认)")
# ⑩ 放弃(战斗中拒绝 · 非战斗期受理同失败型结算)
var a10 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(15))
a10.set_hero_override(900, 400)
check(a10.start_adventure(15, {"first_script": true}), "放弃场景:冒险启动(seed 15——固定模板 0 同上)")
check(a10.runner().pick_next_node(int(a10.runner().next_candidate_ids()[0])),
"放弃场景:入口停点 pick 进首战(停点前移批 2026-09-23 驱动口径)")
check(not a10.abandon(), "战斗中 abandon 拒绝(战斗域单事务在飞)")
var b10 = a10.runner().current_battle()
var st10 := _adv_st()
var g10 := 30
while a10.runner().current_battle() == b10 and g10 > 0:
g10 -= 1
autoplay._autoplay_battle(a10.runner(), b10, st10, null)
check(a10.runner().current_battle() == null and a10.abandon(), "非战斗期(选路态)abandon 受理")
# ⑪ 池耗即枯竭 + 池收缩 + 满血回 0(TP0——white-box:耗尽 attack/ap 令牌后候选零出现;
# 种类次数上限退役,「达上限剔除」语义由牌库耗尽承载)
var a11 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(11))
check(a11.start_adventure(11, {"first_script": true, "treasure_unlocked": {"blast_kit": true}}),
"剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 blast_kit)")
for di in range(a11._curio_deck.size() - 1, -1, -1):
var dk := String((a11._curio_deck[di] as Dictionary).get("kind", ""))
if dk == "attack" or dk == "ap":
a11._curio_deck.remove_at(di)
var draw: Array = a11._draw_curio_candidates()
var draw_kinds := {}
for t in draw:
draw_kinds[String((t as Dictionary).get("kind", ""))] = true
check(not draw_kinds.has("attack") and not draw_kinds.has("ap"),
"池耗即枯竭(attack/ap 令牌耗尽不出候选——%s)" % str(draw))
check((draw as Array).size() >= 2, "池耗后候选恒 ≥2(稀有/罕见档余量——防「三选项全不可选」死角)")
# 池收缩(迭代 11 同调批 T7——aux 卡退役出三选一牌库:重复抽样零 aux 候选;同调位替换两路
# white-box 测试随停点渠道退役拆除——机械惰性承载,零投放零行为)
var treasure_seen := false
for i in 40:
for t in a11._draw_curio_candidates():
if String((t as Dictionary).get("kind", "")) == "treasure_item":
treasure_seen = true
break
check(not treasure_seen, "池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)")
# 满血回 0 合法(通关回血封顶口径)
var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12))
a12.start_adventure(12, {"first_script": true})
var cap := {"payload": {}}
a12.curio_choice_presented.connect(func(p): cap["payload"] = p)
var max_full := int(_db.hero.max_hp) + int(a12._base_override.get("max_hp", 0))
a12.ledger["hero_hp"] = max_full
a12._on_level_finished({"result": "victory", "hp": max_full, "node_index": 1, "node_id": 1, "node_type": "shop"})
check(int(cap["payload"].get("heal_gained", -1)) == 0,
"满血通关回血 0 合法(封顶口径——heal_gained=0 非负溢出)")
# ⑬ 奖励节点共享池(迭代 10 追加批——同库同池同摘牌;TP0:attack 令牌耗尽后节点域同样零出现)
var a13 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(13))
check(a13.start_adventure(13, {"first_script": true, "treasure_unlocked": {"blast_kit": true}}),
"节点共享池场景:冒险启动(seed 13)")
for di13 in range(a13._curio_deck.size() - 1, -1, -1):
if String((a13._curio_deck[di13] as Dictionary).get("kind", "")) == "attack":
a13._curio_deck.remove_at(di13)
var ndraw: Dictionary = a13.node_curio_candidates()
var ncands13: Array = ndraw.get("candidates", []) as Array
var has_attack13 := false
for t in ncands13:
if String((t as Dictionary).get("kind", "")) == "attack":
has_attack13 = true
break
check(not has_attack13,
"奖励节点共享池:令牌耗尽同样不出候选(同库同池——%s)" % str(ncands13))
a13.present_node_curio(ncands13, ndraw.get("aux_detail", []) as Array)
a13.present_node_curio([{"kind": "gp", "tier": "common"}])
var gp13 := int(a13.ledger.get("gp", 0))
check(a13.apply_node_curio("gp", "common") and int(a13.ledger.get("gp", 0)) == gp13 + int(_db.adventure.curio_value("gp", "common")),
"奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.adventure.curio_value("gp", "common")))
check(a13._node_curio_pending.is_empty(), "节点停点态兑付后清零(恰一次语义同构)")
var s10: Dictionary = a10.summary
check(String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true)),
"放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)")
# ---- 双层 BD 规则改写型特性基建冒烟(迭代 12 T1——relic_table/档位激活引擎/规则改写型特性带池接线)----
func _smoke_relics() -> void:
print("--- 规则改写型特性基建(relic_table · 档位激活引擎 · 规则改写型特性带接线)---")
# ① 现行表牌库构成:七流派 27 件全量入池——98 数值令牌+27 relic 令牌(批次一铺量完成)
check(_db.relics != null and (_db.relics.rows as Array).size() == 27, "relic_table 已加载(七流派 27 件全量——批次一 T2-T6 铺量完成)")
var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71))
check(a_r0.start_adventure(71, {"first_script": true}), "规则改写型特性带场景:冒险启动(seed 71)")
var relic_tokens := 0
var rarest_tokens := 0
for t in a_r0._curio_deck:
if String((t as Dictionary).get("kind", "")) == "relic":
relic_tokens += 1
if String((t as Dictionary).get("tier", "")) == "rarest":
rarest_tokens += 1
check(relic_tokens == 27 and rarest_tokens == 6 and a_r0._curio_deck.size() == 125,
"规则改写型特性带入池:27 件(rare 档 21 + rarest 锚点 6)——牌库 98+27=125")
check(_db.relics.validate().is_empty(), "relic_table.validate 零违例(有行流派扫雷端铁律——magic first_dig_spell 在案)")
# ② 档位激活引擎(纯函数直测——构造表 4 件 magic + 1 件 flag + 1 件 rarest 锚点)
var rt := RelicTable.new()
rt.rows = [
{"relic_id": "m1", "display_name": "魔一", "rarity": "common", "archetype": "magic", "hook": "hero_attack_rider", "effects": {"value": 1}},
{"relic_id": "m2", "display_name": "魔二", "rarity": "common", "archetype": "magic", "hook": "first_dig_spell", "effects": {"value": 1}},
{"relic_id": "m3", "display_name": "魔三", "rarity": "rare", "archetype": "magic", "hook": "clamp_modifier", "effects": {"value": 1}},
{"relic_id": "m4", "display_name": "魔四", "rarity": "rare", "archetype": "magic", "hook": "hero_attack_rider", "effects": {"tier_value": 2}},
{"relic_id": "f1", "display_name": "旗一", "rarity": "common", "archetype": "flag", "hook": "flag_resource", "effects": {"value": 1}},
]
check(rt.validate().is_empty(), "构造 relic 表 validate 零违例(magic 4 件含 first_dig_spell 扫雷端——红线①铁律过)")
var tiers := RelicsEngine.tiers_for(["m1", "m2", "m3", "m4", "f1"], rt)
check(int(tiers.get("magic", -1)) == 2 and int(tiers.get("flag", -1)) == 0,
"档位激活:magic 4 件→档二 · flag 1 件→散件档 0(集 2 档一/4 档二——以撒套装/小骨铭文先例)")
check(RelicsEngine.is_formed(tiers, "magic") and not RelicsEngine.is_formed(tiers, "flag"),
"成型判读:magic 成型(tier≥1)· flag 未成型(散件单体保底——档位只质变不启动)")
var t2 := RelicsEngine.tiers_for(["m1", "m2"], rt)
check(int(t2.get("magic", -1)) == 1, "档位激活:2 件→档一(同集合同档位确定性——MC 逐位可复放承载)")
var hooks := RelicsEngine.hooks_for(["m1", "f1"], rt)
check(hooks.size() == 2 and String(hooks[0].get("hook", "")) == "hero_attack_rider",
"挂点投影:表序稳定(同集合→同投影)")
# ③ 规则改写型特性带接线(临时表注入——relic 令牌入牌库/兑付入持有集/踢池/断点往返;测试尾还原现行表)
var live_rows: RelicTable = _db.relics
_db.relics = rt
var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72))
check(a_r.start_adventure(72, {"first_script": true}), "规则改写型特性带场景:冒险启动(seed 72——注入 5 行临时表)")
var deck_relic := 0
for t in a_r._curio_deck:
if String((t as Dictionary).get("kind", "")) == "relic":
deck_relic += 1
check(deck_relic == 5 and a_r._curio_deck.size() == 103,
"规则改写型特性带入池:5 行 relic 每件 1 张(common/rare→稀有档 rarest→罕见档)——牌库 98+5=103")
var relic_seen: Dictionary = {}
for i in 60:
for t in a_r._draw_curio_candidates():
if String((t as Dictionary).get("kind", "")) == "relic":
relic_seen[String(t.get("relic_id", ""))] = true
check(relic_seen.size() >= 2, "抽签出 relic 候选(60 次重抽 ≥2 种不同 relic——身份键 relic_id 同档可多件)")
a_r._curio_pending = {"candidates": [{"kind": "relic", "tier": "rare", "relic_id": "m3"}, {"kind": "gp", "tier": "common"}], "payload": {}}
var deck_m3 := 0
for t in a_r._curio_deck:
if String((t as Dictionary).get("relic_id", "")) == "m3":
deck_m3 += 1
check(a_r.pick_curio("relic", "rare", "m3") and (a_r.relics_held() as Array).has("m3") and a_r.pool_stock("relic") == deck_relic - 1,
"relic 兑付:入持有集 + 牌库摘牌(选中踢池——池库存 %d→%d)" % [deck_relic, deck_relic - 1])
# 断点往返(持有集+余库 relic_id 随行)
var sm_r = load("res://core/save_manager.gd").new("user://_smoke_relics_bp.json")
sm_r.load_from_disk()
a_r.set_save_manager(sm_r)
a_r._on_level_finished({"result": "victory", "hp": int(a_r.ledger.get("hero_hp", 26)), "node_index": 1, "node_id": 1, "node_type": "shop"})
var bp_r: Dictionary = sm_r.breakpoint_snapshot()
check((bp_r.get("relics", []) as Array).has("m3"), "断点附随件:relics 持有集入快照(迭代 12 T1 扩列)")
var bp_deck_m3 := 0
for t in (bp_r.get("bonus_deck", []) as Array):
if String((t as Dictionary).get("relic_id", "")) == "m3":
bp_deck_m3 += 1
check(bp_deck_m3 == 0, "断点余库:已兑付 relic 令牌逐枚还原(m3 零残留——续玩不重复获得)")
sm_r.clear_breakpoint()
_db.relics = live_rows # 还原现行表(共享 _db 防污染——后续冒烟段零感知)
# ④ T2 魔法流求值面(现行表 magic 四件——flags 组装直测;rider 落地行为=伤害控制器消费面,
# 既有战斗冒烟全绿=relic_flags 空 dict 零扰动实证;全链分岔读数归 T7 MC)
var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(73))
orch._relic_flags_for(["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance"])
var rf: Dictionary = orch._damage.relic_flags
check(int(rf.get("rider_flat", -1)) == 2 and bool(rf.get("rider_crit", false)),
"魔法流求值:rider_flat=余烬1+档一(4件)+1=2 · 档二 rider 可暴击(共鸣 rider 提升以奥术矢+档位承载——锚点双效果 provisional 披露)")
check(int(rf.get("clamp_magic_magic", -1)) == 1 and int(rf.get("clamp_magic_heavy", -1)) == 1
and bool(rf.get("first_dig_spell", false)) and int(rf.get("spell_value", -1)) == 2,
"魔法流求值:魔网织者 magic-vs-magic 罚修正 +1 · 湛蓝誓约 magic-vs-heavy +1 · 魔能共鸣奥术矢 value 2")
# ⑤ T3 标旗流求值面(现行表 magic 4+flag 4——混合持有:flag 档一 4 件成型)
orch._relic_flags_for(["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance",
"mine_atlas", "nest_hunter", "perfect_defense", "war_banner"])
var rf8: Dictionary = orch._damage.relic_flags
check(int(rf8.get("flag_resource_extra", -1)) == 1 and int(rf8.get("nest_aura_value", -1)) == 1
and int(rf8.get("flag_all_hit_mult", -1)) == 2 and int(rf8.get("flag_strike_value", -1)) == 2,
"标旗流求值:雷区图谱额外资源 1 · 猎巢人光环 +1/巢 · 完美布防 ×2 · 战旗军规旗伤 2(复用 board_typed 零新配方)")
# ⑥ T4 穿刺/普攻流求值面(全 16 件持有——四流派档位成型)
orch._relic_flags_for(["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance",
"mine_atlas", "nest_hunter", "perfect_defense", "war_banner",
"pierce_shot", "chain_arrows", "weak_instinct", "hunter_rapier",
"miner_stubborn", "miner_hammer", "focus_mind", "armor_crack"])
var rf16: Dictionary = orch._damage.relic_flags
check(bool(rf16.get("kill_overflow", false)) and int(rf16.get("chain_value", -1)) == 2
and int(rf16.get("discount_shift", -1)) == 1 and int(rf16.get("clamp_pierce_light", -1)) == 1,
"穿刺流求值:贯穿射击弹射旗 · 连珠矢段值 2 上限 2 · 猎弱本能折扣改善 1 档 · 猎手刺剑 pierce-vs-light +1")
check(int(rf16.get("round_dig_value", -1)) == 1 and int(rf16.get("hit_trade_atk", -1)) == 25
and int(rf16.get("hit_trade_hit", -1)) == 2 and int(rf16.get("armor_roll_rewrite", -1)) == 75
and int(rf16.get("crack_value", -1)) == 25,
"普攻流求值:执拗 +1/格上限 5 · 重锤交换 +25%/−2AC · 专注恒 75% 免掷骰 · 碎甲震击 off_guard 授予旗")
check(bool(rf16.get("rider_crit", false)) and bool(rf16.get("rider_crit", false)),
"档位质变:magic/pierce/normal/flag 四流派档二(16 件混合——同标签 ≥4 判档二确定性)")
# ⑦ T5 开格/蓄力流求值面(追加七件)
var ids23: Array = ["arcane_ember", "mana_weaver", "azure_oath", "mana_resonance",
"mine_atlas", "nest_hunter", "perfect_defense", "war_banner",
"pierce_shot", "chain_arrows", "weak_instinct", "hunter_rapier",
"miner_stubborn", "miner_hammer", "focus_mind", "armor_crack",
"greedy_lantern", "vein_intuition", "gambler_pact", "deep_drill",
"armory_prep", "iron_wall", "patient_burst"]
orch._relic_flags_for(ids23)
var rf23: Dictionary = orch._damage.relic_flags
check(int(rf23.get("dig_yield_pos_mult", -1)) == 85 and int(rf23.get("dig_yield_neg_mult", -1)) == 125
and int(rf23.get("combo_cap", -1)) == 5,
"开格流求值:正向乘区 25+10+50=85% · 负向加重 25+100=125% · 直觉连击上限 5(同 hook 累加——三件同持)")
check(int(rf23.get("deep_dig_cost", -1)) == 1 and int(rf23.get("deep_dig_cap", -1)) == 4
and int(rf23.get("ap_carry_value", -1)) == 1 and int(rf23.get("shield_per_layer", -1)) == 1
and int(rf23.get("spike_mult", -1)) == 2,
"深挖钻头=开掘 +1 AP/每 4 格深层产出(R4 甲非信息税对价)· 蓄力三件=层值 1/减伤 1/层/满层 ×2")
check(orch.action_economy.relic_ap_carry and orch.action_economy.relic_dig_extra == 1,
"蓄力/钻头 action_economy 注入(ap_carry 改写停手转存 + 付费开掘对价注入——关着 false/0 零扰动)")
# ⑦c 特性持有呈现段(2026-10-04 用户反馈补齐——C 区「特性」节:聚合行+成型档位;空态隐藏)
var panel = load("res://presentation/views/hero_info_panel_view.gd").new()
panel.set_relic_holdings([])
check(not panel._relic_section.visible, "特性段空态隐藏(视图空态纪律同特性段)")
panel.set_relic_holdings(["魔法 ×2 · 档一", "开格 ×1 · 散件"])
check(panel._relic_section.visible and panel._relic_list.get_child_count() == 2,
"特性段持有呈现(聚合行+成型档位常驻可读——「局内奖励选了什么看不到」缺口承载)")
# ⑦b 深挖钻头付费开掘回归(2026-10-04 试玩缺陷修复——对价挂付费侧):AP=1 降级照常开格
# (用户实测「AP=1 无法开格只能标旗」的回归闸);AP=2 付全价积付费计数(深层产出配额取数面)。
var ae_dd = load("res://core/action_economy.gd").new(_db)
ae_dd.relic_dig_extra = 1
ae_dd._phase = 1 if ae_dd.Phase.ACTIVE == 1 else ae_dd.Phase.ACTIVE # white-box 相位(冒烟专用)
ae_dd._ap = 1
check(ae_dd.request_dig(0, false) and ae_dd.deep_paid_count == 0,
"钻头降级:AP=1 照常开格(只付基础 1 不积付费——持有钻头永不锁死挖格,散件保底 B-2)")
ae_dd._ap = 2
ae_dd._phase = ae_dd.Phase.ACTIVE # 首挖 AP 归 0 已翻轮(ap_exhausted 仲裁)——白盒复位续测
check(ae_dd.request_dig(0, false) and ae_dd.deep_paid_count == 1,
"钻头付费开掘:AP=2 付全价(基础 1+对价 1——deep_paid_count 积数=深层产出配额取数面)")
# ⑧ T6 吞噬流求值面(27 件全量——七流派档位成型)
var ids27: Array = ids23.duplicate()
ids27.append_array(["ley_harvest", "thorn_weaver", "purge_sapper", "magma_body"])
orch._relic_flags_for(ids27)
var rf27: Dictionary = orch._damage.relic_flags
check(int(rf27.get("terrain_refund", -1)) == 1 and int(rf27.get("terrain_dig_double", -1)) == 1
and int(rf27.get("flag_neutralize", -1)) == 1 and int(rf27.get("variant_counter_value", -1)) == 1,
"吞噬流求值:地脉采撷补偿 1 · 荆棘编织双产出 · 净化工兵中和旗(🔴 仅伤害型——过滤在消费面)· 熔火反噬 1/层")
var orch0 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(74))
orch0._relic_flags_for([])
check((orch0._damage.relic_flags as Dictionary).is_empty(),
"零持有求值=零旗(relic 关着零扰动——伤害管线既有行为逐位不变)")
# 手工场景驱动:L1 全程 + L2 推进到商店房(_drive_level 复用 + 兑付确认/选路/大奖励确认手工编排)
func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool:
var guard := 24
while guard > 0 and String(st["result"]) == "":
guard -= 1
autoplay._drive_level(a.runner(), st, null, "normal", "min")
if String(st["result"]) != "":
return false
if a._pending_next_level:
# 三选一停点确认(迭代 10——确定性选件后推进,与 run_adventure 自动策略同口径;TP0 令牌化)
if not a._curio_pending.is_empty():
var bcands: Array = a._curio_pending["candidates"]
var bpick: Dictionary = autoplay._pick_token(bcands)
a.pick_curio(String(bpick.get("kind", "")), String(bpick.get("tier", "")), String(bpick.get("relic_id", "")))
a.continue_to_next_level()
continue
var r = a.runner()
if String(r.current_node().get("type", "")) == "shop":
return true
if r.awaiting_choice():
var ids: Array = r.next_candidate_ids()
ids.sort()
r.pick_next_node(int(ids[0]))
continue
var t := String(r.current_node().get("type", ""))
if t == "reward":
# 奖励节点三选一(迭代 10 追加批——确定性选件、替换放弃新件、兑付确认回图;TP0 令牌化)
var ndraw: Dictionary = a.node_curio_candidates()
var ncands: Array = ndraw.get("candidates", []) as Array
if not ncands.is_empty():
a.present_node_curio(ncands, ndraw.get("aux_detail", []) as Array)
var npick: Dictionary = autoplay._pick_token(ncands)
if a.apply_node_curio(String(npick.get("kind", "")), String(npick.get("tier", "")), String(npick.get("relic_id", ""))):
if not a._node_curio_pending.is_empty() and a._node_curio_pending.has("incoming"):
a.resolve_curio_replace(-1)
r.continue_after_node()
elif t == "rest":
r.continue_after_node()
return false
# 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集)
func _adv_st() -> Dictionary:
return {"result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0,
"rest_healed": 0, "hp_before_rest": -1, "defeat_node_index": -1, "defeat_node_type": "",
"boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false, "log_path": "",
"policy": "normal", "refreshes": 0, "mine_hits": 0, "visited_ids": [], "choices": 0}
# ---- 战斗屏棋盘布局·格边长公式回归(2026-09-19 真人试玩 bug⑥:L2 12×12 板下缘被裁约 1/6 格高——
# 旧公式 N×cell ≤ 可用高−12 未计 (N−1)×2px 网格间距,网格实高溢出被 clip_contents 裁掉;
# 修复 = 公式计入间距 + 垂直预留 24px〔上 12/下 12——底部不贴 D 区边框〕)----
func _smoke_layout_cell_formula() -> void:
print("--- 棋盘布局格边长公式(底部预留回归)---")
var board_generator = load("res://core/board_generator.gd").new()
var board_view = load("res://presentation/views/board_view.gd").new()
for L in [1, 2]:
var b = board_generator.generate(L, 0, _mk_stream("board_gen", 555), _db)
board_view.rebuild(b, null)
var avail := Vector2(1036, 460) # 受约束 D 区可用面积样本(1280×800 档量级——用户实测裁切场景)
board_view.set_cell_area(avail)
var n: int = maxi(b.width, b.height)
var cell: float = board_view.get("_cell")
var grid_h := n * int(cell) + (n - 1) * 2
check(grid_h <= int(avail.y) - 24, "L%d %dx%d 板网格高 %d ≤ 可用高 %d − 预留 24(格边 %dpx——下缘不贴边不裁切)" % [L, b.width, b.height, grid_h, int(avail.y), int(cell)])
check(cell >= 26.0 and cell <= 46.0, "L%d 格边在 26–46 档内(%dpx)" % [L, int(cell)])
board_view.free()
# ---- 地图战斗资源·身份面单元冒烟(格面资源批:roll 恰一次 / 雷安同池 / 幂等 / 清图批尾)----
func _smoke_reward_identity_unit() -> void:
print("--- 地图战斗资源·格面资源身份面 ---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260911)
var reward_economy_factory = load("res://core/reward_economy.gd")
var reward_economy = reward_economy_factory.new(_db, rng)
var eligible: Array = []
for i in range(6):
eligible.append({"cell": Vector2i(i, 0)})
check(reward_economy.roll_board(eligible), "roll_board 受理")
check(not reward_economy.roll_board(eligible), "重复 roll_board 被拒(恰一次——double-roll 守卫)")
# 身份查询投影 = {reward_id, cell_type, tier};五型封闭
var types_ok := true
var types_seen := {}
for e in eligible:
var ident: Dictionary = reward_economy.query_identity(e.cell)
if not (ident.has("reward_id") and ident.has("cell_type") and ident.has("tier")):
types_ok = false
if not RewardTable.CELL_TYPES.has(String(ident.cell_type)):
types_ok = false
types_seen[String(ident.cell_type)] = true
check(types_ok, "query_identity 投影 = {reward_id, cell_type, tier} 且五型封闭(样本 %s)" % str(types_seen.keys()))
# 清单外坐标 = null(E19——封锁格不入清单的同款出口)
check(reward_economy.query_identity(Vector2i(99, 99)) == null, "清单外坐标查询 = null(封锁格无身份同口径)")
# 同格重复受理幂等:同一快照逐字段相同
var snap1: Dictionary = reward_economy.grant(eligible[0].cell)
var snap2: Dictionary = reward_economy.grant(eligible[0].cell)
check(JSON.stringify(snap1) == JSON.stringify(snap2), "同格重复 grant 幂等(同一快照——受理恰一次)")
check(reward_economy.grant(Vector2i(99, 99)) == null, "清单外 grant 拒绝")
# 清图批尾身份:clear_default 唯一活行 clear_blast(blast 型正向);重调幂等
var cs1: Dictionary = reward_economy.grant_clear()
var cs2: Dictionary = reward_economy.grant_clear()
check(String(cs1.cell_type) == "blast" and String(cs1.reward_id) == "clear_blast", "清图批尾身份 = clear_blast(clear_default 唯一活行)")
var cb_range := [-1, -1]
for row in _db.reward.rows:
if String(row.reward_id) == "clear_blast":
cb_range = [int(row.get("power_min", row.power)), int(row.get("power_max", row.power))]
check(int(cs1.power) >= int(cb_range[0]) and int(cs1.power) <= int(cb_range[1]),
"清图奖励 power ∈ 表区间 [%d, %d](roll 值 %d——配置推导)" % [cb_range[0], cb_range[1], int(cs1.power)])
check(JSON.stringify(cs1) == JSON.stringify(cs2), "grant_clear 同批次重调幂等")
# 退役后受理拒绝
reward_economy.retire_mapping()
check(reward_economy.grant(eligible[0].cell) == null, "映射退役后 grant 拒绝(生命周期闭合)")
# ---- 地图战斗资源·伤害量值区间批冒烟(2026-09-14:身份批 roll 域内 + 中点锚 + 变异实际流动 + 定值行不受扰)----
# ---- 排雷玩法 旗动作耗尽末点 AP 回归(playtest 缺陷②——v1 冒烟盲区:自动玩家不用旗)----
func _smoke_flag_ap_exhausted() -> void:
print("--- 排雷玩法·旗动作耗尽末点 AP 回归 ---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260911)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rounds: Array = []
settlement.round_started.connect(func(ri): rounds.append(ri))
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载旗回归场景)")
var minesweeper = settlement.minesweeper
# 恰花 (AP-1) 点挖掘,留末点给旗(表驱动——2026-09-16 AP 压速批 8→6 不再写死)
var ap_total: int = int(_db.action_economy.ap_per_round)
for i in range(ap_total - 1):
if settlement.action_economy.phase != "Active" or settlement.action_economy.ap == 0:
break
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}])
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
check(settlement.action_economy.phase == "Active" and settlement.action_economy.ap == 1, "%d 次挖掘后恰剩 1 AP(phase=%s ap=%d)" % [ap_total - 1, str(settlement.action_economy.phase), settlement.action_economy.ap])
# 末点 AP 花在插旗——轮次必须终结并结算进入第 2 轮
# 旗目标 = 挖掘策略的下一目标格(构造保证:未开、与小人邻接——先把小人移到其邻接已开格)
var plan2 = AutoPlayer.choose_dig(minesweeper)
check(not plan2.is_empty(), "仍存在可挖目标(旗目标来源)")
var out: Array = []
if not plan2.is_empty():
if plan2.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan2.hero_cell}])
out = minesweeper.settle_batch([{"action": "flag_place", "cell": plan2.dig_cell}])
check(not out.is_empty() and bool(out[0].accepted), "末点 AP 插旗被受理")
check(rounds.size() >= 2, "旗动作耗尽 AP 触发轮末结算并进入第 2 轮(rounds=%s)" % str(rounds))
# ---- 关卡节点地图·线性推进冒烟(2026-09-11 关卡扩展批)----
func _smoke_level() -> void:
print("--- 关卡节点地图·线性推进(战×2→道具→战×2→恢复→Boss)---")
var autoplay = load("res://smoke/level_autoplay.gd")
var hero_base := {"atk": int(_db.hero.atk), "hp": int(_db.hero.max_hp), "ac": int(_db.hero.armor_class)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码)
# ① 满配通关路径(atk+200%/hp+100%——测试面板同源的属性覆盖)
var win = autoplay.run_level(_db, 42, 1, 200, 100)
check(win.result == "victory", "关卡·满配(atk+200/hp+100)通关(实测:%s @节点 %d)" % [win.result, win.defeat_node_index])
check(win.battles == 5, "战斗节点数 = 5(战×4 + Boss)")
check(win.boss_depth == 4, "Boss 节点以 Dmax=4 解析(棋盘生成 第三轴登记约定)")
check(win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3, "进 Boss HP 携带在域内(%d)" % win.hp_at_boss)
# ② 恢复点:回复 = min(floor(生效max_hp×pct/100), 缺口)——hp+100% 档生效 max = 基线×2
# seed 42(2026-09-19 充能退役批换种:原 4242 因主角输出模型变化在恢复点前败北——探针目的 = 验回复公式,种子仅载体)
var rest = autoplay.run_level(_db, 42, 1, 100, 100)
if rest.result != "start_failed" and rest.hp_before_rest >= 0:
var eff_max := maxi(1, int(round(float(_db.hero.max_hp) * 2.0))) # hp_pct=100(run_level 已恢复基线)
var expect: int = mini(int(floor(float(eff_max) * 0.3)), eff_max - rest.hp_before_rest)
check(rest.rest_healed == expect, "恢复点回复 = floor(生效max_hp×30%%) 与缺口的较小值(%d/%d,生效 max %d)" % [rest.rest_healed, expect, eff_max])
else:
check(false, "恢复点未到达(seed 42 hp+100 在恢复点前败北?%s)" % rest.result)
# ③ 裸装败北路径(atk1/hp10——Roguelike 基线应难以通关)
var lose = autoplay.run_level(_db, 42, 1, -80, -80)
check(lose.result == "defeat", "关卡·裸装(atk≈1/hp≈10)败北路径可达(实测:%s)" % lose.result)
check(lose.defeat_node_index >= 0 and lose.defeat_node_index < 7, "败北节点记录在域内(节点 %d)" % lose.defeat_node_index)
# ④ 回放确定性:同种子两遍步骤流逐行相同
var rep1 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0)
var rep2 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0)
check(JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows), "关卡回放断言:同种子两遍结算步骤流逐行相同")
check(rep1.rows.size() > 0, "关卡步骤流非空(%d 行)" % rep1.rows.size())
# ⑤ 属性覆盖恢复:模拟后 hero 表回基线(调整板不泄漏到后续会话;基线 = 函数入口的配置快照,改表自动跟随)
check(int(_db.hero.atk) == int(hero_base.atk) and int(_db.hero.max_hp) == int(hero_base.hp) \
and int(_db.hero.armor_class) == int(hero_base.ac),
"模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" % [int(_db.hero.atk), int(_db.hero.max_hp), int(_db.hero.armor_class)])
# ---- 护甲等级掷点批·换装护栏(2026-09-16 用户拍板:百分比减伤行整体退役换装 AC,无双轨)----
# ① 换装在位:defense_up 状态行/池映射/奖励行三处全无残留,ac_up 三处就位(unit=ac,2026-09-20 改只续时不叠层);
# ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行
# magnitude × max_stack > pool_clamp_lo——单效果叠满不可触池底(off_guard 已于 2026-09-18
# 结构修复批退离 hero_defense 池换装 AC 轴,池内现无 percent 行——本断言防未来负向行回归时静默造墙);
# ③ 单位表 armor_class 字段:主角 10(暂定)/ 敌方行 0(暂定——正式值归怪物设定批);
# ④ 掷点参数域:档位边界升序/档位值降序无零档/骰面与暴击参数在域(加载校验双保险)。
func _smoke_ac_swap_guard() -> void:
print("--- 护甲等级掷点批·换装护栏 ---")
# ① 换装在位(三处无双轨)
check(_db.status_effect_row("defense_up").is_empty(), "状态表 defense_up 行已退役(无双轨)")
var ac_row: Dictionary = _db.status_effect_row("ac_up")
check(not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \
and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \
and String(ac_row.get("stack_policy", "")) == "refresh",
"ac_up 行在位(hero · +1 ac · 只续时不叠层——2026-09-20 主角增益续时批)")
var ac_reward := {}
for row in _db.reward.rows:
if String(row.get("cell_type", "")) == "defense" and String(row.get("polarity", "")) == "positive":
ac_reward = row
check(String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "护甲强化",
"奖励表 defense 正向行 effect_id = ac_up(显示名暂定护甲强化,权重/tier 不变)")
check(not _db.settlement.effect_pool_map.has("defense_up") and not _db.settlement.effect_pool_map.has("ac_up"),
"effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)")
# ② 免伤墙归零护栏(泛化——BUG-0003 免疫)
var wall_ok := true
var wall_detail := ""
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 row.is_empty() or String(row.get("unit", "")) != "percent":
continue
var floor_v: int = int(row.get("magnitude", 0)) * int(row.get("max_stack", 1))
if floor_v <= int(_db.settlement.pool_clamp_lo):
wall_ok = false
wall_detail += " %s(%d)" % [eff_id, floor_v]
check(wall_ok, "hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" % [
wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.pool_clamp_lo)])
# ③ 单位表 armor_class:域内校验(配置推导——正式投值归怪物/难度批,改表不断锚)
check(int(_db.hero.armor_class) >= 0 and int(_db.hero.armor_class) <= 30,
"主角 armor_class 在域内(%d——暂定基准随表走)" % int(_db.hero.armor_class))
var ac_band := true
var ac_detail := ""
var heavy_names := ["熊地精重击手", "灰矮人重装手"] # U2 裁 v1 硬梯度(2026-09-29):逐行手设值 8/9,非精英编码位
for row in _db.enemy.rows:
var ac: int = int(row.get("armor_class", -1))
var dname := String(row.get("display_name", "?"))
var boss_row: bool = String(row.get("enemy_id", "")).ends_with("_chief") or \
String(row.get("enemy_id", "")) == "drider" or String(row.get("enemy_id", "")) == "duergar_king"
var heavy_row: bool = dname in heavy_names
# 杂鱼 0~5;重击手/重装手 8~9(U2 返工批手设精英值);Boss 8~12 台阶
var lo := 8 if (boss_row or heavy_row) else 0
var hi := 12 if boss_row else (9 if heavy_row else 5)
if ac < lo or ac > hi:
ac_band = false
ac_detail += " %s:%d" % [dname, ac]
check(ac_band, "敌方 AC 行值在已裁带宽内(杂鱼 0~5·重击手/重装手 8~9·Boss 8~12——迭代 5 返工批 v1 重投,越带行%s)" % [
ac_detail if ac_detail != "" else "无"])
# ③b Boss 台阶断言(关卡即台阶——L1 8 < L2 10 < L3 12)+ 首遇位教学位低 AC(≤3)
var boss_ac := {}
for row in _db.enemy.rows:
var eid := String(row.get("enemy_id", ""))
if eid == "ogre_chief" or eid == "drider" or eid == "duergar_king":
boss_ac[int(row.get("level_index", 0))] = int(row.get("armor_class", 0))
check(int(boss_ac.get(1, 0)) < int(boss_ac.get(2, 0)) and int(boss_ac.get(2, 0)) < int(boss_ac.get(3, 0)),
"Boss AC 关卡台阶严格递增(%d < %d < %d)" % [int(boss_ac.get(1, 0)), int(boss_ac.get(2, 0)), int(boss_ac.get(3, 0))])
# ④ 掷点参数域(加载校验已过——此处钉形状语义)
var bounds: Array = _db.settlement.roll_tier_bounds
var discounts: Array = _db.settlement.roll_tier_discounts
check(int(_db.settlement.roll_faces) == 20 and int(_db.settlement.crit_roll) == 20 \
and int(_db.settlement.crit_multiplier) == 200 and int(_db.settlement.damage_floor) == 1 \
and int(_db.settlement.attack_bonus) == 0 and String(_db.settlement.armor_roll_stream) == "armor_roll",
"掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)")
check(bounds == [3, 7] and discounts == [75, 50, 25], "档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)")
# ---- 护甲等级掷点批·行为面(2026-09-16 拍板验收:折扣档分布 / nat20 暴击 / 伤害下限 / 棋盘来源必中)----
func _smoke_armor_roll() -> void:
print("--- 护甲等级掷点(查档纯函数 + 暴击 + 伤害下限 + AC12 分布 + 棋盘必中)---")
# ① 查档纯函数:全掷点域逐位对照(AC10 / AC12)+ EV 锚(AC10 期望减免 21.25% —— registry 参考数)
var expect_ac10 := {100: 11, 75: 3, 50: 4, 25: 2} # 掷 10-20 全额 / 7-9 / 3-6 / 1-2
var expect_ac12 := {100: 9, 75: 3, 50: 4, 25: 4} # 掷 12-20 全额 / 9-11 / 5-8 / 1-4
for cfg in [{"ac": 10, "expect": expect_ac10}, {"ac": 12, "expect": expect_ac12}]:
var dist := {100: 0, 75: 0, 50: 0, 25: 0}
var tier_ok := true
for roll in range(1, 21):
var d: int = DamageMath.roll_discount_percent(roll, cfg.ac, _db.settlement.roll_tier_bounds, _db.settlement.roll_tier_discounts)
if not dist.has(d):
tier_ok = false
continue
dist[d] += 1
check(tier_ok and dist == cfg.expect, "查档 AC%d 全掷点域分布(100/75/50/25 = %s)" % [cfg.ac, str([dist[100], dist[75], dist[50], dist[25]])])
var ev_sum := 0
for roll in range(1, 21):
ev_sum += DamageMath.roll_discount_percent(roll, 10, _db.settlement.roll_tier_bounds, _db.settlement.roll_tier_discounts)
check(ev_sum == 1575, "AC10 EV 锚:期望系数合计 %d/2000 = 期望减免 21.25%%(nat20 恒全额含内)" % ev_sum)
# ② nat20 暴击(行为面):并流批(ADR-0007 裁丙)后掷点位随全局序漂移——探针改为按行为面扫描
# 「主角先手首掷 = 20」的种子,不再假设 armor_roll scope0 位
var crit_seed := -1
var crit_rows := {}
for s in range(1, 800):
crit_rows = _first_hero_attack_rows(s)
if int(crit_rows.get("roll", -1)) == int(_db.settlement.crit_roll):
crit_seed = s
break
check(crit_seed > 0, "nat20 种子可达(seed=%d——主角先手首掷命中 %d)" % [crit_seed, int(_db.settlement.crit_roll)])
check(int(crit_rows.get("roll", -1)) == int(_db.settlement.crit_roll) and bool(crit_rows.get("crit", false)),
"nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量)")
check(int(crit_rows.get("normal", -1)) == 2 * int(_db.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.hero.atk),
"nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" % [int(crit_rows.get("normal", -1)), int(_db.hero.atk)])
# ③ 削弱续时(行为面):授予→衰减一轮→再授予 → 层数保持 1、剩余结算数重置(2026-09-18 结构
# 修复批叠层改续时——削弱 ×2 叠满池触底 ×0 的免伤轮结构性根治,旧 ×2 叠层采样通道随批退役)
var weaken_probe := _weaken_refresh_probe()
check(int(weaken_probe.get("layers", -1)) == 1 and int(weaken_probe.get("remaining", -1)) == 2,
"削弱重复授予 → 层数保持 1、剩余结算数重置 2(续时语义——结构性消饱和)")
check(int(weaken_probe.get("pool", -999)) == int(_db.status_effect_row("weaken").magnitude),
"削弱单层池读数 = 行值(enemy_output 池单层贡献——×2 叠满触底 ×0 不可达,免伤轮根治)")
# ④ AC11 折扣档分布(行为面):ac_up 逐轮授给(时效 1)→ Boss 行攻击拍逐拍判定
# (2026-09-20 主角增益续时批后 ac_up 层数恒 1——局内可达 AC 上限 10+1=11,探针随行下调)
var tiers := _ac11_tier_samples()
var tier_distinct := {}
var tiers_valid := true
for t in tiers:
tier_distinct[int(t)] = true
if not [100, 75, 50, 25].has(int(t)):
tiers_valid = false
check(tiers_valid and tiers.size() >= 3, "AC11 攻击拍逐拍判定(×%d 拍全部落档域 {100,75,50,25})" % tiers.size())
check(tier_distinct.size() >= 2, "折扣档实际流动(AC11 实测档位 %s——≥2 档可见)" % str(tier_distinct.keys()))
# ⑤ 棋盘来源必中 + 吃掷点行留档(全战斗流扫描)
var run = _run_battle(1, 0)
var board_ok := true
var attack_ok := true
var all_positive := true
for row in run.stream:
if String(row.get("type", "")) != "damage":
continue
if int(row.get("value", 0)) <= 0:
all_positive = false
var src := String(row.get("source_class", ""))
if src == "hero_attack" or src == "enemy_action":
if not (row.has("roll") and row.has("ac") and row.has("tier")):
attack_ok = false
elif row.has("roll"):
board_ok = false # 棋盘来源(爆裂/直伤/清障炸药)必中全额——不掷不留档
check(all_positive, "全战斗流零 v=0 伤害行(伤害下限全局生效)")
check(attack_ok, "hero_attack/enemy_action 伤害行掷点读数+档位随行留档(P2 归因)")
check(board_ok, "棋盘来源伤害行零掷点字段(爆裂/直伤/清障炸药/奖励即时必中全额)")
# ⑥ 全来源伤害下限(2026-09-20 用户拍板:任何伤害落地取值域 [1,+∞) 无上限——棋盘克制直伤此前
# 靠表值 2+(−1)=1 贴底无结构保证,反噬/地形变异/爆裂同为表值直落,本批全部收进 damage_floor 闸内)
var df: int = int(_db.settlement.damage_floor)
check(DamageMath.counter_component(2, -1, df) == 1, "棋盘克制直伤贴底 → 下限 1(点数 2 vs 不利修正 −1)")
check(DamageMath.counter_component(1, -3, df) == 1, "克制修正触底穿透 → 下限 1(点数 1 vs 修正 −3)")
check(DamageMath.counter_component(0, 5, df) == 0, "空桶不吃修正恒 0(零点数分量无伤害可落地——边界不变不受下限影响)")
# 定位种子下首场战斗第一行主角攻击伤害行(停手轮——零授予零充能,普通分量 = 攻击基线裸值)
func _first_hero_attack_rows(seed_val: 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 rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}):
return {}
settlement.minesweeper.settle_batch([{"action": "stop"}])
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "hero_attack":
return row
return {}
# 削弱续时探测(2026-09-18 结构修复批——旧 _weaken_zero_round_rows ×2 叠层采样随批退役):
# 授予 → 衰减一轮 → 再授予,读 weaken 实例投影 {layers, remaining, pool}。
# 直授通道(来源格真实坐标惯例——沿前例);depth 2 板(D2 熊地精)仅作宿主,行为与敌种无关。
func _weaken_refresh_probe() -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260916)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
return {}
var src: Dictionary = {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index}
settlement._status_effects.apply_effect("weaken", src)
settlement._status_effects.decay_round() # remaining 2 → 1(先兑付后衰减——只借衰减器,不结算)
settlement._status_effects.apply_effect("weaken", src) # 续时:remaining ← 2,层数不动
for eff in settlement._status_effects.query_active("enemy"):
if String(eff.effect_id) == "weaken":
# T6 收口批:改走伤害控制器公有算术面 clamped_pool(原编排器 _clamped_pool 薄委托的直呼退役)
return {"layers": int(eff.layers), "remaining": int(eff.remaining_settlements), "pool": int(settlement._damage.clamped_pool("enemy_output"))}
return {}
# AC11 折扣档采样:每停手轮前直授 ac_up ×1(时效 1 逐轮衰减)——敌拍逐拍判定留档。
# (2026-09-20 主角增益续时批:ac_up 只续时不叠层、层数恒 1——原 ×2 授给达 AC12 的采样随批降为 AC11;
# 敌人技能池批:拍序改为摇号制,原 AC 批定影种子不再保证档位多样性——扫描种子取首个 ≥2 档样本集。)
func _ac11_tier_samples() -> Array:
for seed_offset in range(20):
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260917 + seed_offset)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
continue
var guard := 12
while settlement.battle_result == "" and guard > 0:
guard -= 1
settlement._status_effects.apply_effect("ac_up", {"source_class": "safe_cell", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index})
settlement.minesweeper.settle_batch([{"action": "stop"}])
var tiers: Array = []
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
tiers.append(int(row.get("tier", -1)))
var distinct := {}
for t in tiers:
distinct[int(t)] = true
if tiers.size() >= 3 and distinct.size() >= 2:
return tiers
return []
# ---- 主角乘区接线 + 硬化折半方向(2026-09-18 结构修复批护栏)----
# 同种子停手轮恒同掷点(效果只改乘区、不动掷点流)→ 落地伤害精确关系:
# 增伤 = 基线 ×1.5、乏力 = ×0.75(hero_offense 池乘攻击合计——2026-09-19 充能退役后 = 单分量,直乘旁路退役);
# 硬化 = 基线 ×0.5 且 < 基线(enemy_defense 池负值折半——+100 翻倍符号 bug 随批纠正)。
# 基线构成(加法核 2026-09-18 + 充能退役 2026-09-19):基线 100——hero attack_type=normal 对轻甲中性 0 修正
# (目标 = L1 地精轻甲;AC 0 恒全额非暴击种子)。
func _smoke_pool_wiring() -> void:
print("--- 主角乘区接线 + 硬化折半方向 ---")
var base_row := _hero_attack_row_with({}, 100, 20260918)
var up_row := _hero_attack_row_with({"effects": ["attack_up"]}, 100, 20260918)
var sapped_row := _hero_attack_row_with({"effects": ["sapped"]}, 100, 20260918)
var fortify_row := _hero_attack_row_with({"effects": ["fortify"]}, 100, 20260918)
var base_v := int(base_row.get("value", -1))
check(int(base_row.get("normal", -1)) == base_v and base_v > 0, "单分量基线在位(normal 分量 = 落地值、基线 %d——充能退役批)" % base_v)
check(int(up_row.get("value", -1)) == int(floor(base_v * 1.5)),
"增伤经乘区加成合计(%d → %d = ×1.5——attack_up 直乘旁路退役,覆盖面=攻击合计)" % [base_v, int(up_row.get("value", -1))])
check(int(sapped_row.get("value", -1)) == int(floor(base_v * 0.75)),
"乏力经乘区削减合计(%d → %d = ×0.75)" % [base_v, int(sapped_row.get("value", -1))])
check(int(fortify_row.get("value", -1)) == int(floor(base_v * 0.5)) and int(fortify_row.get("value", -1)) < base_v,
"硬化折半方向(%d → %d = ×0.5 且 < 基线——敌方防御池负值减伤,翻倍 bug 退役)" % [base_v, int(fortify_row.get("value", -1))])
# 构造停手轮首行主角攻击行:opts = {effects: Array};hero_atk 覆盖 100 拉开量级
# (floor 关系可精确断言);效果直授沿 _weaken_refresh_probe 前例。
func _hero_attack_row_with(opts: Dictionary, hero_atk_val: int, seed_val: 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 rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_atk": hero_atk_val}):
return {}
for eff in opts.get("effects", []):
settlement._status_effects.apply_effect(String(eff), {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index})
settlement.minesweeper.settle_batch([{"action": "stop"}])
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "hero_attack":
return row
return {}
func _smoke_board_generator() -> void:
print("--- 棋盘生成 ---")
var board_generator = load("res://core/board_generator.gd").new()
for L in [1, 2]:
var row = _db.resolve_board_gen(L, 0)
check(not row.is_empty(), "BoardGen 行解析 (%d,0)" % L)
if L == 2:
# R1 ceil 口径(2026-09-18 拍板):无精确行向上取整保证偏难——L2 深度 3 为恢复点无行,解析到深度 4
var ceil_row: Dictionary = _db.resolve_board_gen(2, 3)
check(int(ceil_row.get("node_depth", -1)) == 4, "R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)")
var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db)
check(b1 != null, "L%d 生成产物非空" % L)
if b1 == null:
continue
check(board_generator.is_clearable(b1), "L%d clearable(安全格 8 邻连通)" % L)
check(board_generator.legality(b1, row, _db), "L%d legality 全过" % L)
var inf := float(b1.metrics.get("inferability", 0.0))
var mode := String(b1.metrics.get("accept_mode", ""))
print(" L%d 实测:inferability=%.3f / attempts_used=%d / accept_mode=%s / target=%.2f" % [
L, inf, int(b1.metrics.get("attempts_used", -1)), mode, float(row.inferability_target)])
# 出图验收(棋盘生成 登记口径):first_pass 必须 ≥ target;accept_best(重试耗尽接受最优)允许低于 target
# 但必须打印实测值(正常出口);accept_worst 由下方 illegal_product 断言承担。
check(inf >= float(row.inferability_target) or mode == "accept_best",
"L%d inferability %.3f %s target %.2f(accept_mode=%s)" % [L, inf, "≥" if inf >= float(row.inferability_target) else "<", float(row.inferability_target), mode])
check(b1.variants.is_empty(), "L%d 生成器产物零变体格(变异布置归战斗初始化第 4 步——生成器管线不布置,2026-09-19 用户拍板棋盘设置顺序)" % L)
check(b1.metrics.has("degraded_product") and b1.metrics.has("target_mines"), "L%d 指标集输出降级标记与目标雷数(三分出口表)" % L)
check(b1.degraded_product == (mode == "accept_best"), "L%d 降级产物标记 = (accept_mode==accept_best)(%s→%s)" % [L, mode, str(b1.degraded_product)])
check(b1.illegal_product == (mode == "accept_worst"), "L%d 非法产物标记 = (accept_mode==accept_worst)(%s→%s)" % [L, mode, str(b1.illegal_product)])
check(not b1.degraded_product or not b1.illegal_product, "L%d 降级/非法产物标记互斥(两出口不可同真)" % L)
check(int(b1.metrics.actual_mines) <= int(b1.metrics.target_mines), "L%d 产物断言:实际雷数 %d ≤ 目标雷数 N=%d(先布后删只减不增)" % [L, int(b1.metrics.actual_mines), int(b1.metrics.target_mines)])
check(b1.metrics.get("attempts_used", 99) >= 1 and b1.metrics.get("attempts_used", 99) <= int(row.max_gen_attempts), "L%d attempts_used ∈ [1,%d]" % [L, int(row.max_gen_attempts)])
check(not b1.illegal_product, "L%d 非法产物标记 = false" % L)
# 生成确定性:同种子逐格相同
var b2 = board_generator.generate(L, 0, _mk_stream("board_gen", 777), _db)
var b3 = board_generator.generate(L, 0, _mk_stream("board_gen", 777), _db)
check(_board_dump(b2) == _board_dump(b3), "L%d 同种子两次生成逐格相同" % L)
var b4 = board_generator.generate(L, 0, _mk_stream("board_gen", 778), _db)
check(_board_dump(b2) != _board_dump(b4), "L%d 异种子产物不同(退化护栏)" % L)
# ---- 初始展开收窄批断言(迭代14① T4)----
# ① 结构断言:R₀ ≤ cap(正式表行投值,合法性闸承载——出图必过);② r0_cap_rejected 尝试事实在位;
# ③ ring_removed 量级:剃雷域收窄后 ≤ 域内候选雷格数(5×5−出生 9=16——全域剃雷退役);
# ④ 闸压线行为:cap=9(出生保障面下限)时出图必 r0≤9 或 accept_worst 非法标记(三分出口诚实降级,玩家永不卡死)
var cap_val: int = int(row.get("r0_max_cap", 0))
check(cap_val >= BoardData.SPAWN_AREA_CELLS, "L%d r0_max_cap 正式行投值在位(%d ≥ 出生保障面 9——板面 30%% 锚)" % [L, cap_val])
check(int(b1.metrics.r0_count) <= cap_val, "L%d 结构断言 R₀=%d ≤ cap=%d(R₀≤cap 重掷闸承载)" % [L, int(b1.metrics.r0_count), cap_val])
check(int(b1.metrics.get("r0_cap_rejected", -1)) >= 0, "L%d r0_cap_rejected 尝试事实指标在位(本图 %d 次触发)" % [L, int(b1.metrics.get("r0_cap_rejected", -1))])
check(int(b1.metrics.get("ring_removed", 99)) <= (2 + int(row.get("shave_ring_radius", 1))) * (2 + int(row.get("shave_ring_radius", 1))) * 4 - 5, "L%d ring_removed=%d ≤ 剃雷域候选雷格数((3+2r)²−9——全域剃雷退役)" % [L, int(b1.metrics.get("ring_removed", 99))])
var row9: Dictionary = row.duplicate()
row9["r0_max_cap"] = BoardData.SPAWN_AREA_CELLS
row9["max_gen_attempts"] = 4
var stub9 := _BoardRowStub.new()
stub9.row = row9
var b9 = board_generator.generate(1, 0, _mk_stream("board_gen", 909), stub9)
check(b9 != null and (int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS or b9.illegal_product),
"L1 cap 压线 9:出图 R₀=%d %s(三分出口诚实降级——超阈即 accept_worst 非法标记,玩家永不卡死)" % [int(b9.metrics.r0_count), "≤9" if int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS else ">9→illegal_product"])
check(int(b9.metrics.get("r0_cap_rejected", 0)) >= 0 and String(b9.metrics.get("accept_mode", "")) != "", "L1 cap 压线出图指标集完整(accept_mode=%s / 闸触发 %d 次)" % [String(b9.metrics.get("accept_mode", "")), int(b9.metrics.get("r0_cap_rejected", 0))])
func _mk_stream(stream_name: String, scope: int) -> RandomNumberGenerator:
# 并流批(ADR-0007 裁丙):stream_name 降格为调用点自述标签(不参与派生),保留参数零改调用方
var rg := RandomNumberGenerator.new()
rg.seed = _rng.derive_at(scope)
return rg
# ---- 地形变异 框架面(首批类型定稿批 2026-09-19:四行两轴×两关 + 门控 + 跨字段互锁)----
func _smoke_variation_framework() -> void:
print("--- 地形变异 框架面(首批四类型 + 候选池门控 + 跨字段互锁)---")
check(_db.variation.rows.size() == 7, "变异清单 7 行(荆棘丛/藤蔓缠绕/岩浆/烬覆 + 落石/矿尘 + 贫矿脉——首批类型定稿批 + 第三关内容批 + 全局作用域变异首发批)")
check(_db.variation.validate().is_empty(), "变异表校验零错误(7 行全过域与跨字段互锁——含 scope=global 三互锁)")
var pool1_ids: Array = []
for r in _db.variant_pool(1, "dungeon"):
pool1_ids.append(String(r.variant_id))
check(pool1_ids == ["thorn", "vine"], "L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)")
var pool2_ids: Array = []
for r in _db.variant_pool(2, "underdark"):
pool2_ids.append(String(r.variant_id))
check(pool2_ids == ["magma", "ash"], "L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)")
check(_db.variant_pool(1, "underdark").is_empty(), "unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)")
var pool2d_ids: Array = []
for r in _db.variant_pool(2, "dungeon"):
pool2d_ids.append(String(r.variant_id))
check(pool2d_ids == ["thorn", "vine"], "board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)")
check(_db.variation.row_by_id("nonexistent").is_empty(), "清单外 variant_id 返回空(读取通道在位)")
# 跨字段互锁负样本(加载期致命——枚举语义一致性闸)
var vt = load("res://data/schemas/variation_table.gd").new()
vt.rows.assign([{"variant_id": "bad1", "display_name": "坏行甲", "unlock_level": 1, "scope": "region",
"effect": "flat_damage", "effect_value": 1, "trigger": "on_open", "placement": "any",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本①:flat_damage + placement=any 被拒(只出现在未打开的格子)")
vt.rows.assign([{"variant_id": "bad2", "display_name": "坏行乙", "unlock_level": 1, "scope": "region",
"effect": "hide_number", "effect_value": 0, "trigger": "on_open", "placement": "any",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本②:hide_number + trigger≠on_number_shown 被拒")
func _errs_has_cross_field(errs: Array) -> bool:
for e in errs:
if String(e.get("category", "")) == "cross_field":
return true
return false
# ---- 全局作用域变异 首发批断言面(2026-10-04 迭代 14 ③号卡——T0-T5 集中:互锁/门控三维/
# 替换制/零取号/资源面偏斜守恒与合成序/快照投影/呈现面锚)----
# 取号计数桩(variant_place 零取号断言——子类拦截计数,派生与取数语义零改动)
class _CountVariantRng extends RngServiceCore:
var variant_calls := 0
func open_stream(purpose: String) -> RandomNumberGenerator:
if purpose == "variant_place":
variant_calls += 1
return super.open_stream(purpose)
# 板行覆盖桩(迭代14① T4——cap 压线行为断言;接口面 = resolve_board_gen 单点)
class _BoardRowStub:
var row: Dictionary
func resolve_board_gen(_L: int, _D: int) -> Dictionary:
return row
func status_effect_row(_id: String) -> Dictionary:
return {}
func _smoke_global_variant() -> void:
print("--- 全局作用域变异 首发批(门控三维 + 替换制 + 资源面偏斜)---")
# ① 跨字段互锁负样本(resource_face ⟺ scope=global 三条 + effect_value 新域 [0,50])
var vt = load("res://data/schemas/variation_table.gd").new()
vt.rows.assign([{"variant_id": "g1", "display_name": "负甲", "unlock_level": 2, "scope": "region",
"effect": "resource_face", "effect_value": 30, "trigger": "always", "placement": "any",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "互锁负样本③:resource_face + scope=region 被拒(resource_face ⇒ global)")
vt.rows.assign([{"variant_id": "g2", "display_name": "负乙", "unlock_level": 2, "scope": "global",
"effect": "hide_number", "effect_value": 0, "trigger": "always", "placement": "any",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "互锁负样本④:scope=global + effect=hide_number 被拒(global ⇒ resource_face)")
vt.rows.assign([{"variant_id": "g3", "display_name": "负丙", "unlock_level": 2, "scope": "global",
"effect": "resource_face", "effect_value": 30, "trigger": "on_open", "placement": "any",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "互锁负样本⑤:scope=global + trigger=on_open 被拒(global ⇒ always 板级恒在)")
vt.rows.assign([{"variant_id": "g4", "display_name": "负丁", "unlock_level": 2, "scope": "global",
"effect": "resource_face", "effect_value": 30, "trigger": "always", "placement": "closed_only",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "互锁负样本⑥:scope=global + placement=closed_only 被拒(global ⇒ any 无格子布置域)")
vt.rows.assign([{"variant_id": "g5", "display_name": "负戊", "unlock_level": 2, "scope": "global",
"effect": "resource_face", "effect_value": 51, "trigger": "always", "placement": "any",
"board_types": [], "counter_items": []}])
var vterr: Array = vt.validate()
var domain_hit := false
for e in vterr:
if String(e.get("category", "")) == "domain":
domain_hit = true
check(domain_hit, "互锁负样本⑦:resource_face effect_value=51 越域 [0,50] 被拒(不适用 flat_damage [1,9] 加法核)")
# ② 门控第三维(节点身份——global 行仅 boss/elite 入池;缺省/普通节点静默跳过零报错)
var pool_boss_ids: Array = []
for r in _db.variant_pool(2, "underdark", "boss"):
pool_boss_ids.append(String(r.variant_id))
check(pool_boss_ids == ["magma", "ash", "skewed_veins"], "门控三维:L2 underdark boss 池 = region 两行 + 贫矿脉(board_types ∅ 通配)")
var pool_battle_ids: Array = []
for r in _db.variant_pool(2, "underdark", "battle"):
pool_battle_ids.append(String(r.variant_id))
check(pool_battle_ids == ["magma", "ash"], "门控三维:battle 节点静默跳过 global 行(普通图零报错零警告)")
check(_db.variant_pool(2, "underdark").is_empty() == false and _db.variant_pool(2, "underdark").size() == 2,
"门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)")
check(_db.variant_pool(1, "underdark", "boss").is_empty(), "L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)")
check(_db.global_variant_row(2, "underdark", "battle").is_empty(), "全局决议:battle 节点恒无全局型")
check(_db.global_variant_row(2, "underdark", "boss").get("variant_id", "") == "skewed_veins", "全局决议:L2 boss 取贫矿脉(确定性单行)")
check(_db.global_variant_row(1, "underdark", "boss").is_empty() and _db.global_variant_row(1, "dungeon", "boss").is_empty(),
"全局决议:L1 boss 全局池空 → 空行(兜底回落承载)")
check(_db.global_variant_row(2, "underdark", "elite").get("variant_id", "") == "skewed_veins", "elite 前向词位:boss 同池(首发零 elite 表行——词表位不造行)")
# ③ 替换制 + 零取号:L2 Boss 图恰一行板级变异 + 零 region 格 + variant_place 零消费
var crng = _CountVariantRng.new()
crng.set_master_seed(42)
var boss_settlement = load("res://core/battle_orchestrator.gd").new(_db, crng)
var boss_ok: bool = boss_settlement.begin_battle({"level_index": 2, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})
check(boss_ok, "L2 Boss 战斗启动(node_depth=4 boss 节点——替换制断言载体)")
if boss_ok:
var bb = boss_settlement.minesweeper.current_board()
check(_arr_str_eq(bb.board_variant_ids(), ["skewed_veins"]), "替换制:Boss 图 board_variants 恰一行 = 贫矿脉")
check(bb.variants.is_empty(), "替换制:Boss 图零 region 变异格(无格子级变异标记——呈现件闲置合法态)")
check(crng.variant_calls == 0, "零取号合法态:Boss 图 variant_place 零消费(全局取用确定性)")
var snap: Dictionary = boss_settlement.presentation_snapshot()
check(_arr_str_eq(snap.get("board_variants", []), ["skewed_veins"]), "快照投影:board_variants 呈现面(横幅/角标唯一读取面)")
# Boss 图确定性:同种子两遍逐位相同
var crng2 = _CountVariantRng.new()
crng2.set_master_seed(42)
var boss2 = load("res://core/battle_orchestrator.gd").new(_db, crng2)
if boss2.begin_battle({"level_index": 2, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}):
var bb2 = boss2.minesweeper.current_board()
check(_arr_str_eq(bb2.board_variant_ids(), ["skewed_veins"]) and crng2.variant_calls == 0,
"确定性:同种子 Boss 图两遍 board_variants/取号逐位相同")
# ④ 资源面偏斜投影(RewardEconomy 纯函数面——守恒/白名单/构造例/合成序)
var eco = load("res://core/reward_economy.gd").new(_db, load("res://core/rng_service.gd").new())
var base_rows: Array = eco._rollable_rows()
var base_sum: int = eco._pool_weight(base_rows)
check(base_sum == 77, "偏斜基线:正向池现行合计 77(挖掘风险批口径)")
eco.board_skew = 0
var off_rows: Array = eco._biased_cell_rows()
check(off_rows.size() == base_rows.size() and _rows_weights_equal(off_rows, base_rows), "off 态零扰动:board_skew=0 行集与基线逐位相同(不进投影域)")
eco.board_skew = 30
var on_rows: Array = eco._biased_cell_rows()
check(eco._pool_weight(on_rows) == 77, "合计守恒:偏斜后正向池合计 = 77(整数最大余数重归一)")
var wmap := _rows_weight_map(on_rows)
check(wmap.get("weaken", -1) == 8 and wmap.get("ap_up", -1) == 6 and wmap.get("gp_coin", -1) == 2,
"白名单豁免:weaken/gp/action 三行偏斜后逐位不动")
check(wmap.get("heal_minor", -1) == 3 and wmap.get("ac_up", -1) == 7, "生存族下调:heal 4→3 / defense 11→7(v=30 构造例)")
check(wmap.get("attack_up", -1) == 15 and wmap.get("spark_aoe", -1) == 18 and wmap.get("pierce_strike", -1) == 9 and wmap.get("magic_bolt", -1) == 9,
"进攻族上浮:attack 14→15 / blast 16→18 / pierce 8→9 / magic 8→9(v=30 构造例,等比上浮守恒)")
eco.board_skew = 30
eco.luck_bias = 2
var combo_rows: Array = eco._biased_cell_rows()
var cw := _rows_weight_map(combo_rows)
check(eco._pool_weight(combo_rows) == 77 + 4 * 2, "合成序:图级偏斜先(乘法面)件级 luck_bias 后(加法 +2×四稀有类)——合计 77+8")
check(cw.get("heal_minor", -1) == 5 and cw.get("gp_coin", -1) == 4 and cw.get("pierce_strike", -1) == 11 and cw.get("magic_bolt", -1) == 11,
"合成序构造例:skew(heal 3)+2=5 / gp 2+2=4 / pierce 9+2=11 / magic 9+2=11(图级定型后玩家构筑叠加)")
eco.luck_bias = 0
eco.board_skew = 50
var v50: Dictionary = _rows_weight_map(eco._biased_cell_rows())
check(eco._pool_weight(eco._biased_cell_rows()) == 77 and int(v50.get("heal_minor", -1)) > 0, "域上界 v=50:守恒成立且逐行权重恒正(域 [0,50] 安全)")
# 普通图摇号全链路确定性(off 路径)——同种子普通图资源映射逐位相同(golden 对拍另有批前快照)
var nrng = load("res://core/rng_service.gd").new()
nrng.set_master_seed(42)
var normal1 = load("res://core/battle_orchestrator.gd").new(_db, nrng)
normal1.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
var n1: Dictionary = _board_resource_dump(normal1)
var nrng2 = load("res://core/rng_service.gd").new()
nrng2.set_master_seed(42)
var normal2 = load("res://core/battle_orchestrator.gd").new(_db, nrng2)
normal2.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
check(n1 == _board_resource_dump(normal2) and not n1.is_empty(), "确定性:同种子普通图布置/摇号两遍逐位相同(off 全链路)")
func _arr_str_eq(a, b: Array) -> bool:
var arr: Array = a if a is Array else []
if arr.size() != b.size():
return false
for i in arr.size():
if String(arr[i]) != String(b[i]):
return false
return true
func _rows_weights_equal(a: Array, b: Array) -> bool:
if a.size() != b.size():
return false
for i in a.size():
if String(a[i].get("reward_id", "")) != String(b[i].get("reward_id", "")) or int(a[i].weight) != int(b[i].weight):
return false
return true
func _rows_weight_map(rows: Array) -> Dictionary:
var m := {}
for r in rows:
m[String(r.get("reward_id", ""))] = int(r.weight)
return m
func _board_resource_dump(settlement) -> Dictionary:
var b = settlement.minesweeper.current_board()
var dump := {}
for c in b.variants.keys():
dump["v:%d,%d" % [c.x, c.y]] = ",".join(b.variants[c])
for y in b.height:
for x in b.width:
var ident = settlement._reward_economy.query_identity(Vector2i(x, y))
if ident != null:
dump["r:%d,%d" % [x, y]] = String(ident.reward_id)
return dump
# ---- 地形变异 布置段(首批类型定稿批 2026-09-19——棋盘设置顺序第 4 步断言面)----
# 布置发生在编排器战斗初始化(install 初始展开 + roll_board 资源格之后),生成器产物零变异——
# 本段经 begin_battle 走完整初始化序后断言:门控一致性 / 布置域 / 数量公式 / 连通片上限 / 确定性。
func _smoke_variant_placement(L: int) -> void:
print("--- 地形变异 布置段(L%d)---" % L)
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(42)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp}):
check(false, "L%d 布置段战斗启动失败" % L)
return
var b = settlement.minesweeper.current_board()
check(b != null, "L%d 布置段板非空" % L)
if b == null:
return
var pool_ids: Array = []
for r in _db.variant_pool(L, String(b.board_type)):
pool_ids.append(String(r.variant_id))
var vcells: Array = b.variants.keys()
var vid0 := ""
var single_type := true
var bad_id := false
for c in vcells:
var ids: Array = b.variants[c]
if ids.size() != 1:
single_type = false
for vid in ids:
if vid0 == "":
vid0 = String(vid)
elif vid0 != String(vid):
single_type = false
if not pool_ids.has(String(vid)):
bad_id = true
check(not bad_id, "L%d 变异 id 全在候选池(门控一致性:%s)" % [L, str(pool_ids)])
check(single_type, "L%d 每图至多一种变异类型(单格单 id + 全图同 id)" % L)
var r0 := {}
for c in b.compute_r0():
r0[c] = true
var domain_ok := true
for c in vcells:
if r0.has(c) or b.is_mine(c):
domain_ok = false
check(domain_ok, "L%d 变异格全在初始展开区外且不吃雷格(布置域结构保障——closed_only)" % L)
var cand_count := 0
for c in b.safe_cells():
if not r0.has(c):
cand_count += 1
var expect_cap: int = int(round(float(cand_count) * float(_db.variation.percent_of_cells) / 100.0)) \
+ (L - 1) * int(_db.variation.per_level_bonus)
check(vcells.size() <= expect_cap, "L%d 变异格数 %d ≤ 公式上限 %d(可挖池百分比 + 关卡递增)" % [L, vcells.size(), expect_cap])
# 数量公式量随候选池缩放(2026-09-21 基数修正批):L1 无关卡加成,极小池(<10 格)可 round 至 0——
# 零布置为合法态(同池空口径);非零断言改为条件式,公式量>0 时才要求非零(L2/L3 关卡加成保底恒>0)
check(expect_cap == 0 or vcells.size() > 0, "L%d 公式量>0 时非零布置(极小池 round 至 0 = 合法零布置——2026-09-21 基数修正批)" % L)
var pack_max: int = int(_db.variation.pack_max)
var vset := {}
for c in vcells:
vset[c] = true
var seen := {}
var pack_ok := true
for c in vcells:
if seen.has(c):
continue
seen[c] = true
var comp_size := 1
var q: Array = [c]
while q.size() > 0:
var x: Vector2i = q.pop_front()
for n in b.neighbors8(x):
if vset.has(n) and not seen.has(n):
seen[n] = true
comp_size += 1
q.append(n)
if comp_size > pack_max:
pack_ok = false
check(pack_ok, "L%d 全部连通片 ≤ %d 格(全连通生长、2×2/1×4/1×3+1 等形)" % [L, pack_max])
# 布置确定性:全新 RngService 同种子重开,变异布置逐格相同(variant_place 流确定性)
var rng2 = load("res://core/rng_service.gd").new()
rng2.set_master_seed(42)
var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2)
s2.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp})
var b2 = s2.minesweeper.current_board()
check(b2 != null and _variant_dump(b2) == _variant_dump(b), "L%d 同种子变异布置逐格相同(variant_place 流确定性)" % L)
func _variant_dump(b) -> String:
var keys: Array = b.variants.keys()
keys.sort_custom(func(a, b2): return a.y * 1000 + a.x < b2.y * 1000 + b2.x)
var parts: Array = []
for c in keys:
parts.append("%d,%d:%s" % [c.x, c.y, str(b.variants[c])])
return "|".join(parts)
func _board_dump(b) -> String:
var parts: Array = [str(b.width), str(b.height), str(b.spawn_center)]
for y in b.height:
for x in b.width:
var c := Vector2i(x, y)
parts.append("%d:%d:%d:%s" % [x, y, 1 if b.is_mine(c) else 0, str(b.variants.get(c, []))])
return "\n".join(parts)
# ---- 完整战斗回放(自动玩家,确定性)——回放断言:同种子两遍步骤流逐行相同 ----
func _smoke_full_battle(L: int, D: int, label: String) -> void:
print("--- 完整战斗回放:%s ---" % label)
var run1 = _run_battle(L, D)
var run2 = _run_battle(L, D)
check(run1.terminated, "%s 战斗在轮次上限内终局(%d 轮,%s)" % [label, run1.rounds, run1.result])
if JSON.stringify(run1.stream) != JSON.stringify(run2.stream):
for i in range(maxi(run1.stream.size(), run2.stream.size())):
var a := JSON.stringify(run1.stream[i]) if i < run1.stream.size() else "<none>"
var b := JSON.stringify(run2.stream[i]) if i < run2.stream.size() else "<none>"
if a != b:
print(" 流差异 @%d:\n run1=%s\n run2=%s" % [i, a, b])
break
check(JSON.stringify(run1.stream) == JSON.stringify(run2.stream), "%s 回放断言:结算步骤流逐行相同" % label)
check(JSON.stringify(run1.events) == JSON.stringify(run2.events), "%s 回放断言:事件流逐位相同" % label)
check(run1.stream.size() > 0, "%s 结算步骤流非空(%d 行)" % [label, run1.stream.size()])
var has_verdict := false
var hero_attacks := 0
var hero_breakdown_ok := true
for row in run1.stream:
if row.get("type", "") == "verdict":
has_verdict = true
if row.get("source_class", "") == "hero_attack":
hero_attacks += 1
# 单分量伤害构成行(2026-09-19 充能退役批):hero_attack 行必须携带实际攻击类型键 + 掷点读数(P2 归因)
var atk_t: String = String(_db.hero.attack_type)
if not (row.has(atk_t) and row.has("roll") and row.has("ac") and row.has("tier")):
hero_breakdown_ok = false
check(has_verdict, "%s 步骤流含判定行" % label)
# 伤害道具批(2026-09-18):单弱敌遭遇可被挖掘期兑付(穿刺弹/魔法矢/爆裂粉尘)在授予段单独击杀——
# 敌全灭后战斗段整段跳过、零先手行为合法结局;先手规则本身由强敌场景(L1/L2 深节点)继续守护。
check(hero_attacks >= 1 or run1.terminated, "%s 主角先手攻击行存在或授予段终局(2026-09-11 拍板① + 伤害道具批)×%d" % [label, hero_attacks])
check(hero_breakdown_ok, "%s 主角攻击行携带类型键与掷点读数(单分量——充能退役批)" % label)
func _run_battle(L: int, D: int) -> Dictionary:
# 每遍全新 RngService(同 master 种子)——回放断言的隔离前提(地图战斗资源 AC"两套全新 RngService+地图战斗资源"同款)
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(42)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var out := {"stream": [], "events": [], "rounds": 0, "result": "", "terminated": false}
settlement.step_row.connect(func(row): out.stream.append(row))
settlement.battle_finished.connect(func(payload): out.result = payload.get("result", ""))
settlement.minesweeper.cell_revealed.connect(func(evt): out.events.append(evt))
settlement.minesweeper.flag_judged.connect(func(evt): out.events.append(evt))
settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag}))
settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag}))
var minesweeper = settlement.minesweeper
var action_economy = settlement.action_economy
if not settlement.begin_battle({"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp}):
out.stream.append({"type": "verdict", "value": "begin_failed"})
return out
var max_rounds := 40
while out.result == "" and out.rounds < max_rounds:
out.rounds += 1
_auto_play_round(settlement, out)
if out.result == "":
out.result = "timeout"
out.terminated = out.result == "victory" or out.result == "defeat"
if out.terminated:
# P0-3 回归断言(2026-10-02 修复批):notify_combat_end 曾被并进行尾注释从不执行——
# 战斗收束后相位必须回 Idle(RAP 清零),任何「相位卡 Waiting」回归在此被抓
check(action_economy.phase == "Idle",
"战斗收束后行动经济相位回 Idle(notify_combat_end 生效,实测=%s)" % String(action_economy.phase))
return out
func _auto_play_round(settlement, out: Dictionary) -> void:
var minesweeper = settlement.minesweeper
var action_economy = settlement.action_economy
var start_round: int = settlement.round_index
# 确定性自动玩家:挖最近的已探明邻接未开格;无可挖则停手(结算窗口由信号回调提交)
var guard := 400
while action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "":
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 action_economy.phase != "Active" or settlement.round_index != start_round:
break
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "":
minesweeper.settle_batch([{"action": "stop"}])
# ---- 地图战斗资源·伤害量值区间批冒烟(2026-09-14:身份批 roll 域内 + 中点锚 + 变异实际流动 + 定值行不受扰)----
func _smoke_power_interval() -> void:
print("--- 地图战斗资源·伤害量值区间(上阶/下阶随机)---")
# ① 表侧静态锚(配置推导——2026-09-23 重构,改表不再断锚):凡带 power_min/power_max 的行 = 区间行,
# 校验配对齐全(lo ≤ hi)+ power = floor 中点(行动经济 条款①期望当量锚);其余行必须零区间字段。
# 行清单与区间值全部随表走——现行区间行 heal_minor/clear_blast 只是恰然,不再是锚。
var interval_rows := {} # rid → [lo, hi](读表)
var anchors_ok := true
for row in _db.reward.rows:
var rid := String(row.reward_id)
if row.has("power_min") or row.has("power_max"):
var lo: int = int(row.get("power_min", -1))
var hi: int = int(row.get("power_max", -1))
interval_rows[rid] = [lo, hi]
if lo > hi or int(row.power) != (lo + hi) / 2:
anchors_ok = false
check(anchors_ok and interval_rows.size() >= 1,
"区间行配对齐全且 power = floor 中点(配置推导 %d 行:%s)" % [interval_rows.size(), str(interval_rows.keys())])
# ② 运行侧:24 板 roll——区间行快照 power 全域内 + 上下阶实际流动 + 定值行不受扰
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260914)
var reward_economy_factory = load("res://core/reward_economy.gd")
var eligible: Array = []
for i in range(6):
eligible.append({"cell": Vector2i(i, 0)})
var seen := {}
for rid in interval_rows.keys():
seen[rid] = {} # rid → {roll 值集}(区间行随表注册)
var in_domain := true
var fixed_ok := true
for trial in range(24):
var reward_economy = reward_economy_factory.new(_db, rng)
if not reward_economy.roll_board(eligible):
continue
for e in eligible:
var snap: Dictionary = reward_economy.query_full_snapshot(e.cell)
var rid := String(snap.reward_id)
if interval_rows.has(rid):
var v := int(snap.power)
seen[rid][v] = true
if v < interval_rows[rid][0] or v > interval_rows[rid][1]:
in_domain = false
elif int(snap.power) != _row_power(rid):
fixed_ok = false # 定值行快照 power 必须仍是表值
var cs: Dictionary = reward_economy.grant_clear()
var cv := int(cs.power)
var c_rid := String(cs.reward_id)
if not seen.has(c_rid):
seen[c_rid] = {}
seen[c_rid][cv] = true
if interval_rows.has(c_rid) and (cv < interval_rows[c_rid][0] or cv > interval_rows[c_rid][1]):
in_domain = false
check(in_domain, "24 板全区间行快照 power ∈ [power_min, power_max](含清图槽)")
check(fixed_ok, "定值行快照 power 恒等表值——区间批零外溢")
# 定值行(区间 lo==hi 的行,如 heal_minor)roll 恒等表值;区间行(lo<hi,如 clear_blast)须实际流动。
# 全部按 interval_rows 配置推导(2026-09-23 重构)——新增/改区间行自动纳入,不再逐行硬编码。
var variance_ok := true
var fixed_rows_ok := true
for rid in interval_rows.keys():
var lo: int = interval_rows[rid][0]
var hi: int = interval_rows[rid][1]
var samples: int = seen.get(rid, {}).size()
if lo == hi:
if samples > 0 and not (samples == 1 and seen[rid].has(lo)):
fixed_rows_ok = false
elif samples >= 2 and seen[rid].size() < 2:
variance_ok = false
check(variance_ok, "区间行上下阶实际流动(≥2 样本的区间行须 ≥2 个不同 roll 值)")
check(fixed_rows_ok, "定值行(lo=hi)roll 恒等表值")
func _row_power(rid: String) -> int:
for row in _db.reward.rows:
if String(row.reward_id) == rid:
return int(row.power)
return -1
# ---- 行动经济·轮内刷新冒烟(2026-09-12 方案 A 用户拍板:Active 相位当场换图继续挖)----
func _smoke_active_refresh() -> void:
print("--- 行动经济·轮内刷新(方案 A)---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260912)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
# 计数器用容器(GDScript lambda 对局部 int 按值捕获——int 计数在 lambda 内加无效)
var ct := {"board_swaps": 0, "safe_events": 0, "safe_rows": 0, "waiting_refresh_rejected": true, "waiting_probe_hit": false}
settlement.board_changed.connect(func(): ct["board_swaps"] += 1)
settlement.minesweeper.cell_revealed.connect(func(evt):
if evt.kind == "safe" and evt.channel != "initial":
ct["safe_events"] += 1)
settlement.step_row.connect(func(row):
if row.source_class == "safe_cell":
ct["safe_rows"] += 1 # 结算流中 safe_cell 来源行(含兑付/未生效——每格必有行)
# 结算中(Waiting 相位——段内)发起刷新必须被拒(等待期换图通道 2026-09-15 整体移除的回归探针:
# 衰减行恒在每结算出现,借其在段内同步探测一次)
if String(row.get("type", "")) == "decay" and not bool(ct["waiting_probe_hit"]):
ct["waiting_probe_hit"] = true
ct["waiting_refresh_rejected"] = not settlement.player_request_refresh())
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)")
var minesweeper = settlement.minesweeper
# 轮 1–3:零挖掘停手攒 RAP = 3×AP(每轮全转存;表驱动——2026-09-16 AP 压速批 8→6 不再写死)
for i in range(3):
minesweeper.settle_batch([{"action": "stop"}])
var rap_banked: int = 3 * int(_db.action_economy.ap_per_round)
check(settlement.round_index == 4 and settlement.action_economy.rap == rap_banked, "三轮停手攒 RAP=%d(round=%d rap=%d)" % [rap_banked, settlement.round_index, settlement.action_economy.rap])
check(settlement.action_economy.phase == "Active", "第 4 轮 Active 相位")
# 挖 1 格 → 刷 → 挖 1 格 → 刷 → 挖 1 格 → 第 3 刷拒(上限)
var plan = AutoPlayer.choose_dig(minesweeper)
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
var ap_before: int = settlement.action_economy.ap
var board_before = minesweeper.current_board()
var swaps_before: int = ct["board_swaps"]
var ok1: bool = settlement.player_request_refresh()
check(ok1, "轮内第 1 次刷新受理")
check(settlement.action_economy.phase == "Active" and settlement.action_economy.ap == ap_before, "相位保持 Active、AP 保留不重置(ap=%d)" % settlement.action_economy.ap)
check(settlement.action_economy.rap == rap_banked - int(_db.action_economy.rap_refresh_cost), "RAP -刷新价(%d)" % settlement.action_economy.rap)
check(int(ct["board_swaps"]) == swaps_before + 1 and minesweeper.current_board() != board_before, "当场换新图(board_changed +1)")
var plan2 = AutoPlayer.choose_dig(minesweeper)
if plan2.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan2.hero_cell}])
minesweeper.settle_batch([{"action": "dig", "cell": plan2.dig_cell}])
var ok2: bool = settlement.player_request_refresh()
check(ok2, "轮内第 2 次刷新受理(RAP=%d)" % settlement.action_economy.rap)
var plan3 = AutoPlayer.choose_dig(minesweeper)
if plan3.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan3.hero_cell}])
minesweeper.settle_batch([{"action": "dig", "cell": plan3.dig_cell}])
var ok3: bool = settlement.player_request_refresh()
check(not ok3, "第 3 次刷新被拒(每轮兜底上限 2——RAP %d 足额仍拒)" % settlement.action_economy.rap)
# 跨图 facts 完整兑现:拒绝后挖到 AP 耗尽自动结算(扩大跨图样本——三段挖掘分属三张图)
var dig_guard := 20
while settlement.action_economy.phase == "Active" and settlement.round_index == 4 and dig_guard > 0:
dig_guard -= 1
var p = AutoPlayer.choose_dig(minesweeper)
if p.is_empty():
minesweeper.settle_batch([{"action": "stop"}])
break
if p.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": p.hero_cell}])
minesweeper.settle_batch([{"action": "dig", "cell": p.dig_cell}])
check(settlement.round_index == 5 and settlement.battle_result == "", "轮 4 挖至 AP 耗尽自动结算(round=%d result='%s')" % [settlement.round_index, settlement.battle_result])
check(int(ct["safe_rows"]) == int(ct["safe_events"]), "跨图 facts 完整兑现(safe_cell 结算行 %d == safe 事件 %d——快照批次隔离)" % [int(ct["safe_rows"]), int(ct["safe_events"])])
check(int(ct["safe_events"]) >= 3, "跨图挖掘 safe 事件 ≥3(%d——断言非空转)" % int(ct["safe_events"]))
check(bool(ct["waiting_refresh_rejected"]) and bool(ct["waiting_probe_hit"]), "结算段内(Waiting 相位)刷新被拒(等待期换图通道已移除)")
# 回放确定性:同种子两遍步骤流逐行相同(含两次轮内刷新取号序)
var rep1 = _active_refresh_replay()
var rep2 = _active_refresh_replay()
check(JSON.stringify(rep1) == JSON.stringify(rep2), "轮内刷新回放断言:同种子两遍步骤流逐行相同")
check(rep1.size() > 0, "轮内刷新步骤流非空(%d 行)" % rep1.size())
func _active_refresh_replay() -> Array:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260912)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}):
return rows
var minesweeper = settlement.minesweeper
for i in range(3):
minesweeper.settle_batch([{"action": "stop"}])
for attempt in range(3):
var plan = AutoPlayer.choose_dig(minesweeper)
if not plan.is_empty():
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
settlement.player_request_refresh()
minesweeper.settle_batch([{"action": "stop"}])
return rows
# ---- 难度曲线与Boss DifficultyTable 结构位冒烟(AC-10:空表零行为 + 套乘点 = 行值快照前)----
func _smoke_difficulty_table() -> void:
print("--- 难度曲线与Boss·DifficultyTable 结构位 ---")
check(_db.difficulty != null and _db.difficulty.rows.is_empty(), "DifficultyTable MVP 空表加载(结构位)")
# ① 空表零行为:敌人系统.spawn 敌值 = EnemyTable 行值原样
var enemy_system = load("res://core/unit_container.gd").new(_db)
check(enemy_system.spawn(1, 0, int(_db.hero.max_hp)), "spawn 受理")
var unit = enemy_system.all_enemies()[0]
var d0: Dictionary = _db.resolve_enemy_rowset(1, 0)
check(int(unit.hp) == int(d0.hp) and int(unit.atk) == int(d0.atk), "空表零修饰(hp=%d atk=%d = 行值 %d/%d)" % [int(unit.hp), int(unit.atk), int(d0.hp), int(d0.atk)])
# ② 修饰行运行时注入(debug 通道——R3 override 同族):套乘生效于 hp/atk
_db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 1.5, "atk_mult": 2.0})
var enemy_system_b = load("res://core/unit_container.gd").new(_db)
check(enemy_system_b.spawn(1, 0, int(_db.hero.max_hp)), "修饰后 spawn 受理")
var g2 = enemy_system_b.all_enemies()[0]
check(int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp), "hp 修饰 ×1.5(%d→%d,max_hp 同步)" % [int(d0.hp), int(g2.hp)])
check(int(g2.atk) == int(d0.atk) * 2, "atk 修饰 ×2.0(%d→%d)" % [int(d0.atk), int(g2.atk)])
# ③ 越域 clamp:乘数 50 → clamp 10
_db.difficulty.rows.clear()
_db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 50.0, "atk_mult": 1.0})
var enemy_system_c = load("res://core/unit_container.gd").new(_db)
check(enemy_system_c.spawn(1, 0, int(_db.hero.max_hp)), "越域修饰 spawn 受理")
check(int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10, "越域乘数 clamp 至 10(%d→%d)" % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)])
_db.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线)
# ---- 攻击类型 × 护甲克制 + 标错惩罚冒烟(2026-09-12 克制批:护甲快照/克制矩阵/充能/反噬三选一)----
func _smoke_damage_types() -> void:
print("--- 攻击类型 × 护甲克制 + 标错惩罚 ---")
# ① 护甲类型快照传播(spawn 行值 → 单位字典)
var enemy_system = load("res://core/unit_container.gd").new(_db)
check(enemy_system.spawn(1, 0, int(_db.hero.max_hp)) and String(enemy_system.all_enemies()[0].armor_type) == "light", "goblin 护甲 = light(轻甲——教学位)")
var enemy_system_b = load("res://core/unit_container.gd").new(_db)
check(enemy_system_b.spawn(1, 2, int(_db.hero.max_hp)) and String(enemy_system_b.all_enemies()[0].armor_type) == "light", "bugbear 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)")
var enemy_system_c = load("res://core/unit_container.gd").new(_db)
check(enemy_system_c.spawn(1, 4, int(_db.hero.max_hp)) and String(enemy_system_c.all_enemies()[0].armor_type) == "light", "ogre_chief 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)")
# ② 克制矩阵表值在位(2026-09-18 加法核换装:三档点数 克制 +1 / 中性 0 / 被克 −1——percent 量纲退役)
var am: Dictionary = _db.settlement.armor_effectiveness
check(int(am.normal.light) == 0 and int(am.normal.ward) == 1 and int(am.normal.heavy) == -1, "普通行:轻甲 0 / 魔法护甲 +1 / 重甲 −1(加法核)")
check(int(am.pierce.light) == -1 and int(am.pierce.ward) == 1 and int(am.pierce.heavy) == 0, "穿刺行:轻甲 −1 / 魔法护甲 +1 / 重甲 0 中性(用户拍板)")
check(int(am.magic.light) == 0 and int(am.magic.ward) == -1 and int(am.magic.heavy) == 1, "魔法行:轻甲 0 / 魔法护甲 −1 / 重甲 +1(加法核)")
# ③ 标错(类型化负向兑付)+ 标中正向兑付(2026-09-19 充能退役后标中旗只兑格面资源正向;Boss 行多轮样本;板真相直读保证 hit/miss 确定性)
var run1 = _run_flag_backlash_round(20260912)
var miss_rows := 0
var negative_rows := 0
var hit_rows := 0
for row in run1.rows:
if row.get("type", "") == "flag_miss":
miss_rows += 1
if String(row.get("source_class", "")) == "flag_miss" and String(row.get("type", "")) in ["effect", "damage"]:
negative_rows += 1 # 标错的负向兑付行(该安全格资源的负向字典行——effect/直伤)
if String(row.get("source_class", "")) == "flag_hit":
hit_rows += 1 # 标中的正向兑付行(该雷格资源的正向字典行——2026-09-19 充能退役后标中只走格面资源正向)
check(miss_rows >= 1, "标错旗产生 flag_miss 归因行(×%d)" % miss_rows)
check(negative_rows >= 1, "标错旗兑付该安全格资源的负向字典行(×%d——attack/defense/weaken 效果行或 heal 直伤行)" % negative_rows)
check(hit_rows >= 1, "标中旗兑付该雷格资源的正向字典行(×%d——充能退役后无魔法分量,正向=格面资源)" % hit_rows)
# ④ 同种子回放(极性路由零随机——同种子逐行相同)
var run2 = _run_flag_backlash_round(20260912)
check(JSON.stringify(run1.rows) == JSON.stringify(run2.rows), "标错惩罚轮回放断言:同种子步骤流逐行相同(类型化负向兑付确定性)")
# 构造轮:标 1 面"安全格旗"(必 miss → 该格资源负向兑付)+ 标 1 面"雷旗"(必 hit → 正向兑付 + 充能)后停手收轮。
# 返回 flagged safe 格与其 cell_type(Ready 域内查询——类型化断言的数据面)。
func _run_flag_backlash_round(seed_val: 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 rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}):
return {"rows": rows, "safe_cell": Vector2i(-1, -1), "safe_type": ""}
var minesweeper = settlement.minesweeper
var safe_t := _find_flag_target(minesweeper, false)
var mine_t := _find_flag_target(minesweeper, true)
var safe_type := ""
if not safe_t.is_empty():
var ident = settlement.query_reward_identity(safe_t.flag_cell)
safe_type = String(ident.get("cell_type", "")) if ident != null else ""
if safe_t.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": safe_t.hero_cell}])
minesweeper.settle_batch([{"action": "flag_place", "cell": safe_t.flag_cell}])
if not mine_t.is_empty():
if mine_t.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": mine_t.hero_cell}])
minesweeper.settle_batch([{"action": "flag_place", "cell": mine_t.flag_cell}])
minesweeper.settle_batch([{"action": "stop"}])
return {"rows": rows, "safe_cell": safe_t.get("flag_cell", Vector2i(-1, -1)), "safe_type": safe_type}
# 板真相直读找旗目标:未开 + 指定雷/安全属性 + 存在可达已开邻格(测试允许读板真相——排雷玩法 冒烟同口径)
func _find_flag_target(minesweeper, want_mine: bool) -> Dictionary:
var revealed: Array = minesweeper.revealed_cells()
var revealed_set := {}
for c in revealed:
revealed_set[c] = true
for y in minesweeper.current_board().height:
for x in minesweeper.current_board().width:
var c := Vector2i(x, y)
if minesweeper.is_revealed(c) or minesweeper.has_flag(c):
continue
if minesweeper.current_board().is_mine(c) != want_mine:
continue
for n in minesweeper.current_board().neighbors8(c):
if revealed_set.has(n) and (minesweeper.compute_move_path(n).size() > 0 or n == minesweeper.hero_pos()):
return {"flag_cell": c, "hero_cell": n}
return {}
# ---- 插旗不可逆四情况冒烟(2026-09-19 用户拍板全套落地:禁拔旗 + 挖旗格兑付改判)----
# 四情况:① 插雷格不动 = 标中奖励照常;② 插雷格再挖开 = 只受挖雷一层惩罚(mine_hit 照常)标中不发;
# ③ 插安全格不动 = 标空惩罚照常;④ 插安全格再挖开 = 零惩罚零奖励(正向兑付作废、标空不判)。
# 退役面:flag_pull 出目录(out_of_catalog);重复插旗仍 flag_present 拒绝。
# 场景 A(④ + 退役面):标安全格 → flag_pull 拒绝 → 重复插旗拒绝 → 直接挖开 → 收轮。
# 场景 B(②):标雷格 → 直接挖开 → 收轮。
# 场景 C(①③):标安全格 + 标雷格 → 不挖收轮 → 标空/标中判定照常。
func _smoke_flag_irreversible_quadrants() -> void:
print("--- 插旗不可逆四情况(禁拔旗 + 挖旗格兑付改判)---")
# 场景 A:安全格直挖(情况④)+ flag_pull/重复插旗退役面
var rng_a = load("res://core/rng_service.gd").new()
rng_a.set_master_seed(20260919)
var settle_a = load("res://core/battle_orchestrator.gd").new(_db, rng_a)
var rows_a: Array = []
settle_a.step_row.connect(func(row): rows_a.append(row))
check(settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 A:开局受理")
var ms_a = settle_a.minesweeper
var t_a := _find_flag_target(ms_a, false)
check(not t_a.is_empty(), "四情况 A:找到安全格旗目标")
if not t_a.is_empty():
if t_a.hero_cell != ms_a.hero_pos():
ms_a.settle_batch([{"action": "move", "cell": t_a.hero_cell}])
check(ms_a.settle_batch([{"action": "flag_place", "cell": t_a.flag_cell}])[0].accepted, "四情况 A:插旗受理")
var pull_out: Array = ms_a.settle_batch([{"action": "flag_pull", "cell": t_a.flag_cell}])
check(not pull_out.is_empty() and not bool(pull_out[0].get("accepted", true)) and String(pull_out[0].get("sub_reason", "")) == "out_of_catalog",
"四情况 A:flag_pull 出目录拒绝(拔旗退役)")
var dup_place: Array = ms_a.settle_batch([{"action": "flag_place", "cell": t_a.flag_cell}])
check(not dup_place.is_empty() and not bool(dup_place[0].get("accepted", true)) and String(dup_place[0].get("sub_reason", "")) == "flag_present",
"四情况 A:重复插旗仍被拒(flag_present)")
var dig_a: Array = ms_a.settle_batch([{"action": "dig", "cell": t_a.flag_cell}])
check(not dig_a.is_empty() and bool(dig_a[0].get("accepted", false)), "四情况 A:旗格直接挖开被受理(不再须先拔旗)")
check(not ms_a.has_flag(t_a.flag_cell), "四情况 A:旗随挖掘隐式退役(has_flag = false)")
ms_a.settle_batch([{"action": "stop"}])
var target_a: Array = [t_a.flag_cell.x, t_a.flag_cell.y]
var miss_a := 0
var grant_a := 0
var void_a := 0
for row in rows_a:
if row.get("origin_cell", [0, 0]) != target_a:
continue
if row.get("type", "") == "flag_miss" or String(row.get("source_class", "")) == "flag_miss":
miss_a += 1
if String(row.get("source_class", "")) == "safe_cell" and row.get("type", "") != "reward_ineffective":
grant_a += 1
if row.get("type", "") == "reward_ineffective":
void_a += 1
check(miss_a == 0, "四情况 A:标空判定零兑付(×%d——旗已擦除不入轮末基线)" % miss_a)
check(grant_a == 0, "四情况 A:安全格正向兑付作废(×%d——零奖励)" % grant_a)
check(void_a == 1, "四情况 A:兑付作废归因行恰一次(×%d——零惩罚零奖励留痕)" % void_a)
# 场景 B:雷格直挖(情况②)
var rng_b = load("res://core/rng_service.gd").new()
rng_b.set_master_seed(20260919)
var settle_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b)
var rows_b: Array = []
settle_b.step_row.connect(func(row): rows_b.append(row))
check(settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 B:开局受理")
var ms_b = settle_b.minesweeper
var t_b := _find_flag_target(ms_b, true)
check(not t_b.is_empty(), "四情况 B:找到雷格旗目标")
if not t_b.is_empty():
if t_b.hero_cell != ms_b.hero_pos():
ms_b.settle_batch([{"action": "move", "cell": t_b.hero_cell}])
check(ms_b.settle_batch([{"action": "flag_place", "cell": t_b.flag_cell}])[0].accepted, "四情况 B:插旗受理")
var dig_b: Array = ms_b.settle_batch([{"action": "dig", "cell": t_b.flag_cell}])
check(not dig_b.is_empty() and bool(dig_b[0].get("accepted", false)), "四情况 B:旗雷格直接挖开被受理")
check(ms_b.is_revealed(t_b.flag_cell) and ms_b.current_board().is_mine(t_b.flag_cell), "四情况 B:雷格已揭示(挖雷本体照常)")
ms_b.settle_batch([{"action": "stop"}])
var target_b: Array = [t_b.flag_cell.x, t_b.flag_cell.y]
var mine_hit_b := 0
var hit_b := 0
for row in rows_b:
if row.get("origin_cell", [0, 0]) != target_b:
continue
if String(row.get("source_class", "")) == "mine_hit":
mine_hit_b += 1
if row.get("type", "") == "flag_hit" or String(row.get("source_class", "")) == "flag_hit":
hit_b += 1
check(mine_hit_b >= 1, "四情况 B:挖雷惩罚照常兑付(×%d——只受挖雷一层惩罚)" % mine_hit_b)
check(hit_b == 0, "四情况 B:标中不发(×%d——旗已擦除不判)" % hit_b)
# 场景 C:不挖收轮(①③判定照常)
var rng_c = load("res://core/rng_service.gd").new()
rng_c.set_master_seed(20260919)
var settle_c = load("res://core/battle_orchestrator.gd").new(_db, rng_c)
var rows_c: Array = []
settle_c.step_row.connect(func(row): rows_c.append(row))
check(settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 C:开局受理")
var ms_c = settle_c.minesweeper
var t_safe := _find_flag_target(ms_c, false)
var t_mine := _find_flag_target(ms_c, true)
check(not t_safe.is_empty() and not t_mine.is_empty(), "四情况 C:安全格+雷格旗目标齐备")
if not t_safe.is_empty() and not t_mine.is_empty():
for t in [t_safe, t_mine]:
if t.hero_cell != ms_c.hero_pos():
ms_c.settle_batch([{"action": "move", "cell": t.hero_cell}])
ms_c.settle_batch([{"action": "flag_place", "cell": t.flag_cell}])
ms_c.settle_batch([{"action": "stop"}]) # 不挖收轮——两旗均在场基线
var safe_c: Array = [t_safe.flag_cell.x, t_safe.flag_cell.y]
var mine_c: Array = [t_mine.flag_cell.x, t_mine.flag_cell.y]
var miss_c := 0
var miss_neg_c := 0
var hit_c := 0
for row in rows_c:
if row.get("origin_cell", [0, 0]) == safe_c and row.get("type", "") == "flag_miss":
miss_c += 1
if row.get("origin_cell", [0, 0]) == safe_c and String(row.get("source_class", "")) == "flag_miss" and row.get("type", "") != "flag_miss":
miss_neg_c += 1
if row.get("origin_cell", [0, 0]) == mine_c and (row.get("type", "") == "flag_hit" or String(row.get("source_class", "")) == "flag_hit"):
hit_c += 1
check(miss_c >= 1 and miss_neg_c >= 1, "四情况 C:标空惩罚照常(归因×%d 负向兑付×%d)" % [miss_c, miss_neg_c])
check(hit_c >= 1, "四情况 C:标中奖励照常(×%d)" % hit_c)
# ---- 试玩日志器冒烟(结算快照 JSONL + 诊断转储)----
func _smoke_playtest_logger() -> void:
print("--- PlaytestLogger(结算快照 JSONL + 诊断转储)---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260912)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var logger = load("res://presentation/playtest_logger.gd").new()
var path: String = logger.start_session(20260912)
logger.attach_battle(settlement)
var snaps: Array = []
settlement.settlement_snapshot.connect(func(snap): snaps.append(snap))
check(path.contains("playtest_20260912"), "会话文件开启(%s)" % path)
check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}), "快照战斗启动")
var minesweeper = settlement.minesweeper
for i in range(2):
minesweeper.settle_batch([{"action": "stop"}])
check(snaps.size() == 2, "每结算一快照(%d 次结算 → %d 快照)" % [2, snaps.size()])
var s1: Dictionary = snaps[0]
var required := ["kind", "level", "depth", "round", "verdict", "hero", "economy", "board", "enemies", "buffs", "round_facts", "spawn_count"]
var missing := []
for k in required:
if not s1.has(k):
missing.append(k)
check(missing.is_empty(), "快照字段齐备(缺 %s)" % str(missing))
check(int(s1.hero.max_hp) == int(_db.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq"), "快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq))
# spawn_count 回归(playtest-2026-09-12 报告 Bug#1):停手两轮零击杀(主角攻击 6×2 < 24 HP 确定)→
# unit_count 必须 == spawn_count(全灭才允许 <);快照级区分「首轮全灭」vs「空场」的数据面在位
check(int(s1.get("spawn_count", -1)) >= 1 and int(s1.get("spawn_count", -1)) == int(s1.get("unit_count", -2)),
"快照 spawn_count 在位且 == unit_count(无击杀轮:%d/%d——全灭/空场可判别)" % [int(s1.get("spawn_count", -1)), int(s1.get("unit_count", -1))])
logger.log_action("dig", Vector2i(1, 1), true)
var diag: String = logger.dump_diagnosis({"test": true})
check(diag.contains("diag_"), "诊断转储文件生成(%s)" % diag)
# JSONL 文件内容验证(重新读取)
var f := FileAccess.open(path, FileAccess.READ)
var lines := 0
var parse_ok := true
while not f.eof_reached():
var line := f.get_line()
if line.strip_edges() == "":
continue
lines += 1
var parsed = JSON.parse_string(line)
if parsed == null:
parse_ok = false
check(lines >= 3 and parse_ok, "JSONL 全部行可解析(%d 行 = session + 2 结算)" % lines)
logger.close_session()
# ---- 遭遇随机化冒烟(难度曲线与Boss Core Rule 8:区间骰/encounter 流/可回放/空表定值路径)----
func _smoke_encounter_roll() -> void:
print("--- 遭遇随机化(unit_count 区间骰)---")
var counts: Array = []
for i in range(20):
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(30000 + i)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}), "遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)", true)
counts.append(settlement.presentation_snapshot().enemies.size())
# 区间生效:20 个种子应出现 1 和 2(D0 [1,2])
check(counts.has(1) and counts.has(2), "D0 区间 [1,2] 两值均出现(分布 %s)" % str(counts))
# 域内断言带标签(原恒真 check(true) 已废——断言必须能抓住失效,code-review 2026-09-12)
var all_in_domain := true
for c in counts:
if c < 1 or c > 2:
all_in_domain = false
check(all_in_domain, "全部计数在域内 [1,2](分布 %s)" % str(counts))
# 同种子可回放:两遍同数量(encounter 流确定性)
var r1 := _encounter_replay(30000)
var r2 := _encounter_replay(30000)
check(r1 == r2, "同种子遭遇骰可回放(%s == %s)" % [str(r1), str(r2)])
# D1 [2,3]:seed 30005 路径(任意种子域内即可)
var rng3 = load("res://core/rng_service.gd").new()
rng3.set_master_seed(31000)
var settlement_d1 = load("res://core/battle_orchestrator.gd").new(_db, rng3)
check(settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.hero.max_hp)}), "D1 战斗启动")
var n1: int = settlement_d1.presentation_snapshot().enemies.size()
check(n1 >= 2 and n1 <= 3, "D1 区间 [2,3] 域内(%d 只)" % n1)
# D2 固定(教学位不加骰)
var rng4 = load("res://core/rng_service.gd").new()
rng4.set_master_seed(31000)
var settlement_d2 = load("res://core/battle_orchestrator.gd").new(_db, rng4)
settlement_d2.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)})
check(settlement_d2.presentation_snapshot().enemies.size() == 1, "D2 熊地精固定 1(首遇教学位)")
# 全深度 × 多种子域内扫描(playtest-2026-09-12 报告 Bug#1 回归——区间下限 1 不可掷 0:
# 战斗结算 roll_lo 下限 + 敌人系统 spawn count<1 fail-fast 双闸;单深度单种子是盲区,全域扫描才钉死不变式)
var depths: Array = []
for node in _level_battle_nodes(1):
depths.append(int(node.get("node_depth", -1)))
var scan_bad := 0
var scan_total := 0
for i in range(12):
var rngs = load("res://core/rng_service.gd").new()
rngs.set_master_seed(32000 + i)
for depth_v in depths:
scan_total += 1
var settlement_scan = load("res://core/battle_orchestrator.gd").new(_db, rngs)
if not settlement_scan.begin_battle({"level_index": 1, "node_depth": int(depth_v), "hero_hp": int(_db.hero.max_hp)}):
scan_bad += 1
continue
var n: int = settlement_scan.presentation_snapshot().enemies.size()
var erow2: Dictionary = _db.resolve_enemy_rowset(1, int(depth_v))
var lo2: int = int(erow2.get("unit_count", 1))
var hi2: int = int(erow2.get("unit_count_max", lo2))
if n < 1 or n < lo2 or n > hi2:
scan_bad += 1
check(scan_bad == 0, "全深度 × 12 种子 spawn 全域内(%d 场,∈[unit_count, unit_count_max] 且 ≥1)——空遭遇不可达(Bug#1 回归)" % scan_total)
# 模板感知取节点表(M-3 迭代 5 T5——row.templates 非空取模板 0=现行固定图,保持既有断言语义)
func _level_active_nodes(level_index: int) -> Array:
for row in _db.level.rows:
if int(row.level_index) == level_index:
var tpls: Array = row.get("templates", [])
if not tpls.is_empty():
return tpls[0].get("nodes", [])
return row.get("nodes", [])
return []
func _level_battle_nodes(level_index: int) -> Array:
var out: Array = []
for row in _db.level.rows:
if int(row.level_index) == level_index:
for node in _level_active_nodes(level_index):
var t := String(node.get("type", ""))
if t == "battle" or t == "boss":
out.append(node)
return out
# ---- 战斗段 fresh 池攻击链回归(playtest-2026-09-12 报告 Bug#2 沉淀——
# 「Boss 攻击拍零兑现」实为旧 defense_up 数据 ×2 叠满 = 池触底 ×0(设计语义);本断言钉死:
# 池为空时攻击链必须按掷点档位足额兑现——2026-09-16 护甲等级掷点批改口径:
# d_tick = max(damage_floor, pool_damage(floor(基线×档位%), 0, 0)),档位读数随行重建可归因)----
func _smoke_attack_chain_fresh_pool() -> void:
print("--- 战斗结算·fresh 池攻击链回归(Bug#2·掷点口径)---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260913)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var ticks: Array = [] # {baseline, attach, roll, tier, crit, tick}——敌行动伤害行重建
settlement.step_row.connect(func(row):
if row.type == "damage" and row.source_class == "enemy_action":
ticks.append({"baseline": int(row.baseline), "attach": int(row.get("attach", 0)), "roll": int(row.roll),
"tier": int(row.tier), "crit": bool(row.get("crit", false)), "tick": int(row.value)}))
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "fresh 池回归战斗启动(Boss 行)")
# 停手轮:零挖掘零旗 → 零授予 → 池恒空;Boss 拍 = 开轮摇号结果(伤害技/普攻兑付伤害行、硬化施放行不计)
for i in range(6):
if settlement.battle_result != "":
break
settlement.minesweeper.settle_batch([{"action": "stop"}])
check(ticks.size() >= 2, "非施放攻击拍采样充足(%d 拍——增益施放行零兑付为设计语义不计)" % ticks.size())
var all_full := true
for t in ticks:
var base_factor: float = float(t.tier) * (float(_db.settlement.crit_multiplier) / 100.0 if t.crit else 1.0)
var rolled_base := int(floor(float(t.baseline) * base_factor / 100.0))
var expected: int = maxi(int(_db.settlement.damage_floor), rolled_base + int(t.get("attach", 0)))
if int(t.tick) != expected or int(t.tick) <= 0:
all_full = false
check(all_full, "fresh 池非蓄力拍 d_tick = max(1, 掷点档位基线)(Boss 攻击链健康——Bug#2 回归断言)")
func _encounter_replay(seed_val: int) -> Array:
var out: Array = []
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)
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
out.append(settlement.presentation_snapshot().enemies.size())
return out
# ==================== 清图奖励双份兑付链 / 轮内刷新 provenance 守卫 / 窗口锚行(2026-09-15 补覆盖批) ====================
# 清图 = 全安全格开完。清图测试用"真雷旗(零误标→完美排雷)+ AutoPlayer 逐轮清图"真实流驱动;
# DifficultyTable 注入 hp_mult=10 把 Boss 血量抬到 1500,保证清图批次发生在战斗结束之前(测试台账,跑完即还原)。
const CLEAR_SMOKE_DEPTH := 4
const CLEAR_SMOKE_HERO_HP := 100000
var _regular_clear_reward_rows := 0 # 常规(非刷新图)清图的 clear_reward 行数——刷新守卫对照基线
func _smoke_clear_reward_chain() -> void:
print("--- 清图奖励·双份兑付链(完美排雷 / 零兑付 / 第二份被拒 / 高光记实)---")
# ① 真实流完美排雷:标中一面真雷旗 + 零误标 + 清全图 → grant_clear 身份(clear_blast)双份真实兑付
var rows: Array = []
var run := _run_full_clear(20260915, false, rows)
check(bool(run.cleared), "① 完美排雷清图达成(%d 轮)" % int(run.rounds))
var dmg := []
for r in rows:
if String(r.get("type", "")) == "damage" and String(r.get("source_class", "")) == "clear_reward":
dmg.append(r)
var hl := _rows_of_type(rows, "highlight")
_regular_clear_reward_rows = dmg.size()
check(dmg.size() == 2, "① 清图奖励双份真实兑付(clear_reward 伤害行 ×%d——clear_blast 身份两份各打一发)" % dmg.size())
if dmg.size() == 2:
check(String(dmg[0].display_name).contains("清图奖励") and String(dmg[1].display_name).contains("完美第二份"),
"① 第二份按身份类型走真实兑付(首份『清图奖励』/ 第二份『完美第二份』)")
check(hl.size() == 1 and int(hl[0].value) == 2, "④ 高光行 value = 实际有效份数 2(不记名义值;行数 %d)" % hl.size())
if hl.size() == 1:
check(String(hl[0].get("highlight", "")) == "perfect_sweep", "高光标记 = perfect_sweep")
# ②/③ 弱形式:正常战斗流不可达的零兑付分支(见函数头注)
_smoke_clear_reward_zero_paths()
# 弱形式登记:正常战斗流下"授段时刻无存活敌"不可达——敌全灭当轮判定段即判胜并终止战斗,而清图批在判定段之前,
# 恒有存活敌;故本组直调编排私有面构造状态,只断言"路径存在 + 行为符合契约"(构造手法见各行注释)。
func _smoke_clear_reward_zero_paths() -> void:
# ②-a 首份零兑付判定(无存活敌)→ first_ok=false + 未生效行
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260915)
var s = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
s.step_row.connect(func(row): rows.append(row))
check(s.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
"零兑付构造战斗启动")
for u in s._enemy_system.all_enemies():
s._enemy_system.apply_damage(u, int(u.hp)) # 测试通道:构造"无存活敌"(弱形式——全灭而非 despawn)
var snap = s._reward_economy.grant_clear()
check(snap != null and String(snap.cell_type) == "blast",
"清图槽身份 = blast 型(clear_default 唯一活行 clear_blast——第二份同身份兑付)")
rows.clear()
var first_ok: bool = s._grant_clear_identity(snap, 1, "(清图奖励)")
check(not first_ok, "② 即时类首份零兑付(无存活敌)→ first_ok=false")
check(_has_source_row(rows, "reward_ineffective", "clear_reward", "clear_blast"),
"② 零兑付产出 reward_ineffective 行(batch_key=clear_blast)")
# ②-b 高光守卫:perfect 谓词成立但首份零兑付 → 高光行不发(不记名义值)
var rng_b = load("res://core/rng_service.gd").new()
rng_b.set_master_seed(20260915)
var s_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b)
var rows_b: Array = []
s_b.step_row.connect(func(row): rows_b.append(row))
check(s_b.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
"高光守卫构造战斗启动")
for u in s_b._enemy_system.all_enemies():
s_b._enemy_system.apply_damage(u, int(u.hp))
s_b._perfect_hit_true = 1 # 测试通道:构造完美谓词成立(真值在正常流由旗判定累加)
s_b._perfect_hit_false = 0
rows_b.clear()
s_b._grant_clear_reward(1)
check(_has_source_row(rows_b, "reward_ineffective", "clear_reward", "clear_blast"),
"② _grant_clear_reward 守卫路径重走首份 → 未生效行仍在案")
check(_rows_of_type(rows_b, "highlight").is_empty(), "② 首份零兑付 → 高光行不发(不记名义值 2)")
# ③ 第二份被拒:唯一存活敌 HP ≤ 首份 aoe 量 → 首份击杀 → 第二份无存活敌(zero + 未生效行)
var rng2 = load("res://core/rng_service.gd").new()
rng2.set_master_seed(20260915)
var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2)
var rows2: Array = []
s2.step_row.connect(func(row): rows2.append(row))
check(s2.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
"第二份被拒构造战斗启动")
var boss = s2._enemy_system.all_enemies()[0]
boss.hp = 1 # 测试通道:压低至首份 aoe 必杀(弱形式——正常流 Boss 血量不可控)
s2._perfect_hit_true = 1
s2._perfect_hit_false = 0
rows2.clear()
s2._grant_clear_reward(1)
check(_has_source_row(rows2, "damage", "clear_reward"), "③ 首份 aoe 真实兑付(击杀唯一存活敌)")
check(_has_source_row(rows2, "reward_ineffective", "clear_reward", "clear_blast"),
"③ 第二份被拒产出 reward_ineffective 行(零兑付)")
var hl2 := _rows_of_type(rows2, "highlight")
check(hl2.size() == 1 and int(hl2[0].value) == 1, "④ 高光行 value = 实际有效份数 1(不记名义值 2)")
# 轮内刷新 provenance 守卫:刷新换来的图同轮清图不发清图奖励(_board_from_midround_refresh 守卫)
func _smoke_refresh_provenance_guard() -> void:
print("--- 轮内刷新 provenance 守卫(刷新图清图不发清图奖励)---")
var rows: Array = []
var run := _run_full_clear(20260916, true, rows)
check(bool(run.refreshed), "轮内刷新受理(三轮停手攒 RAP 后 Active 相位发起)")
check(bool(run.cleared), "刷新图清图达成(%d 轮)" % int(run.rounds))
var cr := 0
var hl := 0
for r in rows:
if String(r.get("source_class", "")) == "clear_reward":
cr += 1
if String(r.get("type", "")) == "highlight":
hl += 1
check(cr == 0, "刷新图清图 → 无 clear_reward 行(守卫在位——实测 %d 行)" % cr)
check(hl == 0, "刷新图清图 → 无高光行(守卫在位——实测 %d 行)" % hl)
check(_regular_clear_reward_rows > 0, "对照基线:常规换图清图有 clear_reward 行(×%d——守卫非全局静默)" % _regular_clear_reward_rows)
# ==================== 格面资源批新增覆盖(极性路由 / 负向字典 / 资源面 / 五段全序) ====================
# 极性路由断言(战斗结算 验收):挖安全格/标中旗只出正向、挖雷格/标错旗只出负向、兑付行指回来源格。
# 正向字典效果集 = {attack_up, ac_up, weaken}(ac_up = 2026-09-16 护甲等级掷点批换装,原 defense_up)
# + spark_aoe/heal_minor(flat)+ gp_gain(九型批 2026-09-20——金币袋,escrow 缓冲路由键非增益容器行);
# 负向字典效果集 = {enrage, off_guard, sapped}(percent)+ 直伤(heal flat)/ alert 警觉(blast percent——2026-09-20 换装)
# + gp_loss(九型批——破财,escrow 缓冲路由键)。
const POSITIVE_EFFECTS := ["attack_up", "ac_up", "weaken", "gp_gain"]
const NEGATIVE_EFFECTS := ["enrage", "off_guard", "sapped", "alert", "gp_loss"]
# 构造验收谓词(hunt 用——并流批后种子按行为面扫描:四触达 + 标空旗负向兑付行在案)
func _polarity_probe_accepts(probe: Dictionary) -> bool:
if not (bool(probe["flag_hit_done"]) and bool(probe["flag_miss_done"])):
return false
if int(probe["mine_events"]) < 1 or int(probe["safe_events"]) < 1:
return false
var miss_rows := _rows_at(probe["rows"], "flag_miss", probe["miss_cell"])
if miss_rows.is_empty():
return false
for r in miss_rows:
if String(r.get("type", "")) == "flag_miss":
continue
var eff := String(r.get("effect_id", ""))
if eff != "" and not NEGATIVE_EFFECTS.has(eff):
return false
return true
func _smoke_polarity_routing() -> void:
print("--- 极性路由(挖安全格正向 / 挖雷格负向 / 标中正向 / 标空负向)---")
# 并流批(ADR-0007 裁丙)后战斗随机值面漂移——构造种子改行为面扫描(谓词见 _polarity_probe_accepts)
var probe := {}
var polarity_seed := -1
for s in [20260918, 20260919, 20260920, 20260921, 42, 4242, 7, 100, 20260922, 123, 2024, 31337, 555, 1111, 2222, 20260923, 999, 777]:
probe = _run_polarity_probe(s)
if _polarity_probe_accepts(probe):
polarity_seed = s
break
check(polarity_seed > 0, "极性路由构造种子可达(seed=%d——并流后行为面重锚)" % polarity_seed)
check(bool(probe["flag_hit_done"]) and bool(probe["flag_miss_done"]), "构造触达:标中真雷 + 标空安全格均入队")
check(int(probe["mine_events"]) >= 1, "构造触达:挖到雷格 ≥1(%d)" % int(probe["mine_events"]))
check(int(probe["safe_events"]) >= 1, "构造触达:挖到安全格 ≥1(%d)" % int(probe["safe_events"]))
# 挖到的安全格:只出正向——其全部来源行必存在且 effect 行 ∈ 正向集
# (action 型排队行 effect_id=ap_delta 正负两极共用路由键——按行值符号辨极性:正向格恒 +power)
var safe_all_pos := true
for e in probe["safe_cells"].keys():
var cell_arr: Array = [e.x, e.y]
var rows := _rows_at(probe["rows"], "safe_cell", cell_arr)
if rows.is_empty():
safe_all_pos = false # 每个资源格必有 ≥1 兑付行(每格必有货 → 每格必有行)
for r in rows:
var eff := String(r.get("effect_id", ""))
if eff == "ap_delta":
if int(r.get("value", 0)) <= 0:
safe_all_pos = false
continue
if eff != "" and not POSITIVE_EFFECTS.has(eff):
safe_all_pos = false
check(safe_all_pos, "挖安全格 → 只出正向兑付(效果行 ∈ 正向字典 + 每格 ≥1 行——%d 格)" % probe["safe_cells"].size())
# 挖到的雷格:只出负向——来源行必存在且 effect 行 ∈ 负向集(alert = blast 负向;heal 负向 = damage 行;
# action 型排队行同按行值符号辨极性:雷格恒 −power)
var mine_all_neg := true
for e in probe["mine_cells"].keys():
var cell_arr: Array = [e.x, e.y]
var rows := _rows_at(probe["rows"], "mine_hit", cell_arr)
if rows.is_empty():
mine_all_neg = false
for r in rows:
var eff := String(r.get("effect_id", ""))
if eff == "ap_delta":
if int(r.get("value", 0)) >= 0:
mine_all_neg = false
continue
if eff != "" and not NEGATIVE_EFFECTS.has(eff):
mine_all_neg = false
check(mine_all_neg, "挖雷格 → 只出负向兑付(效果行 ∈ 负向字典 + 每格 ≥1 行——%d 格)" % probe["mine_cells"].size())
# 标中旗:该雷格正向兑付行(flag_hit 来源)+ 效果 ∈ 正向字典
var hit_ok := false
for r in probe["rows"]:
if String(r.get("source_class", "")) == "flag_hit" and r.get("origin_cell", [0, 0]) == probe["hit_cell"]:
hit_ok = true
var eff := String(r.get("effect_id", ""))
if eff != "" and not POSITIVE_EFFECTS.has(eff):
hit_ok = false
check(hit_ok, "标中旗 → 该雷格资源的正向兑付行(origin 指回雷格,效果 ∈ 正向字典)")
# 条款3 条件二旗溢价断言(迭代14② T4——标中兑付行数值 = 同型正向 ×(1+δ)):
# δ 读 settlement.flag_hit_premium(125 provisional);九型正向行值全固定(唯一区间行 clear_blast
# 系清图槽不入格池)⇒ 逐型期望全确定。整数量级四舍五入域注记:ac_up/action 1 点量纲取整后不变
# =整数粒度地板——等号压线判读在当量聚合面(audit 工具/registry clause3_inequality),非断言面。
var prem_pct: int = int(_db.settlement.flag_hit_premium)
check(prem_pct == 125, "旗溢价配置在位(flag_hit_premium=125 provisional——形态 a 兑付溢价)")
var prem_rows := 0
var prem_ok := true
for r in probe["rows"]:
if String(r.get("source_class", "")) != "flag_hit" or String(r.get("highlight", "")) != "flag_hit":
continue
prem_rows += 1
var rtype := String(r.get("type", ""))
var v := int(r.get("value", 0))
var eff := String(r.get("effect_id", ""))
if rtype == "effect":
if eff == "gp_gain":
prem_ok = prem_ok and v == int(round(3 * prem_pct / 100.0)) # 金币袋 3→4
elif eff == "ap_delta":
prem_ok = prem_ok and v == 1 # +1AP:1 点量纲(round(1.25)=1——整数粒度地板)
else:
var mag: int = int(_db.status_effect_row(eff).get("magnitude", -999))
prem_ok = prem_ok and v == int(round(mag * prem_pct / 100.0)) # 增伤50→63/削弱65→81/护甲1→1
elif rtype == "heal":
# 急救 2→3(clamp 封顶只降不升:满血走 reward_ineffective 不入本行——区间断言)
prem_ok = prem_ok and v >= 1 and v <= int(round(2 * prem_pct / 100.0))
elif rtype == "damage":
var at := String(r.get("attack_type", ""))
if at == "":
prem_ok = prem_ok and v == int(round(1 * prem_pct / 100.0)) # 爆裂粉尘 1→1(群伤行值=表值直付)
else:
# 穿刺/魔法 2→3(行值=落地伤=基准+克制点数——反推基准核对;damage_floor 兜底下成立)
prem_ok = prem_ok and (v - int(r.get("effectiveness", 0))) == int(round(2 * prem_pct / 100.0))
check(prem_rows >= 1 and prem_ok, "标中兑付溢价(×%d 行逐型=同型正向 ×%d/100——等号压线注记:δ=1.25 时条件二 E[标中]=1.25·E_dig 恰等成立)" % [prem_rows, prem_pct])
# 标空不乘溢价:负向直伤行(反噬6/陷阱8/魔法反噬8 固定表值)零溢价污染——溢价值挂标中正向单点
var miss_flat_ok := true
for r in _rows_at(probe["rows"], "flag_miss", probe["miss_cell"]):
if String(r.get("type", "")) == "damage":
miss_flat_ok = miss_flat_ok and int(r.get("value", 0)) in [6, 8]
check(miss_flat_ok, "标空惩罚零溢价(负向直伤行=固定表值原值——溢价只挂标中正向)")
# 标空旗:归因行 + 该安全格负向兑付行(flag_miss 来源)
var miss_rows := _rows_at(probe["rows"], "flag_miss", probe["miss_cell"])
var miss_neg_ok := false
for r in miss_rows:
if String(r.get("type", "")) == "flag_miss":
miss_neg_ok = true
else:
var eff := String(r.get("effect_id", ""))
if eff != "" and not NEGATIVE_EFFECTS.has(eff):
miss_neg_ok = false
check(not miss_rows.is_empty() and miss_neg_ok, "标空旗 → 标旗未中归因行 + 该安全格资源负向兑付(同格同批)")
# 抵消过滤既有语义:标旗后被撤旗不判(发射面基线过滤)——由排雷玩法 冒烟与旗判定基线覆盖,此处核兑付行零泄漏
var flag_srcs := {}
for r in probe["rows"]:
if String(r.get("source_class", "")) in ["flag_hit", "flag_miss"]:
flag_srcs[r.get("origin_cell")] = true
check(int(flag_srcs.size()) <= 2, "旗判定兑付仅旗判定批两旗在案(撤旗抵消过滤零泄漏——%d 格)" % flag_srcs.size())
# 极性路由探针驱动:标 1 面真雷旗(标中)+ 1 面安全格旗(标空)→ AutoPlayer 挖到战斗终局(多轮样本)。
# 收集全部 cell_revealed / flag_judged 事件与结算行;返回格子集合与旗格坐标。
func _run_polarity_probe(seed_val: int) -> Dictionary:
_db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 10.0, "atk_mult": 0.5}) # 抬敌血压低敌攻——多轮挖掘样本不被终局截断
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 probe := {"rows": [], "safe_cells": {}, "mine_cells": {}, "hit_cell": [], "miss_cell": [],
"mine_events": 0, "safe_events": 0, "flag_hit_done": false, "flag_miss_done": false}
settlement.step_row.connect(func(row): probe["rows"].append(row))
settlement.minesweeper.cell_revealed.connect(func(evt):
if String(evt.get("channel", "")) == "initial":
return
if evt.kind == "mine":
probe["mine_cells"][evt.cell] = true
probe["mine_events"] = int(probe["mine_events"]) + 1
elif evt.kind == "safe":
probe["safe_cells"][evt.cell] = true
probe["safe_events"] = int(probe["safe_events"]) + 1)
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 300, "hero_max_hp": 300})
var ms = settlement.minesweeper
# 旗 1:真雷(标中 → 正向);旗 2:安全格(标空 → 负向)
var mine_t := _find_flag_target(ms, true)
var safe_t := _find_flag_target(ms, false)
if not mine_t.is_empty():
if mine_t.hero_cell != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": mine_t.hero_cell}])
ms.settle_batch([{"action": "flag_place", "cell": mine_t.flag_cell}])
probe["hit_cell"] = [mine_t.flag_cell.x, mine_t.flag_cell.y]
probe["flag_hit_done"] = true
if not safe_t.is_empty():
if safe_t.hero_cell != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": safe_t.hero_cell}])
ms.settle_batch([{"action": "flag_place", "cell": safe_t.flag_cell}])
probe["miss_cell"] = [safe_t.flag_cell.x, safe_t.flag_cell.y]
probe["flag_miss_done"] = true
# 挖到终局(旗挡链不影响 AutoPlayer——choose_dig 跳过旗格)
var out := {"result": "", "rounds": 0, "terminated": false, "stream": []}
var guard := 40
while settlement.battle_result == "" and guard > 0:
guard -= 1
_auto_play_round(settlement, out)
_db.difficulty.rows.clear() # 测试台账还原
return probe
func _rows_at(rows: Array, source_class: String, cell) -> Array:
var out: Array = []
for r in rows:
if String(r.get("source_class", "")) == source_class and r.get("origin_cell", [9, 9]) == cell:
out.append(r)
return out
# 负向字典逐型直验(类型化兑付行协议):找真实板上各型非封锁格,直调 _grant_routed 负向路由,
# 断言每型的兑付形态(enrage/off_guard/sapped 效果行、heal 直伤行、blast 警觉行、action 行动点排队)。
func _smoke_negative_dictionary() -> void:
print("--- 负向字典逐型直验(sapped 在位 / heal 直伤 / blast 警觉 / action 行动点)---")
# 数据面:sapped 行在位(状态表七行 + effect_pool_map 收编)
var sapped: Dictionary = _db.status_effect_row("sapped")
check(not sapped.is_empty() and String(sapped.get("target_side", "")) == "hero" \
and int(sapped.get("magnitude", 0)) == -25 and int(sapped.get("duration_settlements", 0)) == 1 \
and int(sapped.get("max_stack", 0)) == 16 and String(sapped.get("stack_policy", "")) == "stack",
"sapped 行在位(hero · −25 percent · dur 1 · max 16 · stack——weaken 型负向)")
check(String(_db.settlement.effect_pool_map.get("sapped", "")) == "hero_offense",
"effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)")
# 行为面:逐型负向兑付形态(真实板 + 真实路由路径)
# 数值压缩批(2026-09-17):heal 权重 15→3 后单板五型齐备不再必然(无 heal 板概率 ≈1/3)——
# 改多板扫描,取第一块五型齐备的板做行为断言。
var settlement = null
var board = null
var by_type := {} # cell_type → 首个非封锁格(query_identity 雷安同池——雷格可查)
var rows: Array = []
for seed_i in range(6):
# 每板独立 rng 实例(迭代 7 T1 修正——复用实例重播种触发 set_master_seed 的 _counter==0 守卫;
# 原版第一板即 break 掩盖了该违例,aux 供给挤占后多板扫描暴露)
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260919 + seed_i)
settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
rows = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_max_hp": int(_db.hero.max_hp)}):
continue
board = settlement.minesweeper.current_board()
var r0 := {}
for c in board.compute_r0():
r0[c] = true
by_type = {}
for y in board.height:
for x in board.width:
var c := Vector2i(x, y)
if r0.has(c) or by_type.size() == 5:
continue
var ident = settlement.query_reward_identity(c)
# aux 身份不入五型轴(秘宝内容批 2026-09-29——五型齐备=格面资源历史断言轴,
# aux 供给挤占不改变本断言语义;扫描排除 aux 身份格)
if ident != null and String(ident.cell_type) != RewardTable.TREASURE_ITEM_TYPE and not by_type.has(String(ident.cell_type)):
by_type[String(ident.cell_type)] = c
# break 条件=percent_map 三型齐备(迭代 7 T1——aux 供给挤占下 weaken/defense 收集时机漂移,
# 直验可达性优先;五型多样性 check 保留不动)
if by_type.has("attack") and by_type.has("defense") and by_type.has("weaken"):
break
check(by_type.size() == 5, "扫描 6 板内出现五型齐备板(%s)" % str(by_type.keys()))
# attack/defense/weaken:负向字典行走 apply_effect(off_guard 2026-09-18 换装 ac 轴——兑付形态不变)
var percent_map := {"attack": "enrage", "defense": "off_guard", "weaken": "sapped"}
for ctype in percent_map.keys():
if not by_type.has(ctype):
continue
rows.clear()
var snap: Dictionary = settlement._reward_economy.query_full_snapshot(by_type[ctype])
settlement._grant_routed(snap, "negative", "mine_hit", by_type[ctype], 1)
var eff: String = percent_map[ctype]
check(_has_source_row(rows, "effect", "mine_hit") and _has_effect_id(rows, eff),
"%s 型负向 → %s 效果行(mine_hit 来源指回该格)" % [ctype, eff])
# heal:负向 = 主角直伤(表值——数值压缩批 4→1;不经增益状态)
if by_type.has("heal"):
rows.clear()
var hp_before: int = int(settlement.presentation_snapshot().hero.hp)
var snap_h: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["heal"])
settlement._grant_routed(snap_h, "negative", "mine_hit", by_type["heal"], 1)
var hp_after: int = int(settlement.presentation_snapshot().hero.hp)
var backlash_power: int = 0
for row in _db.reward.rows:
if String(row.get("reward_id", "")) == "heal_backlash":
backlash_power = int(row.power)
check(hp_after == hp_before - backlash_power, "heal 型负向 → 主角直伤 %d(%d → %d,不经增益状态)" % [backlash_power, hp_before, hp_after])
# blast:负向 = 警觉(敌方护甲等级 +1 percent 行——2026-09-20 伏击退役换装)
if by_type.has("blast"):
rows.clear()
var snap_b: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["blast"])
settlement._grant_routed(snap_b, "negative", "mine_hit", by_type["blast"], 1)
var alert_ok: bool = false
for eff_a in settlement._status_effects.query_active("enemy"):
if String(eff_a.effect_id) == "alert":
alert_ok = true
check(alert_ok, "blast 型负向 → 警觉(敌方护甲等级 +1 状态在位)")
# action:负向 = 下轮行动点 −1 / 正向 = +1(排队,轮开始兑现——八型批 2026-09-20;确定性直验不依赖板面摇号)
rows.clear()
var d0: int = settlement.action_economy.pending_ap_delta()
settlement._grant_routed({"reward_id": "ap_drain", "cell_type": "action", "polarity": "negative", "display_name": "迟滞", "power": 1}, "negative", "mine_hit", Vector2i(0, 0), 1)
check(settlement.action_economy.pending_ap_delta() == d0 - 1, "action 型负向 → 下轮行动点 −1(排队)")
settlement._grant_routed({"reward_id": "ap_up", "cell_type": "action", "polarity": "positive", "display_name": "行动点", "power": 1}, "positive", "safe_open", Vector2i(0, 0), 1)
check(settlement.action_economy.pending_ap_delta() == d0, "action 型正负抵消 → 排队净值归零")
# 资源面断言:chain/dig 事件恒携带 resource=true(雷格同权);initial 恒 false(封锁格);
# 封锁格查询 = null、雷格未开恒可查。
func _smoke_resource_surface() -> void:
print("--- 格面资源资格面(雷格携带 / 封锁格无资源)---")
var run1 = _run_battle(1, 0)
var run2 = _run_battle(2, 0)
var all_events: Array = run1.events + run2.events
var digchain_bad := 0
var initial_bad := 0
var mine_with_resource := 0
var initial_count := 0
for evt in all_events:
if not evt.has("resource"):
continue # 非揭示事件(round_end 标记等)
if String(evt.get("channel", "")) == "initial":
initial_count += 1
if bool(evt.resource):
initial_bad += 1
else:
if not bool(evt.resource):
digchain_bad += 1
if evt.kind == "mine":
mine_with_resource += 1
check(digchain_bad == 0, "chain/dig 揭示事件 resource 恒 true(雷安同池——违例 %d)" % digchain_bad)
check(initial_bad == 0 and initial_count >= 1, "initial 揭示事件(封锁格)resource 恒 false(×%d 事件)" % initial_count)
check(mine_with_resource >= 1, "雷格揭示携带资源(mine ×resource=true %d——雷格不再恒无)" % mine_with_resource)
# 封锁格无身份 / 雷格未开恒可查(未开格恒可查——秘宝 桥接结构前提)
var rng3 = load("res://core/rng_service.gd").new()
rng3.set_master_seed(20260920)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng3)
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
var board = settlement.minesweeper.current_board()
var r0_first: Variant = null
for c in board.compute_r0():
r0_first = c
break
var mine_unrevealed = null
for c in board.mines.keys():
if not settlement.minesweeper.is_revealed(c):
mine_unrevealed = c
break
check(settlement.query_reward_identity(r0_first) == null, "封锁格(R₀)身份查询 = null(不入摇号清单)")
check(mine_unrevealed != null and settlement.query_reward_identity(mine_unrevealed) != null,
"未开雷格身份恒可查({reward_id, cell_type, tier}——秘宝 桥接前提)")
# 五段全序断言(战斗结算 验收"五段全序"+ 行型封闭 10 类):逐轮行相位序 = 授予 → 增益/减益 → 战斗 → 衰减 → 判定。
func _smoke_settlement_segments() -> void:
print("--- 五段全序 + 行型封闭 ---")
check(Array(_db.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER
and SettlementTable.FROZEN_SEGMENT_ORDER == ["grant", "buff_debuff", "combat", "decay", "verdict"],
"segment_order 五段冻结在位(表值 = 拍板序恒等)")
var run = _run_battle(1, 0)
var legacy := {"item": 0, "consumed_ineffective": 0, "use_rejected": 0, "window_open": 0, "window_skip": 0}
for row in run.stream:
if legacy.has(String(row.get("type", ""))):
legacy[String(row.get("type", ""))] += 1
var legacy_total := 0
for k in legacy:
legacy_total += int(legacy[k])
check(legacy_total == 0, "行型封闭 10 类(删五类零残留:item/consumed_ineffective/use_rejected/window_open/window_skip)")
var order_ok := true
var decay_verdict_ok := true
var by_round := {}
for row in run.stream:
var ri: int = int(row.get("round_index", 0))
if not by_round.has(ri):
by_round[ri] = []
by_round[ri].append(row)
for ri in by_round.keys():
var phases: Array = []
for row in by_round[ri]:
phases.append(_row_phase(row, ri))
var max_phase := 0
for p in phases:
if int(p) < max_phase:
order_ok = false
max_phase = maxi(max_phase, int(p))
var decays := 0
var verdicts := 0
for row in by_round[ri]:
if String(row.get("type", "")) == "decay":
decays += 1
if String(row.get("type", "")) == "verdict":
verdicts += 1
if decays != 1 or verdicts != 1:
decay_verdict_ok = false # 衰减/判定每结算恰一次
check(order_ok, "每轮行相位单调(授予→增益/减益→战斗→衰减→判定)")
check(decay_verdict_ok, "衰减/判定段每结算恰一段(先兑付后衰减——含末轮)")
# 行相位归类(五段序断言用):0=授予(含高光/归因)1=增益减益(续存组合行)2=战斗 3=衰减 4=判定
func _row_phase(row: Dictionary, ri: int) -> int:
match String(row.get("type", "")):
"decay":
return 3
"verdict":
return 4
"cast":
return 2
"damage":
if String(row.get("source_class", "")) in ["hero_attack", "enemy_action"]:
return 2
return 0 # 授予段 flat 兑付(爆裂/直伤)
match String(row.get("source_class", "")):
"safe_cell", "mine_hit", "flag_hit", "flag_miss", "clear_reward":
if int(row.get("round_index", ri)) < ri:
return 1 # 前轮授予实例的续存组合行(增益/减益段)
return 0
_:
return 0
# 清图驱动(真实流):标一面真雷旗(完美前置)→ 逐轮 AutoPlayer 挖到轮末;board_seq 递增 = 清图换图。
# from_refresh=true 时先三轮停手攒 RAP、第 4 轮轮内刷新换图,再清刷新图。
func _run_full_clear(seed_val: int, from_refresh: bool, rows: Array) -> Dictionary:
_db.difficulty.rows.append({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hp_mult": 10.0, "atk_mult": 1.0})
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)
settlement.step_row.connect(func(row): rows.append(row))
var res := {"cleared": false, "refreshed": false, "rounds": 0}
if not settlement.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH,
"hero_hp": CLEAR_SMOKE_HERO_HP, "hero_max_hp": CLEAR_SMOKE_HERO_HP}):
_db.difficulty.rows.clear()
return res
if from_refresh:
for i in range(3):
settlement.minesweeper.settle_batch([{"action": "stop"}])
res["refreshed"] = settlement.player_request_refresh()
var drive := _drive_clear(settlement)
res["cleared"] = drive.cleared
res["rounds"] = drive.rounds
_db.difficulty.rows.clear() # 测试台账还原(后续冒烟基线)
return res
func _drive_clear(settlement) -> Dictionary:
var flag_done := false
var rounds := 0
while rounds < 60:
rounds += 1
var ms = settlement.minesweeper
if not flag_done:
var t := _find_flag_target(ms, true)
if not t.is_empty():
if t.hero_cell != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": t.hero_cell}])
ms.settle_batch([{"action": "flag_place", "cell": t.flag_cell}])
flag_done = true # 一面真雷旗 = 完美排雷前半(零误标),后半靠 AutoPlayer 清图
var seq_before: int = int(settlement.diagnostic_snapshot().board_seq)
var out := {"result": "", "rounds": 0, "terminated": false, "stream": []}
_auto_play_round(settlement, out)
if int(settlement.diagnostic_snapshot().board_seq) > seq_before:
return {"cleared": true, "rounds": rounds} # 清图换图 = board_seq 递增
if settlement.battle_result != "":
return {"cleared": false, "rounds": rounds}
return {"cleared": false, "rounds": rounds}
func _rows_of_type(rows: Array, t: String) -> Array:
var out: Array = []
for r in rows:
if String(r.get("type", "")) == t:
out.append(r)
return out
func _has_source_row(rows: Array, t: String, source: String, batch_key: String = "") -> bool:
for r in rows:
if String(r.get("type", "")) != t or String(r.get("source_class", "")) != source:
continue
if batch_key == "" or String(r.get("batch_key", "")) == batch_key:
return true
return false
func _has_effect_id(rows: Array, effect_id: String) -> bool:
for r in rows:
if String(r.get("effect_id", "")) == effect_id:
return true
return false
# 手搓战斗状态袋单点(T6 收口批——7 处字面量收编;_autoplay_battle 直接消费的最小字段族。
# 大字段族同构袋仍走 _adv_st()——_drive_level/_autoplay_battle 全字段消费面)
func _battle_st(policy: String) -> Dictionary:
return {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": policy}
# ==================== 敌人技能池批(2026-09-16 用户拍板——开轮摇号出招系统) ====================
# 六项验收断言(docs/proposal-skill-pool.md §8 批 1):同种子回放逐位复现 / 单敌轨迹独立复放 /
# EV 抽样对锚 / 死亡打断作废 / 空池不可能 / 加载校验拒绝。
func _smoke_skill_pool() -> void:
print("--- 敌人技能池(开轮摇号 · 冷却/保底两闸 · 三乘法链 · 独立子流)---")
_smoke_skill_pool_data()
_smoke_skill_pool_replay_independence()
_smoke_skill_pool_ev_anchor()
_smoke_skill_pool_death_void()
_smoke_skill_pool_validation_reject()
_smoke_debuff_rider()
# ① 数据面:技能表行集 + 普攻锁死 + 建议池行值 + 空池不可能(结构断言)
func _smoke_skill_pool_data() -> void:
check(_db.skill.rows.size() == 12, "技能表十二行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆/蛛网束缚 + 大地脉动 + 震地怒击/遏毒/岩崩——前三批 + Boss 半血解锁技三行〔迭代14④ T5〕)")
var basic: Dictionary = _db.skill.row_by_id("attack")
check(not basic.is_empty() and String(basic.type) == "basic" and int(basic.cooldown_beats) == 0
and (not basic.has("damage_mult") or is_equal_approx(float(basic.damage_mult), 1.0)),
"普攻行:倍率恒 1 · 冷却恒 0(域校验锁死——池恒非空的结构前提)")
var lunge: Dictionary = _db.skill.row_by_id("lunge")
check(not lunge.is_empty() and is_equal_approx(float(lunge.damage_mult), 1.5) and int(lunge.cooldown_beats) == 2,
"突刺行(伤害 ×1.5 · 冷却 2——提案 §2.5 建议池新行)")
var charge_row: Dictionary = _db.skill.row_by_id("charge")
check(not charge_row.is_empty() and String(charge_row.buff_ref) == "empower",
"蓄力行 = 纯攻击增益技(0 伤害、buff_ref → empower——与重击无组合语义)")
var bite: Dictionary = _db.skill.row_by_id("bite")
check(not bite.is_empty() and is_equal_approx(float(bite.damage_mult), 1.5) and int(bite.cooldown_beats) == 1,
"撕咬行(伤害 ×1.5 · 冷却 1——巨蜘蛛池载体,与突刺同倍率错开冷却轴)")
var venom: Dictionary = _db.skill.row_by_id("venom_burst")
check(not venom.is_empty() and is_equal_approx(float(venom.damage_mult), 2.5) and int(venom.cooldown_beats) == 4
and is_equal_approx(float(venom.rider_mult), 0.75),
"毒爆行(伤害 ×2.5 · 冷却 4 · 必中附加 ×0.75——蛛化卓尔精灵专属池独占技,新效应词汇批)")
var venom_sole_ok := true
for row in _db.enemy.rows:
for entry in row.pool:
if String(entry.skill_id) == "venom_burst" and String(row.enemy_id) != "drider":
venom_sole_ok = false
check(venom_sole_ok, "毒爆独占性(仅蛛化卓尔精灵池引用——Boss 专属池独占技纪律)")
# 新效应词汇批(2026-09-19):妨害技首发行 + off_guard 落点 + 实装面收窄
var snare: Dictionary = _db.skill.row_by_id("web_snare")
check(not snare.is_empty() and String(snare.type) == "debuff" and String(snare.buff_ref) == "off_guard"
and int(snare.buff_duration) == 2 and int(snare.cooldown_beats) == 4,
"蛛网束缚行(妨害型 · buff_ref → off_guard 失衡 · duration 2 = 1 有效拍 · 冷却 4——新效应词汇批首发)")
var snare_sole_ok := true
for row in _db.enemy.rows:
for entry in row.pool:
if String(entry.skill_id) == "web_snare" and String(row.enemy_id) != "drider":
snare_sole_ok = false
check(snare_sole_ok, "蛛网束缚独占性(仅蛛化卓尔精灵池引用——实装面收窄,其余敌种不动)")
var off_guard_row: Dictionary = _db.status_effect_row("off_guard")
check(not off_guard_row.is_empty() and String(off_guard_row.target_side) == "hero" and String(off_guard_row.unit) == "ac",
"失衡状态行(hero · ac 轴——妨害技落点,ConfigDB side 守卫正例)")
# 行集键 = (level_index, node_depth)——分支节点图批起 bugbear 等敌种跨关异池,enemy_id 不再唯一
var expect_pools := {
"1_0": {"attack": 9, "lunge": 1},
"1_1": {"attack": 9, "lunge": 1},
"1_2": {"attack": 6, "charge": 2, "heavy": 2},
"1_3": {"attack": 6, "charge": 2, "heavy": 2},
"1_4": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2},
"2_0": {"attack": 6, "lunge": 1, "heavy": 2},
"2_1": {"attack": 6, "lunge": 1, "heavy": 2},
"2_4": {"attack": 6, "bite": 2, "charge": 1},
"2_5": {"attack": 5, "lunge": 2, "heavy": 2},
"2_6": {"attack": 6, "bite": 2, "charge": 1},
"2_7": {"attack": 6, "bite": 2, "charge": 1},
"2_9": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2},
"3_0": {"attack": 6, "charge": 2, "heavy": 2},
"3_1": {"attack": 6, "bite": 2, "charge": 1},
"3_4": {"attack": 6, "bite": 2, "charge": 1},
"3_5": {"attack": 5, "lunge": 2, "heavy": 2},
"3_6": {"attack": 6, "bite": 2, "charge": 1},
"3_7": {"attack": 6, "charge": 2, "heavy": 2},
"3_9": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
}
var pools_ok := true
for row in _db.enemy.rows:
var rk := "%d_%d" % [int(row.level_index), int(row.node_depth)]
if not expect_pools.has(rk):
pools_ok = false
continue
var got := {}
for entry in row.pool:
got[String(entry.skill_id)] = int(entry.weight)
if got != expect_pools[rk]:
pools_ok = false
check(pools_ok, "十九行池模板 = 建议池行值(L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔(2,9)——商店房批 Boss 重编号〕;L3 灰矮人 6/2/2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2 · 石君王 6/2/2〔earth_pulse·(3,9)〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕)")
var basic_count_ok := true
for row in _db.enemy.rows:
var n := 0
for entry in row.pool:
var srow: Dictionary = _db.skill_row(String(entry.skill_id))
if not srow.is_empty() and String(srow.type) == "basic":
n += 1
if n != 1:
basic_count_ok = false
check(basic_count_ok, "每池恰一行普攻(空池不可能的加载校验同款)")
# Boss 半血解锁技·编排集成断言(迭代14④ T6——真实编排回路:半血 → 下轮开轮闩检查 → 解锁行恰一次)
var st_unlock: Dictionary = {"rows": []}
var sett_u = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(4242))
sett_u.step_row.connect(func(row): st_unlock["rows"].append(row))
check(sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp)}), "前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)")
var boss_u = sett_u._enemy_system.all_enemies()[0] # 白盒夹具(smoke 惯例——autoplay 同族私有面)
sett_u._enemy_system.apply_damage(boss_u, (int(boss_u.max_hp) - 65))
check(int(boss_u.hp) == 65, "前置:Boss 压到阈值 65(=round(130×0.5))")
sett_u.minesweeper.settle_batch([{"action": "stop"}]) # 收轮 → 结算 → 次轮开轮闩检查
var unlock_rows: Array = []
for r in st_unlock["rows"]:
if String(r.get("highlight", "")) == "skill_unlock":
unlock_rows.append(r)
check(unlock_rows.size() == 1, "解锁归因行恰一次(×%d——闩去重,二次检查零行)" % unlock_rows.size())
if unlock_rows.size() == 1:
check(String(unlock_rows[0].get("display_name", "")) == "蛛化卓尔精灵 解锁了 遏毒",
"解锁行文本 =「蛛化卓尔精灵 解锁了 遏毒」(高光键 skill_unlock——呈现层一次性技能名 toast 路由键)")
check(String(unlock_rows[0].get("type", "")) == "highlight", "解锁行型 = highlight 既有行型(零行型新增——行协议封闭)")
var rollable_ok := true
for depth in [0, 1, 2, 3, 4]:
var es = load("res://core/unit_container.gd").new(_db)
es.spawn(1, int(depth), int(_db.hero.max_hp))
for u in es.all_enemies():
if es.rollable_skills(u).is_empty():
rollable_ok = false
for depth in [0, 1, 4, 5, 6, 7, 8]:
var es2 = load("res://core/unit_container.gd").new(_db)
es2.spawn(2, int(depth), int(_db.hero.max_hp))
for u in es2.all_enemies():
if es2.rollable_skills(u).is_empty():
rollable_ok = false
check(rollable_ok, "全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言,L1/L2 双关卡)")
# 状态表新行 + 敌方防御池收编
var empower: Dictionary = _db.status_effect_row("empower")
var fortify: Dictionary = _db.status_effect_row("fortify")
check(not empower.is_empty() and String(empower.target_side) == "enemy" and int(empower.magnitude) == 100,
"蓄力状态行 empower(enemy +100 percent——敌方输出 ×2 走既有 enemy_output 池)")
check(not fortify.is_empty() and String(fortify.target_side) == "enemy" and int(fortify.magnitude) == -50,
"硬化状态行 fortify(enemy −50 percent——敌方防御池负值 = 主角攻击折半;+100 符号 bug 2026-09-18 纠正,provisional 归 状态层重设计批)")
check(String(_db.settlement.effect_pool_map.get("empower", "")) == "enemy_output" \
and String(_db.settlement.effect_pool_map.get("fortify", "")) == "enemy_defense",
"effect_pool_map 收编 empower/fortify(敌方防御池 enemy_defense 入表)")
# ② 同种子回放逐位复现(D3 大地精——原熊地精双雄,多实例同种)
# ③「单敌轨迹独立复放」随并流批退役(ADR-0007 裁丙 2026-09-27):子流派生向量退役后单敌轨迹无法
# 脱离全序重建(位置与其它流取数交错);同种子全战斗逐位复现由 ② 全对拍承载,单点定位归事件日志。
func _smoke_skill_pool_replay_independence() -> void:
var seed_val := -1
for s in range(1, 60):
var seqs := _record_pool_trajectories(s, 1)
if not seqs.is_empty() and int(seqs.count) >= 2:
seed_val = s
break
check(seed_val > 0, "多实例种子可达(seed=%d——D3 遭遇 ≥2 实例)" % seed_val)
# 数值压缩批(2026-09-17):敌 hp 18→6 后 hero_atk=1 十轮内必然击杀(实例错位、轨迹断裂)——
# 观测窗 10 轮→5 轮(5 拍 ×1 伤 < 6 HP 全员存活;仍覆盖保底 M=4 的触发窗)。
var rounds := 5
var run1 := _record_pool_trajectories(seed_val, rounds)
var run2 := _record_pool_trajectories(seed_val, rounds)
check(not run1.is_empty() and JSON.stringify(run1.seqs) == JSON.stringify(run2.seqs),
"同种子回放:各实例摇定动作序列逐位相同(×%d 轮 × %d 实例——子流独立复放面随并流退役)" % [rounds, int(run1.count)])
# 多敌战逐轮记录各实例摇定动作(hero_atk=1/hp=300 构造全员存活满 N 轮——轨迹长度 = 轮数)
func _record_pool_trajectories(seed_val: int, rounds: 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)
if not settlement.begin_battle({"level_index": 1, "node_depth": 3, "hero_hp": 300, "hero_max_hp": 300, "hero_atk": 1}):
return {}
var seqs: Array = []
for u in settlement._enemy_system.all_enemies():
seqs.append([])
for r in rounds:
var handles: Array = settlement._enemy_system.all_enemies()
for i in handles.size():
seqs[i].append(settlement._enemy_system.pending_action(handles[i]))
settlement.minesweeper.settle_batch([{"action": "stop"}])
if settlement.battle_result != "":
return {}
return {"count": seqs.size(), "seqs": seqs}
# ④ EV 抽样对锚(含闸长期速率 vs 静态锚——proposal §2.5;精确重锚归 难度曲线与Boss)。
# 带宽口径:熊地精/酋长 EV 结构性恒 1(普攻 1 + 重击 2 与蓄力/硬化 0 加权守恒)→ 窄带;
# 地精突刺池受保底闸抬升(唯一特殊技 w=1/10,保底 M=4 把突刺率从自然 10% 抬到 ~20%+)→ 宽带登记。
func _smoke_skill_pool_ev_anchor() -> void:
var bands := {"goblin": [1.00, 1.20], "bugbear": [0.97, 1.03], "ogre_chief": [1.10, 1.30]} # 酋长重锚〔迭代14④ T5——震地怒击半血解锁入池,含闸长期速率上移:实测 1.1948;EV 锚批注 enemy.tres 同批换代〕
var species := ["goblin", "bugbear", "ogre_chief"]
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260921)
var n_beats := 5000
var pity_m: int = int(_db.skill.pity_threshold)
for si in species.size():
var eid: String = species[si]
var template: Array = []
for row in _db.enemy.rows:
if String(row.enemy_id) == eid:
template = row.pool
break
var pool: Array = []
for entry in template:
pool.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight), "cooldown_left": 0})
var streak := 0
var ev_sum := 0.0
for k in n_beats:
var entries: Array = []
for e in pool:
if int(e.cooldown_left) <= 0:
entries.append({"skill_id": e.skill_id, "weight": e.weight})
if streak >= pity_m:
var specials: Array = []
for e in pool:
var sr: Dictionary = _db.skill_row(String(e.skill_id))
if not sr.is_empty() and String(sr.type) != "basic":
specials.append({"skill_id": e.skill_id, "weight": e.weight})
if not specials.is_empty():
entries = specials
var sid := SkillLottery.draw(entries, rng.open_stream("behavior_roll"))
var srow: Dictionary = _db.skill_row(sid)
var mult := 0.0
if String(srow.type) == "basic":
mult = 1.0
elif String(srow.type) == "damage":
mult = float(srow.get("damage_mult", 1.0))
ev_sum += mult
for e in pool:
e.cooldown_left = maxi(0, int(e.cooldown_left) - 1)
if String(e.skill_id) == sid:
e.cooldown_left = int(srow.cooldown_beats)
streak = streak + 1 if String(srow.type) == "basic" else 0
var measured: float = ev_sum / float(n_beats)
var band: Array = bands[eid]
var in_band: bool = measured >= float(band[0]) and measured <= float(band[1])
check(in_band, "EV 抽样对锚 %s(实测 %.4f ∈ [%.2f, %.2f] 含闸长期速率,×%d 拍)" % [eid, measured, float(band[0]), float(band[1]), n_beats])
# ⑤ 死亡打断作废(摇定后、兑现前死亡 → 本拍技能作废、不动手、不进账)
func _smoke_skill_pool_death_void() -> void:
var void_seed := -1
for s in range(1, 400):
var rg = load("res://core/rng_service.gd").new()
rg.set_master_seed(s)
var erow: Dictionary = _db.resolve_enemy_rowset(1, 2)
var entries: Array = []
for entry in erow.pool:
entries.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight)})
var sid := SkillLottery.draw(entries, rg.open_stream("behavior_roll"))
if String(_db.skill_row(sid).type) != "basic":
void_seed = s
break
check(void_seed > 0, "死亡打断种子可达(seed=%d——D2 首拍摇中特殊技)" % void_seed)
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(void_seed)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
check(settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}),
"死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)")
var u: Dictionary = settlement._enemy_system.all_enemies()[0] # 句柄预捕获(胜利 despawn 后容器已清,字典引用仍可读)
var pending: String = settlement._enemy_system.pending_action(u)
check(pending != "", "开轮已摇定本拍动作(待发 = %s)" % pending)
settlement.minesweeper.settle_batch([{"action": "stop"}])
var enemy_rows := 0
for row in rows:
if String(row.get("source_class", "")) == "enemy_action":
enemy_rows += 1
check(enemy_rows == 0, "先手击杀 → 摇定技能作废(第 1 轮零敌方行动行——死亡即不动手)")
var cd_sum := 0
for e in u.pool:
cd_sum += int(e.cooldown_left)
check(cd_sum == 0 and int(u.basic_streak) == 0,
"作废不进账(全池冷却 0 · 保底计数 0——执行上报未发生)")
# ⑥ 加载校验拒绝(单表域校验 + 跨表池构成纪律)
func _smoke_skill_pool_validation_reject() -> void:
var skill_table = load("res://data/schemas/skill_table.gd").new()
var enemy_table = load("res://data/schemas/enemy_table.gd").new()
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "ghost", "weight": 1}]}])
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "dangling_skill"),
"加载校验拒绝:池条目 skill_id 悬空致命")
skill_table.rows.assign([{"skill_id": "a", "display_name": "a", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2}])
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"),
"加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)")
skill_table.rows.assign([
{"skill_id": "b1", "display_name": "b1", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 3},
{"skill_id": "b2", "display_name": "b2", "type": "buff", "buff_ref": "fortify", "buff_duration": 2, "cooldown_beats": 4}])
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "b1", "weight": 1}, {"skill_id": "b2", "weight": 1}]}])
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"),
"加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)")
skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "damage_mult": 2.0, "cooldown_beats": 0}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:普攻配倍率 ≠ 1(域校验锁死)")
skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 2}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:普攻配冷却 ≠ 0(域校验锁死)")
skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 30.0, "cooldown_beats": 1}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:damage_mult 越域 [1.1,16]")
var et2 = load("res://data/schemas/enemy_table.gd").new()
et2.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "a", "weight": 0}]}])
check(not et2.validate().is_empty(), "加载校验拒绝:池权重 ≤ 0")
et2.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": []}])
check(not et2.validate().is_empty(), "加载校验拒绝:空池(禁)")
# 新效应词汇批(2026-09-19):妨害 / 必中附加分量校验组
skill_table.rows.assign([
{"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4},
{"skill_id": "w2", "display_name": "w2", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4}])
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "w1", "weight": 1}, {"skill_id": "w2", "weight": 1}]}])
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"),
"加载校验拒绝:池妨害技 > 1(每池妨害 ≤1 纪律)")
skill_table.rows.assign([{"skill_id": "w1", "display_name": "w1", "type": "debuff", "cooldown_beats": 4}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:妨害技缺 buff_ref(效果引用)")
skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 1.5, "rider_mult": 1.5, "cooldown_beats": 1}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:rider_mult 越域 (0,1]")
skill_table.rows.assign([{"skill_id": "b", "display_name": "b", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "rider_mult": 0.5, "cooldown_beats": 1}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:非伤害技带必中附加分量")
# 施放类 side 守卫:妨害技引用敌方侧状态 → 拒绝(增益技引用主角侧同罪,本组以妨害反例覆盖)
skill_table.rows.assign([
{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 0},
{"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 4}])
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "atk", "weight": 1}, {"skill_id": "w1", "weight": 1}]}])
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table, _db.status_effects), "cast_side"),
"加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)")
func _has_err_category(errs: Array, category: String) -> bool:
for e in errs:
if String(e.get("category", "")) == category:
return true
return false
# ⑦ 新效应词汇批实装探针(2026-09-19——妨害技蛛网束缚 + 毒爆必中附加分量,实装面 = L2 Boss 卓尔):
# 停手轮驱动 (2,9)——零挖掘零旗 → 零池,伤害行可逐拍精确复算;种子 hunt 至少各命一粒毒爆伤害行
# 与蛛网束缚施放行;毒分量随开战掷定的攻击基线缩放(atk 区间 2–4 → rider 1–3)。
# (2026-09-21 商店房批连带:Boss 重编号 8→9——(2,8) 现为商店房无战斗行,驱动点随行迁移。)
func _smoke_debuff_rider() -> void:
var venom_seed := -1
var venom_check_ok := true
var venom_rows_seen := 0
var snare_seed := -1
var base_ac: int = int(_db.hero.armor_class)
var off_m: int = int(_db.status_effect_row("off_guard").magnitude)
var floor_v: int = int(_db.settlement.damage_floor)
for s in range(1, 401):
if venom_seed > 0 and snare_seed > 0:
break
var rg = load("res://core/rng_service.gd").new()
rg.set_master_seed(s)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rg)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp)}):
continue
var boss_atk: int = int(settlement._enemy_system.all_enemies()[0].atk) # 开战掷定攻击基线(毒分量随其缩放)
for i in range(6):
if settlement.battle_result != "":
break
settlement.minesweeper.settle_batch([{"action": "stop"}])
var has_venom := false
var has_snare := false
var snare_round := -1
for row in rows:
if String(row.get("type", "")) != "damage" or String(row.get("source_class", "")) != "enemy_action":
if String(row.get("type", "")) == "cast" and String(row.get("skill_id", "")) == "web_snare":
has_snare = true
snare_round = int(row.get("round_index", -1))
continue
if String(row.get("skill_id", "")) == "venom_burst":
has_venom = true
venom_rows_seen += 1
var rider_v: int = int(row.get("rider", -1))
if rider_v != maxi(floor_v, int(floor(float(boss_atk) * 0.75))):
venom_check_ok = false
# 终值精确复算(停手轮池恒空):主分量链结果 + 必中附加后置
var base_factor: float = float(int(row.tier)) * (float(_db.settlement.crit_multiplier) / 100.0 if bool(row.get("crit", false)) else 1.0)
var rolled_main: int = int(floor(float(int(row.baseline)) * base_factor / 100.0))
var expected: int = maxi(floor_v, rolled_main + int(row.get("attach", 0))) + rider_v
if int(row.value) != expected:
venom_check_ok = false
elif row.has("rider"):
venom_check_ok = false # rider 键只允许出现在带 rider_mult 的技能行
if has_snare:
# 施放后主角掷点阈值下调(off_guard ac 轴):同轮起的伤害行阈值读数 = 基准 AC + 失衡幅度。
# 只取"可观测"种子(施放轮后仍有敌方伤害行——施放在末轮随战斗终局属合法不可观测,不作失败)。
var seen_lowered := false
for row in rows:
if String(row.get("type", "")) != "damage" or String(row.get("source_class", "")) != "enemy_action":
continue
if int(row.get("round_index", -1)) >= snare_round and int(row.get("ac", -999)) == base_ac + off_m:
seen_lowered = true
break
if seen_lowered and snare_seed < 0:
snare_seed = s
if has_venom and venom_seed < 0:
venom_seed = s
check(venom_seed > 0 and venom_rows_seen >= 1, "毒爆必中附加分量种子可达(seed=%d,毒爆伤害行 ×%d)" % [venom_seed, venom_rows_seen])
check(venom_check_ok, "毒爆伤害行逐拍精确复算:终值 = max(1, 掷点档位主分量) + 必中附加(rider 只随 rider_mult 行出现)")
check(snare_seed > 0, "蛛网束缚落主角:施放后掷点阈值 = 基准 AC + 失衡幅度(off_guard ac 轴端到端,seed=%d)" % snare_seed)
# ---- 分支节点图批冒烟:图合法性校验 + L2 幽暗地域图版行进(2026-09-17——关卡节点地图 v1.0 图版落地)----
func _smoke_level_graph() -> void:
print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---")
var autoplay = load("res://smoke/level_autoplay.gd")
# ① L1 单链与 L2 图经同一套加载校验(单链 = 出边恰一的退化形态——load_tables 全绿已含,显式复述)
check(_db.level.validate().is_empty(), "LevelTable 图合法性校验:L1 单链 + L2 图同套算法零错误")
# ② L2 行集结构(网状图建议规模)
var l2 := {}
for row in _db.level.rows:
if int(row.level_index) == 2:
l2 = row
check(not l2.is_empty() and _level_active_nodes(2).size() == 10, "L2 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——商店房批 Boss 重编号 8→9)")
var tcount := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0}
var branch := 0
for node in _level_active_nodes(2):
tcount[String(node.get("type", ""))] = int(tcount[String(node.get("type", ""))]) + 1
if node.get("next", []).size() >= 2:
branch += 1
check(int(tcount.battle) == 6 and int(tcount.boss) == 1, "L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)")
check(int(tcount.reward) == 1 and int(tcount.rest) == 1 and int(tcount.shop) == 1, "L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)")
check(branch >= 2, "L2 真实岔路 = %d 处(≥2)" % branch)
# ③ 图校验正反断言(违规行集定位到类别——加载期致命的同款算法)
var lt = load("res://data/schemas/level_table.gd").new()
var good := [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [2]},
{"node_depth": 2, "type": "boss"}]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": good}])
check(lt.validate().is_empty(), "图校验正例:两岔合流小图零错误")
var dup = JSON.parse_string(JSON.stringify(good))
dup[1].node_depth = 0
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dup}])
check(_has_err_category(lt.validate(), "dup_key"), "图校验拒绝:节点编号重复(关卡内唯一定位地址)")
var dual = JSON.parse_string(JSON.stringify(good))
dual.append({"node_depth": 9, "type": "battle", "next": [2]})
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dual}])
check(_has_err_category(lt.validate(), "structural"),
"图校验拒绝:第二无入边节点(入口唯一违例——入口节点数 = 3)")
var boss_out = JSON.parse_string(JSON.stringify(good))
boss_out[2].next = [1]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": boss_out}])
check(_has_err_category(lt.validate(), "structural"), "图校验拒绝:Boss 带出边(终点不可外出)")
var cyc = JSON.parse_string(JSON.stringify(good))
cyc[1].next = [0]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": cyc}])
check(_has_err_category(lt.validate(), "structural"), "图校验拒绝:回边 1→0(连线只朝前进方向 ⇒ 无环)")
var dang = JSON.parse_string(JSON.stringify(good))
dang[0].next = [1, 7]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dang}])
check(_has_err_category(lt.validate(), "dangling_next"), "图校验拒绝:出边指向不存在编号(悬空致命)")
var orphan = JSON.parse_string(JSON.stringify(good))
orphan[0].next = [1]
orphan[1].next = [2]
orphan.append({"node_depth": 5, "type": "battle", "next": [2]})
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": orphan}])
var orphan_errs: Array = lt.validate()
check(_has_err_category(orphan_errs, "structural"), "图校验拒绝:孤儿节点(从入口不可达 / 双入口同体违例)")
# ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开)
# 并流批(ADR-0007 裁丙)后战斗随机值面漂移——max 路径种子改行为面扫描(路线形状/商店/选路口为断言本体)
var win_min = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "min", 0)
check(win_min.result == "victory", "L2·满配 min 路径通关(实测:%s)" % win_min.result)
check(win_min.boss_depth == 9, "L2 Boss 以编号 9 解析(终点编号最大——商店房批重编号 8→9)")
check(win_min.visited_ids == [0, 1, 3, 7, 8, 9], "min 路径 = 0→1→3(休整)→7→8(商店零购离开)→9(实测 %s——T17 出度≤2 拍板模板 0 重建)" % str(win_min.visited_ids))
check(win_min.choices >= 3, "选路口发生 %d 次(每节点兑付后——零自动推进)" % win_min.choices)
# max 路径 = 存储种子(T17 改造——废除逐种子扫描):本项目核心承诺=确定性复放(同代码同表
# 同种子逐位一致),「能赢且路径符合的种子」是恒定事实,采一次存死即可。旧形态 range(42,300)
# 逐种子全关卡通关扫描找命中,期望路径过期时 258 次模拟撞 180s 闸(台账 257 + 2026-10-02
# 出度≤2 重建两次实证)。存储种子常态一次跑完;图表/数值有意变更致本断言红=快速失败(一次
# 即红非扫描超时),重采=临时脚本跑同款 run_level 循环取首命中种子回填(离线一次性)。
const MAX_PATH_SEED := 43 # 2026-10-05 判式换代批重采(旗溢价 δ=1.25 落地致种子 42 战局位移=败于终点;同款循环首命中——断言注释既定程序,临时脚本用后即删)
var win_max: Dictionary = autoplay.run_level(_db, MAX_PATH_SEED, 2, 200, 100, false, null, "normal", "max", 0)
check(win_max.result == "victory" and win_max.visited_ids == [0, 2, 6, 7, 8, 9],
"L2·满配 max 路径(max 取最大编号候选)通关 = 0→2(奖励)→6→7→8(商店零购离开)→9(存储种子 %d——T17 出度≤2 拍板模板 0 重建)" % MAX_PATH_SEED)
# ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言)
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42))
runner.set_hero_override(200, 100)
var choice_sizes: Array = []
runner.choices_presented.connect(func(c): choice_sizes.append(c.size()))
check(runner.start_level(2, 42, {}, 0), "手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;template_pick=0 固定现行图——精确选路断言)")
var st = _battle_st("normal")
var first = runner.current_battle()
var res_box := {} # 信号写回容器(lambda 按值捕获局部变量——Dictionary 引用可变)
first.battle_finished.connect(func(p): res_box["result"] = String(p.get("result", "")))
while runner.current_battle() == first and not res_box.has("result"):
autoplay._autoplay_battle(runner, first, st, null)
check(String(res_box.get("result", "")) == "victory" and runner.current_battle() == null and runner.awaiting_choice(),
"首战胜利后:settlement 释放 + 进选路态(战斗胜利不再自动推进——接口漂移清偿)")
check(runner.next_candidate_ids() == [1, 2], "入口岔路候选 = [1, 2](T17 出度≤2 拍板模板 0 宽廊双干——层宽 4 上限随出度约束)")
check(not runner.pick_next_node(8) and runner.awaiting_choice(), "候选外编号拒绝且仍待选(拒绝语义)")
check(runner.pick_next_node(2), "选编号 2(奖励点)受理")
check(String(runner.current_node().get("type", "")) == "reward" and not runner.awaiting_choice(),
"到达奖励点:直通兑付(确认前不出选路口)")
check(runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [5, 6],
"兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = [5, 6]")
check(runner.pick_next_node(6) and runner.current_battle() != null, "选编号 6(巨蜘蛛巢群)开战")
# ⑥ L2 锚行首遇战:基线 0 加成 · 完美档(零盲挖 = 零踩雷上限口径)多种子扫描
# (Package B 第 7 轮阶段拔高后锚行 45→50 退出绿档——13/12 全胜改多数胜 + 零踩雷恒要求,
# 2026-09-23 拍板 B 接受现曲线;还原绿档需回调锚行血量,归用户决策)
var green_ok := true
var green_min_hp := 99999
var green_wins := 0
for i in range(12):
var g_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i))
g_runner.start_level(2, 9100 + i)
var g_st = _battle_st("perfect")
var g_first = g_runner.current_battle()
var g_box := {}
g_first.battle_finished.connect(func(p):
g_box["result"] = String(p.get("result", ""))
g_box["hero_hp"] = int(p.get("hero_hp", 0)))
var g_guard := 6
while g_runner.current_battle() == g_first and not g_box.has("result") and g_guard > 0:
g_guard -= 1
autoplay._autoplay_battle(g_runner, g_first, g_st, null)
if int(g_st.mine_hits) > 0:
green_ok = false
if String(g_box.get("result", "")) == "victory":
green_wins += 1
if int(g_box.get("hero_hp", 0)) > 0:
green_min_hp = mini(green_min_hp, int(g_box.hero_hp))
check(green_ok, "首遇位零踩雷:完美档 12 种子(节点 0 熊地精)恒零踩雷")
check(green_wins >= 7, "首遇战胜局 ≥7/12(实际 %d——阶段拔高后锚行退出绿档,多数胜口径)" % green_wins)
check(green_min_hp > 0, "首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green_min_hp)
# ⑦ 跨表行完整:L2 每战斗/Boss 节点 (2, 编号) 在 board_gen 与 enemy 精确行(加载校验同款显式复述)
var bg_keys := {}
for row in _db.board_gen.rows:
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var en_keys := {}
for row in _db.enemy.rows:
en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var cross_ok := true
for node in _level_active_nodes(2):
var t := String(node.get("type", ""))
if t != "battle" and t != "boss":
continue
var key := "2_%d" % int(node.node_depth)
if not bg_keys.has(key) or not en_keys.has(key):
cross_ok = false
check(cross_ok, "跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行(7 节点)")
var l2_spider: Dictionary = _db.enemy.resolve_rowset(2, 4)
check(String(l2_spider.get("enemy_id", "")) == "giant_spider", "巨蜘蛛首遇行集 (2,4) = giant_spider(固定 2 只——首遇教学位不加骰)")
var l2_boss: Dictionary = _db.enemy.resolve_rowset(2, 9)
check(String(l2_boss.get("enemy_id", "")) == "drider" and String(l2_boss.get("armor_type", "")) == "ward",
"蛛化卓尔精灵 Boss 行集 (2,9) = drider(魔法甲——护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲;商店房批重编号 (2,8)→(2,9))")
# ---- 第三关内容批(2026-09-20)L3 灰矮人矿城断言段 ----
# ⑧ L3 行集结构(复用 L2 九节点图模板——6 战 + Boss + 奖励 + 恢复,真实岔路 4 处)
var l3 := {}
for row in _db.level.rows:
if int(row.level_index) == 3:
l3 = row
check(not l3.is_empty() and _level_active_nodes(3).size() == 10, "L3 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——L2 同模板 + 商店房批)")
var tcount3 := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0}
var branch3 := 0
for node in _level_active_nodes(3):
tcount3[String(node.get("type", ""))] = int(tcount3[String(node.get("type", ""))]) + 1
if node.get("next", []).size() >= 2:
branch3 += 1
check(int(tcount3.battle) == 6 and int(tcount3.boss) == 1, "L3 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)")
check(int(tcount3.reward) == 1 and int(tcount3.rest) == 1 and int(tcount3.shop) == 1, "L3 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)")
check(branch3 == 3, "L3 真实岔路 = %d 处(0→[1,2,3,4,5] · 2→[6,7] · 5→[6,7]——U2 返工批五岔环廊层宽 5)" % branch3)
# ⑨ 跨表行完整:L3 每战斗/Boss 节点 (3, 编号) 在 board_gen 与 enemy 精确行(⑦ 的 bg_keys/en_keys 复用)
var cross_ok3 := true
for node in _level_active_nodes(3):
var t3 := String(node.get("type", ""))
if t3 != "battle" and t3 != "boss":
continue
var key3 := "3_%d" % int(node.node_depth)
if not bg_keys.has(key3) or not en_keys.has(key3):
cross_ok3 = false
check(cross_ok3, "跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行(7 节点)")
# ⑩ 定点:L3 首遇行集与 Boss 行集(克制面重轻错开——重甲灰矮人首次登台)
var l3_lead: Dictionary = _db.enemy.resolve_rowset(3, 0)
check(String(l3_lead.get("enemy_id", "")) == "duergar" and String(l3_lead.get("armor_type", "")) == "heavy",
"灰矮人首遇行集 (3,0) = duergar(重甲首次登台——固定 1 只,首遇教学位不加骰)")
var l3_king: Dictionary = _db.enemy.resolve_rowset(3, 9)
check(String(l3_king.get("enemy_id", "")) == "duergar_king" and String(l3_king.get("armor_type", "")) == "heavy",
"灰矮人石君王 Boss 行集 (3,9) = duergar_king(重甲收束「轻甲→魔法甲→重甲」教学梯度;商店房批重编号 (3,8)→(3,9))")
# ⑪ L3 成长代理档首遇绿档:模拟 GP 商店成长后的入关参数(atk_pct=200 / hp_pct=100)· 完美档多种子首遇战稳过
# (零加成基线红档属预期——断言口径 = 成长代理档,非基线;跑法同 ⑥)
var green3_ok := true
var green3_min_hp := 99999
for i in range(12):
var g3_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i))
g3_runner.set_hero_override(200, 100)
g3_runner.start_level(3, 9100 + i)
var g3_st = _battle_st("perfect")
var g3_first = g3_runner.current_battle()
var g3_box := {}
g3_first.battle_finished.connect(func(p):
g3_box["result"] = String(p.get("result", ""))
g3_box["hero_hp"] = int(p.get("hero_hp", 0)))
var g3_guard := 6
while g3_runner.current_battle() == g3_first and not g3_box.has("result") and g3_guard > 0:
g3_guard -= 1
autoplay._autoplay_battle(g3_runner, g3_first, g3_st, null)
if String(g3_box.get("result", "")) != "victory" or int(g3_st.mine_hits) > 0:
green3_ok = false
elif int(g3_box.get("hero_hp", 0)) > 0:
green3_min_hp = mini(green3_min_hp, int(g3_box.hero_hp))
check(green3_ok, "L3 首遇位绿档:成长代理档口径(atk_pct=200/hp_pct=100)完美档 12 种子首遇战(节点 0 灰矮人)全胜且零踩雷")
check(green3_min_hp > 0, "L3 首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green3_min_hp)
# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律)
func _fresh_rng(seed_val: int):
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
return rng
# ==================== GP+商店房实施批冒烟(2026-09-21——接 L3 断言段后) ====================
# ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支)
# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移)
# ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。
# ---- ① GP 冒险钱包·到账 ----
func _smoke_gp_wallet() -> void:
print("--- GP 冒险钱包·到账(战斗完成入账恰一次 + Boss 加值)---")
var autoplay = load("res://smoke/level_autoplay.gd")
# ① L1 首战(普通战档——掷定 [2,4] + 关级加值 0):runner 驱动,收集 battle_finished 与钱包事件
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42))
runner.set_hero_override(200, 100)
var fin := {}
var wallet_events: Array = []
runner.wallet_changed.connect(func(ev, gp_val, extra): wallet_events.append({"event": String(ev), "gp": int(gp_val), "amount": int(extra.get("amount", 0))}))
check(runner.start_level(1, 42), "GP 到账:L1 启动(普通战档)")
var battle = runner.current_battle()
var rows: Array = []
battle.step_row.connect(func(row): rows.append(row))
battle.battle_finished.connect(func(p): fin["p"] = p)
var st := _battle_st("normal")
var guard := 30
while runner.current_battle() == battle and not fin.has("p") and guard > 0:
guard -= 1
autoplay._autoplay_battle(runner, battle, st, null)
check(fin.has("p") and String(fin["p"].get("result", "")) == "victory", "L1 首战胜利(battle_finished gp_dropped 载荷在案)")
var escrow_sum := _escrow_sum_of(rows)
var gp_dropped: int = int(fin["p"].get("gp_dropped", -999))
check(gp_dropped - escrow_sum >= 2 and gp_dropped - escrow_sum <= 4,
"L1 普通战 gp_dropped − escrow ∈ [2+0, 4+0](掷定+关级加值 %d,escrow %+d)" % [gp_dropped - escrow_sum, escrow_sum])
var gains: Array = []
for ev in wallet_events:
if ev["event"] == "gain":
gains.append(ev)
check(gains.size() == 1, "钱包恰入账一次(gain 事件 ×%d)" % gains.size())
if gains.size() == 1:
check(int(gains[0]["amount"]) == gp_dropped, "入账金额 = gp_dropped 载荷(%d)" % gp_dropped)
check(runner.gp == maxi(0, gp_dropped) and runner.gp == int(gains[0]["gp"]),
"钱包读数 = 入账后投影(gp=%d,破财负值场景经 clamp ≥0)" % runner.gp)
# ② L2 Boss 战(节点 9——商店房批重编号):掷定 [6,10] + 关级加值 1
var boss = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(42))
var rows_b: Array = []
var fin_b := {}
boss.step_row.connect(func(row): rows_b.append(row))
boss.battle_finished.connect(func(p): fin_b["p"] = p)
check(boss.begin_battle({"level_index": 2, "node_depth": 9, "node_type": "boss", "hero_hp": int(_db.hero.max_hp)}),
"L2 Boss 战启动((2,9)——Boss 档掷定 [6,10] + 关级加值 1)")
var guard_b := 60
while boss.battle_result == "" and guard_b > 0:
guard_b -= 1
boss.minesweeper.settle_batch([{"action": "stop"}])
check(fin_b.has("p"), "L2 Boss 战终局(%s——gp_dropped 于战斗末掷定上抛)" % String(boss.battle_result))
var escrow_b := _escrow_sum_of(rows_b)
var gp_b: int = int(fin_b.get("p", {}).get("gp_dropped", -999)) if fin_b.has("p") else -999
check(gp_b - escrow_b >= 7 and gp_b - escrow_b <= 11,
"L2 Boss 战 gp_dropped − escrow ∈ [6+1, 10+1](掷定+关级加值 %d,escrow %+d)" % [gp_b - escrow_b, escrow_b])
# 步骤流中 GP 型兑付行(effect_id gp_gain/gp_loss)的 value 代数和 = escrow 会计算(归因行随行携带)
func _escrow_sum_of(rows: Array) -> int:
var sum := 0
for row in rows:
if String(row.get("effect_id", "")) == "gp_gain" or String(row.get("effect_id", "")) == "gp_loss":
sum += int(row.get("value", 0))
return sum
# ---- ② escrow 隔离(金币安全格兑付 / 破财直验 / 钱包 clamp)----
func _smoke_gp_escrow() -> void:
print("--- GP escrow 隔离(金币安全格兑付入缓冲 · 破财不透支)---")
# ① 种子扫描:找金币袋(gp 型安全格)可挖掘局——挖开兑付 +3 入 escrow,战斗末随 gp_dropped 一次上抛
var scan := {}
for s in range(1, 121):
var rng = _fresh_rng(s)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
var fin := {}
settlement.battle_finished.connect(func(p): fin["p"] = p)
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}):
continue
var target := _find_gp_dig(settlement.minesweeper, settlement)
if target.is_empty():
continue
scan = {"seed": s, "settlement": settlement, "target": target, "rows": rows, "fin": fin}
break
check(not scan.is_empty(), "金币袋(gp 型安全格)可挖掘局种子可达(seed=%s)" % str(scan.get("seed", -1)))
if not scan.is_empty():
var settlement2 = scan["settlement"]
var ms = settlement2.minesweeper
var t: Dictionary = scan["target"]
var dig_arr: Array = [t["dig_cell"].x, t["dig_cell"].y]
if t["hero_cell"] != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": t["hero_cell"]}])
ms.settle_batch([{"action": "dig", "cell": t["dig_cell"]}])
var guard_stop := 12
while settlement2.battle_result == "" and guard_stop > 0:
guard_stop -= 1
ms.settle_batch([{"action": "stop"}]) # 收轮结算:授予段兑付 +3 → 战斗段先手 999 清场 → 胜利上抛
check(settlement2.battle_result == "victory", "金币局战斗终局(先手 999 收束)")
var gain_ok := false
for row in scan["rows"]:
if String(row.get("effect_id", "")) == "gp_gain" and row.get("origin_cell", []) == dig_arr and int(row.get("value", 0)) == 3:
gain_ok = true
check(gain_ok, "金币袋兑付行在案(金币袋 +3 → 战斗内缓冲,origin 指回来源格)")
check(int(settlement2.escrow) >= 3, "escrow 累计缓冲 ≥3(实测 %d——战斗域中间态,不入钱包不入增益容器)" % int(settlement2.escrow))
var gp_d: int = int(scan["fin"].get("p", {}).get("gp_dropped", -999))
check(gp_d - int(settlement2.escrow) >= 2 and gp_d - int(settlement2.escrow) <= 4,
"格面兑付计入 gp_dropped(%d − escrow %d ∈ [2,4]——战斗末一次到账,随上抛即弃)" % [gp_d, int(settlement2.escrow)])
# ② 破财(gp_loss)直验:兑付入口直调(weight 0 不入池——字典负向行单测通道,负向字典冒烟前例)
var rng_l = _fresh_rng(20260921)
var settle_l = load("res://core/battle_orchestrator.gd").new(_db, rng_l)
var fin_l := {}
settle_l.battle_finished.connect(func(p): fin_l["p"] = p)
check(settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}),
"破财直验战斗启动")
for i in range(3):
settle_l._grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1},
"negative", "mine_hit", Vector2i(0, 0), 1)
check(int(settle_l.escrow) == -6, "破财 ×3 → escrow −6(负向缓冲记账,零钱包触达)")
settle_l.minesweeper.settle_batch([{"action": "stop"}])
var gp_l: int = int(fin_l.get("p", {}).get("gp_dropped", 999))
check(gp_l + 6 >= 2 and gp_l + 6 <= 4, "破财压制 gp_dropped 负值上抛(%d + 6 ∈ [2,4]——掷定不受破财污染)" % gp_l)
# ③ 钱包不透支(clamp ≥0):runner 驱动 L1 首战 + 战斗中注入破财 → gp_dropped 负值 → 钱包钳制归 0
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(4242))
runner.set_hero_override(49850, 0) # atk 2 → 999(快速终局;hp 基线)
var ev_box := {"gains": [], "min_gp": 999999}
runner.wallet_changed.connect(func(ev, gp_val, extra):
ev_box["gains"].append({"event": String(ev), "gp": int(gp_val), "amount": int(extra.get("amount", 0))})
ev_box["min_gp"] = mini(ev_box["min_gp"], int(gp_val)))
check(runner.start_level(1, 4242), "clamp 场景:L1 启动(冒险钱包开局 0)")
var b3 = runner.current_battle()
var fin3 := {}
b3.battle_finished.connect(func(p): fin3["p"] = p)
for i in range(3):
b3._grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1},
"negative", "mine_hit", Vector2i(0, 0), 1)
var guard3 := 12
while runner.current_battle() == b3 and not fin3.has("p") and guard3 > 0:
guard3 -= 1
b3.minesweeper.settle_batch([{"action": "stop"}])
var gp3: int = int(fin3.get("p", {}).get("gp_dropped", 999))
check(gp3 < 0, "破财压制入账载荷为负(gp_dropped=%d)" % gp3)
var gain3: Array = []
for ev in ev_box["gains"]:
if ev["event"] == "gain":
gain3.append(ev)
check(gain3.size() == 1 and int(gain3[0]["amount"]) == gp3 and int(gain3[0]["gp"]) == 0,
"破财入账恰一次且投影读数 0(amount=%d,clamp 后 gp=0)" % gp3)
check(runner.gp == 0 and int(ev_box["min_gp"]) >= 0, "钱包全程不透支(gp=%d,事件投影最小读数 %d ≥ 0)" % [runner.gp, int(ev_box["min_gp"])])
# 板真相直读找金币袋挖掘目标:未开安全格 + gp 型资源身份 + 存在可达已开邻格(_find_flag_target 同款口径)
func _find_gp_dig(minesweeper, settlement) -> Dictionary:
var board = minesweeper.current_board()
var revealed := {}
for c in minesweeper.revealed_cells():
revealed[c] = true
for y in board.height:
for x in board.width:
var c := Vector2i(x, y)
if revealed.has(c) or board.is_mine(c):
continue
var ident = settlement.query_reward_identity(c)
if ident == null or String(ident.get("cell_type", "")) != "gp":
continue
for n in board.neighbors8(c):
if revealed.has(n) and (minesweeper.compute_move_path(n).size() > 0 or n == minesweeper.hero_pos()):
return {"dig_cell": c, "hero_cell": n}
return {}
# ---- ③ 商店房三收口 ----
func _smoke_shop_room() -> void:
print("--- 商店房三收口(现价/加算区/余额不足拒/离开回选路/收束清零)---")
var autoplay = load("res://smoke/level_autoplay.gd")
# 行进到商店房(min 路径:0→1→3(恢复)→6→8)——种子扫描保证进店余额 ≥ 三件首购总价 16(6+4+6)
var shop_run := {}
for s in range(42, 92):
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(s))
runner.set_hero_override(900, 400) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言)
runner.start_level(2, s, {}, 0) # 固定模板 0——商店经济断言按现行图 GP 账本
var st := _battle_st("normal")
var guard := 40
while String(runner.level_result) == "" and int(runner.current_node_id()) != 8 and guard > 0:
guard -= 1
if runner.awaiting_choice():
var ids: Array = runner.next_candidate_ids()
ids.sort()
runner.pick_next_node(int(ids[0]))
continue
var t := String(runner.current_node().get("type", ""))
if t == "reward" or t == "rest":
runner.continue_after_node()
continue
if t == "shop":
break
var b = runner.current_battle()
if b == null:
break
var fin := {}
b.battle_finished.connect(func(p): fin["p"] = p)
var g2 := 80
while runner.current_battle() == b and not fin.has("p") and g2 > 0:
g2 -= 1
autoplay._autoplay_battle(runner, b, st, null)
if String(runner.level_result) == "" and int(runner.current_node_id()) == 8 and runner.gp >= 16:
shop_run = {"seed": s, "runner": runner}
break
check(not shop_run.is_empty(), "商店房到达种子可达(seed=%s——进店 GP ≥ 16)" % str(shop_run.get("seed", -1)))
if shop_run.is_empty():
return
var runner = shop_run["runner"]
check(String(runner.current_node().get("type", "")) == "shop" and int(runner.current_node_id()) == 8,
"L2 行进到商店房(节点 8,next=[9]——Boss 前必经位)")
check(not runner.awaiting_choice() and runner.next_candidate_ids().is_empty(),
"到店开店态(覆盖层数据源就位——离开前不出选路口,零自动推进)")
# 现价 = 基价 + price_level_bonus(L2)=2 + 步长 × 已购档数
var prods: Array = runner.shop_products()
check(prods.size() == 3, "三商品全陈列(atk_up/hp_up/armor_up——无刷新无摇号)")
var by_id := _shop_products_by_id(prods)
check(int(by_id["atk_up"]["price"]) == 6 and int(by_id["hp_up"]["price"]) == 4 and int(by_id["armor_up"]["price"]) == 6,
"L2 首购现价 = 基价 + 关级加值 2(atk_up 6 / hp_up 4 / armor_up 6)")
# 购买 atk_up:钱包 −6 · 加算区 atk +1 · 二购现价 8
var gp0: int = runner.gp
check(runner.buy_product("atk_up"), "购买 atk_up 受理(现价 6)")
check(runner.gp == gp0 - 6, "钱包扣现价 6(%d → %d)" % [gp0, runner.gp])
check(int(runner.bonus.get("atk", -1)) == 1 and int(runner.bonus.get("max_hp", -1)) == 0 and int(runner.bonus.get("armor_class", -1)) == 0,
"统一冒险加算区 atk +1(bonus={atk,max_hp,armor_class} 账本)")
by_id = _shop_products_by_id(runner.shop_products())
check(int(by_id["atk_up"]["price"]) == 8 and int(by_id["atk_up"]["purchased"]) == 1,
"atk_up 二购现价 = 4+2+2=8(步长 2 × 已购 1 档,现价现算)")
# 行进段百分比归零(购买/生效断言按加算区裸值口径——调试浮窗百分比后乘;hp 快照钳到新口径上限防 spawn 越界,测试通道)
runner.set_hero_override(0, 0)
runner.hero_hp = mini(runner.hero_hp, runner.effective_max_hp())
# hp_up:上限与当前生命同步 +3(封顶新上限)
var hp_before: int = runner.hero_hp
var mhp_before: int = runner.effective_max_hp()
check(runner.buy_product("hp_up"), "购买 hp_up 受理(现价 4)")
check(runner.gp == gp0 - 6 - 4, "钱包扣 4(%d)" % runner.gp)
check(runner.effective_max_hp() == mhp_before + 3, "生命上限 +3(%d → %d——加算区 max_hp 轴)" % [mhp_before, runner.effective_max_hp()])
check(runner.hero_hp == mini(hp_before + 3, runner.effective_max_hp()), "当前生命同步 +3 封顶新上限(%d → %d)" % [hp_before, runner.hero_hp])
# armor_up:AC 10+1
check(runner.buy_product("armor_up"), "购买 armor_up 受理(现价 6)")
check(runner.effective_ac() == int(_db.hero.armor_class) + 1, "AC = 10+1(armor_class 加算区轴,无百分比通道)")
# 余额不足购买被拒(零副作用):hp_up 连购至不可负担后兜底拒绝
var guard_drain := 20
var hp_buys := 1 # 夹具前段已购 1 笔(购买 hp_up 受理断言)——总数 = 前段 + 本循环
while guard_drain > 0:
guard_drain -= 1
var hp_price: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"])
if runner.gp < hp_price:
break
if not runner.buy_product("hp_up"):
break
hp_buys += 1
var gp_dry: int = runner.gp
var tier_dry: int = runner.purchased_tier("hp_up")
var hp_dry: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"])
check(not runner.buy_product("hp_up") and runner.gp == gp_dry and runner.purchased_tier("hp_up") == tier_dry,
"余额不足购买被拒(gp=%d < 现价 %d——零扣费零档位变动)" % [gp_dry, hp_dry])
# 离开 → 回选路口(候选 [9])
check(runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [9],
"离开商店 → 回选路口(候选 [9]——零自动推进收口)")
# 下一战参数 = 基础表值 + 加算区(百分比已归零——加算先乘后乘序的前半在案)
check(runner.effective_atk() == int(_db.hero.atk) + 1, "主角攻击生效值 = 2+1(基础表值 + 加算区)")
check(runner.pick_next_node(9) and runner.current_battle() != null, "选 Boss(节点 9)开战")
var snap: Dictionary = runner.current_battle().presentation_snapshot()
# max_hp 期望按实际购买笔数推导(迭代14① T4 断言基线复核——棋盘换代改变战局 GP 流,可购笔数
# 随经济漂移;断言语义=「加算区笔数×3 全额入战斗开始参数」,不再锚死恰好 1 笔)
check(int(snap["hero"].get("atk", -1)) == int(_db.hero.atk) + 1 and int(snap["hero"].get("max_hp", -1)) == int(_db.hero.max_hp) + 3 * hp_buys,
"战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = %d+%d×3)" % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1)), int(_db.hero.max_hp), hp_buys])
# 冒险收束(Boss 胜或败北——基线裸装口径任一结局)→ 钱包清零
var fin_end := {}
runner.level_finished.connect(func(p): fin_end["p"] = p)
var g3 := 60
while not fin_end.has("p") and g3 > 0:
g3 -= 1
var b_end = runner.current_battle()
if b_end == null:
break
autoplay._autoplay_battle(runner, b_end, _battle_st("normal"), null)
check(fin_end.has("p") and String(fin_end["p"].get("result", "")) in ["victory", "defeat"],
"冒险收束(Boss 战终局:%s)" % String(fin_end.get("p", {}).get("result", "")))
check(runner.gp == 0, "冒险收束钱包清零(局内货币生命周期终点——不入存档不入摘要)")
func _shop_products_by_id(prods: Array) -> Dictionary:
var out := {}
for p in prods:
out[String(p["product_id"])] = p
return out
# ---- ④ 商店房图不变式(恰 1 商店 · 必经位 · 跨表行集迁移)----
func _smoke_shop_graph() -> void:
print("--- 商店房图不变式(恰 1 商店房 · 全路径必经 · 跨表行集迁移)---")
for L in [2, 3]:
var row := {}
for r in _db.level.rows:
if int(r.level_index) == L:
row = r
var shops: Array = []
var boss_id := -1
var max_id := -1
for n in _level_active_nodes(L):
var t := String(n.get("type", ""))
if t == "shop":
shops.append(int(n.get("node_depth", -1)))
if t == "boss":
boss_id = int(n.get("node_depth", -1))
max_id = maxi(max_id, int(n.get("node_depth", -1)))
check(shops.size() == 1 and int(shops[0]) == 8, "L%d 商店房恰 1 且 node_depth=8(next=[9])" % L)
check(boss_id == 9 and boss_id == max_id, "L%d Boss = 9 = 全图最大编号(商店房批重编号 8→9)" % L)
var paths: Array = _all_entry_boss_paths(_level_active_nodes(L))
var via_shop := true
for p in paths:
if not p.has(8):
via_shop = false
check(paths.size() >= 2 and via_shop, "L%d 全部 %d 条入口→Boss 路径穿商店房 8(必经位——DFS 枚举)" % [L, paths.size()])
# 跨表校验:(·,9) Boss 行精确存在 · (·,8) 无战斗行残留(陈旧行清理确认)
var bg_keys := {}
for row in _db.board_gen.rows:
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var en_keys := {}
for row in _db.enemy.rows:
en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
for L in [2, 3]:
check(bg_keys.has("%d_9" % L) and en_keys.has("%d_9" % L), "(%d,9) Boss 行在 board_gen + enemy 精确存在" % L)
check(not bg_keys.has("%d_8" % L) and not en_keys.has("%d_8" % L), "(%d,8) 商店房无板/行集残留(陈旧行已清理)" % L)
check(bg_keys.has("1_4") and en_keys.has("1_4") and not bg_keys.has("1_8") and not en_keys.has("1_8"),
"L1 行集零改动(单链 Boss (1,4)——教学期最小修订面)")
# ---- ⑤ 商店共享池(M-4 迭代 11 第②批 T5——三选一同构/乘法价/跨渠道共账/切边/断点扩键)----
func _smoke_shop_pool() -> void:
print("--- 商店共享池(抽3买1全重抽 · 乘法定价 ceil · 池空硬闸 · 切边零入店)---")
var autoplay = load("res://smoke/level_autoplay.gd")
var shelf_kinds: Array = []
for row in _db.shop_pool_rows():
shelf_kinds.append(String(row.get("kind_id", "")))
# ① 池模式场景可达(_adv_drive_to_shop 复用——L1 全程+L2 推进到商店房;pool 模式进店即抽架)
var scen := {}
var first_shelf_a: Array = []
var first_shelf_b: Array = []
for s in range(42, 92):
var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s))
a8.set_hero_override(900, 400)
if not a8.start_adventure(s, {"first_script": true, "shop_mode": "pool"}):
continue
if not _adv_drive_to_shop(a8, _adv_st(), autoplay):
continue
scen = {"a": a8, "s": s, "r": a8.runner()}
first_shelf_a = (a8.runner().shop_shelf() as Array).duplicate(true)
break
check(not scen.is_empty(), "池模式商店房场景可达(seed=%s)" % str(scen.get("s", -1)))
if not scen.is_empty():
# 同种子再跑一遍采第二份首架(确定性对拍——断点货架续玩=确定性重放承载的实证面)
var a8b = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(int(scen["s"])))
a8b.set_hero_override(900, 400)
if a8b.start_adventure(int(scen["s"]), {"first_script": true, "shop_mode": "pool"}) \
and _adv_drive_to_shop(a8b, _adv_st(), autoplay):
first_shelf_b = (a8b.runner().shop_shelf() as Array).duplicate(true)
check(first_shelf_a.size() == 3, "进店抽 3(货架 %d 卡——同构交互)" % first_shelf_a.size())
# MC-1 商店独立刷新(P-C2+Q2 裁——基础免费 0;GP 刷新 ceil(20×1.8^n) provisional;重抽同源)
if not scen.is_empty():
var r8 = scen["r"]
var gp0: int = int(r8.gp)
var cost0: int = r8.shop_refresh_cost()
check(cost0 == int(_db.adventure.shop_refresh_cost_base), "刷新首价=基价(%d)" % cost0)
check(r8.shop_refresh_free_left() == 0, "基础免费 0 次(Q2——天赋增益位后续添加)")
var shelf_pre: Array = (r8.shop_shelf() as Array).duplicate(true)
check(r8.shop_refresh(), "GP 刷新受理(gp=%d cost=%d)" % [gp0, cost0])
check(int(r8.gp) == gp0 - cost0, "GP 刷新扣费(%d→%d)" % [gp0, int(r8.gp)])
check(int(r8.shop_refresh_cost()) == int(ceil(float(cost0) * float(_db.adventure.shop_refresh_cost_curve))), "刷新爬价 ×curve(%d)" % int(r8.shop_refresh_cost()))
var same_shelf := first_shelf_a.size() == first_shelf_b.size()
if same_shelf:
for i in first_shelf_a.size():
if String(first_shelf_a[i].get("kind_id", "")) != String(first_shelf_b[i].get("kind_id", "")) \
or int(first_shelf_a[i].get("price", -1)) != int(first_shelf_b[i].get("price", -1)):
same_shelf = false
break
check(same_shelf, "同种子同货架逐位(确定性重放承载——断点货架续玩语义)")
if scen.is_empty():
return
var a8 = scen["a"]
var r8 = scen["r"]
# ② 货架投影形制:kind 词表封闭 + 现价公式推导(ceil(base×b^(关序−1)×A^0)——核心现算,呈现零复算)
var l2 := int(r8.level_row.level_index)
for c in r8.shop_shelf():
# T6 商店保底后 relic 卡合法入架(保底占位)——词表断言放行 relic;价格断言分叉
# (数值卡走 shop_pool_row 推导 / relic 卡走 15 固定锚——同公式不同基价)
var ckind := String(c.get("kind_id", ""))
var is_relic := ckind == "relic"
check(is_relic or shelf_kinds.has(ckind), "货架 kind 在封闭词表或保底 relic 卡(%s)" % ckind)
var expect_p: int
if is_relic:
expect_p = int(ceil(15.0 * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0))
else:
var prow: Dictionary = _db.shop_pool_row(ckind)
expect_p = int(ceil(float(int(prow.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0))
check(int(c.get("price", -1)) == expect_p,
"首购现价 = ceil(基价×b^(关序−1))(%s: %d = %d 推导)" % [ckind, int(c.get("price", -1)), expect_p])
# ③ 买 1 全重抽:出账+加算区+账本三落点 + 现价 A 链递增(买后 visit 计数=1;TP0——消费在架令牌本体)
var buy_c: Dictionary = {}
for c in r8.shop_shelf():
if int(c.get("price", 1 << 30)) <= int(r8.gp):
buy_c = c
break
check(not buy_c.is_empty(), "可购卡在架(GP %d——种子扫描保证首购空间)" % int(r8.gp))
if not buy_c.is_empty():
var kind := String(buy_c.get("kind_id", ""))
var tier := String(buy_c.get("tier", ""))
var val_map := {"attack": "atk", "max_hp": "max_hp", "armor": "armor_class", "ap": "ap"}
var gp0: int = int(r8.gp)
var taken0: int = int(a8._curio_taken.get(kind, 0))
var val0: int = int(a8.bonus.get(String(val_map[kind]), -999))
check(r8.buy_pool_item(kind), "购买受理(pool 口——%s|%s)" % [kind, tier])
check(int(r8.gp) == gp0 - int(buy_c.get("price", 0)), "钱包扣现价(%d → %d)" % [gp0, int(r8.gp)])
check(int(a8.bonus.get(String(val_map[kind]), -999)) == val0 + int(_db.adventure.curio_value(kind, tier)),
"加算区 +令牌档位件值(池件=奖励件本体——%s|%s %+d)" % [kind, tier, int(_db.adventure.curio_value(kind, tier))])
check(int(a8._curio_taken.get(kind, 0)) == taken0 + 1, "共享池账本 -1(跨渠道单点——taken %d → %d)" % [taken0, int(a8._curio_taken.get(kind, 0))])
# A 链:同visit 第二购现价 = ceil(base×b^(l2−1)×A^1)(货架已全重抽——新卡新价同公式)
var second: Dictionary = {}
for c in r8.shop_shelf():
if int(c.get("price", 1 << 30)) <= int(r8.gp):
second = c
break
if not second.is_empty():
var prow2: Dictionary = _db.shop_pool_row(String(second.get("kind_id", "")))
var expect_p2: int = int(ceil(float(int(prow2.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * _db.shop_price_a()))
check(int(second.get("price", -1)) == expect_p2,
"A 链爬价 = ceil(base×b^(关序−1)×A^1)(进店第 2 购——%d)" % expect_p2)
check(r8.buy_pool_item(String(second.get("kind_id", ""))), "同 visit 第二购受理(A 链软闸在价不在限)")
# ④ 切边零入店(白-box 重抽 40 次——GP 袋/规则改写型特性永不在架;重抽消费 shop_draw 签,场景尾不回写主时间线)
var cut_seen := false
for i in 40:
r8._shop_draw_shelf()
for c in r8.shop_shelf():
if String(c.get("kind_id", "")) == "gp" or String(c.get("kind_id", "")) == "treasure_item":
cut_seen = true
check(not cut_seen, "切边件零入店(40 次重抽零 gp/aux——负交易切边+三轴 doctrine)")
# ⑤ 池空硬闸(库存尽剔出牌库——TP0:white-box 耗尽 attack 令牌后货架零 attack + 消费口拒绝)
for di8 in range(a8._curio_deck.size() - 1, -1, -1):
if String((a8._curio_deck[di8] as Dictionary).get("kind", "")) == "attack":
a8._curio_deck.remove_at(di8)
var attack_seen := false
for i in 40:
r8._shop_draw_shelf()
if (r8.shop_shelf() as Array).any(func(c): return String(c.get("kind_id", "")) == "attack"):
attack_seen = true
break
check(not attack_seen, "库存尽剔牌库(attack 令牌耗尽——池消耗即上限跨渠道共库 reward→shop 向)")
var stock_before: int = a8.pool_stock("attack")
check(not a8._consume_pool_purchase("attack", "common") and a8.pool_stock("attack") == stock_before,
"池空消费拒绝(handler 零副作用——买光即枯竭硬闸)")
# ⑥ 跨渠道共库 shop→reward 向:回填一枚 attack 令牌 → 消费口受理 → 奖励牌库同步摘牌
a8._curio_deck.append({"kind": "attack", "tier": "common"})
check(a8._consume_pool_purchase("attack", "common"), "商店消费口受理(_apply_curio_pick 同口——真单点库)")
var reward_attack_seen := false
for t6 in a8._draw_curio_candidates():
if String((t6 as Dictionary).get("kind", "")) == "attack":
reward_attack_seen = true
break
check(not reward_attack_seen,
"商店购买进奖励牌库摘牌(买一件=奖励面少一件——shop→reward 向共库)")
# ⑦ 断点扩键(P2 甲——save v4 域内:shop_shelf/shop_visit_buys 入快照;离开店=空架合法态)
var sm = load("res://core/save_manager.gd").new("user://_smoke_shop_pool_save.json")
sm.load_from_disk()
a8.set_save_manager(sm)
check(r8.continue_after_node() and r8.awaiting_choice(), "离开商店 → 回选路口(货架态清零落档——零自动推进收口)")
var bp: Dictionary = sm.breakpoint_snapshot()
check(bp.has("shop_shelf") and (bp["shop_shelf"] as Array).is_empty(),
"断点扩键 shop_shelf 在案(离开店=空架合法态——P2 甲扩列)")
check(bp.has("shop_visit_buys") and int(bp["shop_visit_buys"]) == 0,
"断点扩键 shop_visit_buys 在案(离开店清零——A 链输入不入档语义)")
sm.clear_breakpoint()
# ⑧ 终局池读数(run_adventure pool 面——商店与奖励跨渠道消耗后的终局余量出讫)
var st_pool: Dictionary = autoplay.run_adventure(_db, 42, 0, 0, false, null, "normal", "min", {}, {}, {"mode": "pool"})
var ps: Dictionary = st_pool.get("pool_stock", {})
check(ps.size() == 4, "终局池读数四键出讫(run_adventure pool 面——读数只锚一次载体)")
var ps_ok := true
for k in ps.keys():
var quota := 0
for tier in _db.adventure.TIERS:
quota += int(_db.adventure.curio_quota(String(k), tier))
if int(ps[k]) < 0 or int(ps[k]) > quota:
ps_ok = false
check(ps_ok, "终局池余量在域 [0,配额](%s)" % str(ps))
# 入口→Boss 全路径枚举(DFS 迭代栈——图合法性校验保证单入口无环,路径有限)
func _all_entry_boss_paths(nodes: Array) -> Array:
var by_id := {}
var inbound := {}
for n in nodes:
by_id[int(n.get("node_depth", -1))] = n
for n in nodes:
for tid in n.get("next", []):
inbound[int(tid)] = true
var entry := -1
for n in nodes:
if not inbound.has(int(n.get("node_depth", -1))):
entry = int(n.get("node_depth", -1))
var paths: Array = []
var stack: Array = [[entry]]
while not stack.is_empty():
var path: Array = stack.pop_back()
var cur: Dictionary = by_id[path[path.size() - 1]]
var outs: Array = cur.get("next", [])
if outs.is_empty():
paths.append(path)
continue
for t in outs:
var np := path.duplicate()
np.append(int(t))
stack.append(np)
return paths
# ---- ⑤ 格面资源九型 + gp_drop 流(黄金向量)----
func _smoke_nine_types_gp_stream() -> void:
print("--- 格面资源九型 + gp_drop 流(奖励字典 18 格 + 黄金向量)---")
check(RewardTable.CELL_TYPES.size() == 10 and RewardTable.CELL_TYPES.has("gp") and RewardTable.CELL_TYPES.has("treasure_item"),
"格面资源十型封闭(第九型 gp 九型批 2026-09-20 / 第十型 treasure_item 秘宝内容批 2026-09-29——词表 v1.2)")
var cell_rows := 0
var pos_weight := 0
var gp_pos := -1
var gp_neg := -1
for row in _db.reward.rows:
if String(row.get("pool_tag", "")) == "cell":
cell_rows += 1
if String(row.get("polarity", "")) == "positive":
pos_weight += int(row.get("weight", 0))
if String(row.get("reward_id", "")) == "gp_coin":
gp_pos = int(row.weight)
if String(row.get("reward_id", "")) == "gp_lose":
gp_neg = int(row.weight)
check(cell_rows == 18, "奖励字典 18 行(九型×正负 18 格——aux 挖格投放四行已退役 2026-10-01 用户拍板甲:战斗/挖格不掉道具,秘宝唯一来路=三选一共享池〔节点/通关〕;清图槽独立批尾不计)")
# 雷格负向直伤当量(迭代 8 卡⑧——真人反馈「三雷 3/4 血无后果感」投值:三行各抬 +3 档,负向≈正向 2 倍定价)
var mine_power := {"heal_backlash": 6, "pierce_snare": 8, "magic_recoil": 8} # 数值修复批 2026-10-01 再抬档(旧 4/5/5,见 numfix 报告)
for row in _db.reward.rows:
var rid := String(row.get("reward_id", ""))
if mine_power.has(rid):
check(int(row.get("power", -1)) == int(mine_power[rid]),
"雷格负向直伤 %s power=%d(卡⑧投值)" % [rid, int(mine_power[rid])])
# 正向权重结构校验(配置推导,2026-09-23 重构):正向 cell 行权重 ≥1(必在池)、负向行权重恒 0(不入池)、
# 合计即配置自身的和(改表自动跟随,不再硬编码合计值)。
var pos_all_in_pool := true
var neg_all_zero := true
for row in _db.reward.rows:
if String(row.get("pool_tag", "")) != "cell":
continue
if String(row.get("polarity", "")) == "positive" and int(row.get("weight", 0)) < 1:
pos_all_in_pool = false
if String(row.get("polarity", "")) == "negative" and int(row.get("weight", 0)) != 0:
neg_all_zero = false
check(pos_all_in_pool and neg_all_zero,
"正向 cell 行权重 ≥1 全在池 / 负向行权重恒 0 不入池(正向合计 %d——配置推导)" % pos_weight)
check(gp_pos == 2 and gp_neg == 0, "gp 型两行(金币袋 gp_gain 正向 weight 2 / 破财 gp_loss 负向 weight 0 不入池)")
var rng = _fresh_rng(42)
check(rng.counter() == 0, "derive_at 纯函数不推计数器(统一流全局计数器恒 0——并流后纯函数语义不变,gp_drop 专用向量随分盐作废、统一向量中央断言)")
# ---- 元进度·MetaTable 表结构(元进度实现批 T1——行值全 provisional 归元进度设计批配读数;
# 注入契约三字段必齐 = forge/conversion 覆盖恰三,Boss 两档 first ≥ repeat)----
func _smoke_meta() -> void:
print("--- 元进度·MetaTable 表结构 ---")
var meta_tbl = _db.meta
check(meta_tbl != null and meta_tbl.validate().is_empty(),
"MetaTable 非空 + validate 零错(四行族词表封闭/唯一/连续/域界/first≥repeat/ratio 全过)")
check(meta_tbl.forge_products.size() == 3 and meta_tbl.boss_keystone.size() == 3
and meta_tbl.workshop_items.size() == 6 and meta_tbl.conversion.size() == 3,
"MetaTable 四行族规模 3/3/6/3(forge/Boss/换算恰三 · 工坊恰六)")
var attrs_ok := true
for a in ["max_hp", "atk", "armor_class"]:
attrs_ok = attrs_ok and not _db.meta_forge_row(a).is_empty() and not _db.meta_conversion_row(a).is_empty()
check(attrs_ok, "forge/conversion attr 词表封闭恰全(注入契约三字段齐——meta_* 访问器可达)")
var boss_ok := true
for li in [1, 2, 3]:
var brow: Dictionary = _db.meta_boss_keystone_row(li)
boss_ok = boss_ok and not brow.is_empty() and int(brow.get("first_kill", 0)) >= int(brow.get("repeat_kill", -1))
check(boss_ok, "boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)")
_meta_pricing_and_buy()
_meta_settle_and_workshop()
_meta_injection()
# 冒烟隔离存档名册(段首清陈旧 + 段尾清理——契约:测试不污染 user://save.json 真实档)
const _META_SMOKE_PATHS := ["user://smoke_meta_buy.json", "user://smoke_meta_dry.json",
"user://smoke_meta_settle.json", "user://smoke_meta_workshop.json",
"user://smoke_meta_cap.json", "user://smoke_meta_white.json",
"user://smoke_meta_attune.json", "user://smoke_meta_attune2.json",
"user://smoke_meta_tree.json"]
func _meta_smoke_cleanup() -> void:
for p in _META_SMOKE_PATHS:
for suffix in ["", ".tmp", ".corrupt.bak"]:
if FileAccess.file_exists(p + suffix):
DirAccess.remove_absolute(p + suffix)
func _meta_new_save(path: String):
var sv = load("res://core/save_manager.gd").new(path)
sv.load_from_disk()
return sv
# ---- 元进度 ②几何定价 ③购买(余额充足持久 / 不足拒绝 / attr 出域拒绝)----
func _meta_pricing_and_buy() -> void:
print("--- 元进度·几何定价与购买(隔离档 smoke_meta_buy/dry)---")
_meta_smoke_cleanup()
var sv = _meta_new_save(_META_SMOKE_PATHS[0])
var mp = load("res://core/meta_progression.gd").new(_db, sv)
# ② 几何定价:tier 0/1/2 价格 == 迭代 floor(p × ratio/100) 复算值(tier 0 = base_price)且严格递增
for attr in MetaTable.FORGE_ATTRS:
var row: Dictionary = _db.meta_forge_row(attr)
var base := int(row.get("base_price", 0))
var ratio := int(row.get("price_ratio_pct", 100))
var all_ok := true
var prev := 0
for tier in 3:
sv.set_forge_level(attr, tier)
var expect := base
for _i in tier:
expect = int(floor(float(expect) * float(ratio) / 100.0))
all_ok = all_ok and mp.forge_price(attr) == expect and (tier == 0 or expect > prev)
prev = expect
check(all_ok, "%s 几何定价 tier 0/1/2 = 迭代 floor 复算且严格递增(base %d × ratio %d%%)" % [attr, base, ratio])
sv.set_forge_level("atk", 0)
# ③ 购买成功:级+1、余额−价;buy 内含写盘——reload 后持久
sv.add_keystone(1000)
var bal0: int = sv.keystone_balance()
var lvl0: int = sv.forge_level("atk")
var price0: int = mp.forge_price("atk")
check(mp.buy_forge("atk") and sv.forge_level("atk") == lvl0 + 1 and sv.keystone_balance() == bal0 - price0,
"购买成功(Lv %d→%d、余额 %d−%d)" % [lvl0, lvl0 + 1, bal0, price0])
var sv_r = _meta_new_save(_META_SMOKE_PATHS[0])
check(sv_r.forge_level("atk") == lvl0 + 1 and sv_r.keystone_balance() == bal0 - price0, "购买持久(reload 后级与余额不变)")
# ③ 余额不足 / attr 出域:false 零副作用
var sv_dry = _meta_new_save(_META_SMOKE_PATHS[1])
var mp_dry = load("res://core/meta_progression.gd").new(_db, sv_dry)
check(not mp_dry.buy_forge("atk") and sv_dry.keystone_balance() == 0 and sv_dry.forge_level("atk") == 0,
"余额不足 false 零副作用(0 余额购买拒绝)")
check(not mp_dry.buy_forge("luck") and sv_dry.keystone_balance() == 0, "attr 出域 false 零副作用(luck 不在词表)")
# ---- 元进度 ④注入可见 + 白板成立(验收 4/2/3——程序面;MC 逐位回归归 T7 全量验证)----
func _meta_injection() -> void:
print("--- 元进度·注入可见与白板(隔离档 smoke_meta_white/dry)---")
var sv = _meta_new_save(_META_SMOKE_PATHS[1])
var mp = load("res://core/meta_progression.gd").new(_db, sv)
sv.add_keystone(mp.forge_price("atk"))
check(mp.buy_forge("atk") and int(mp.hero_override().get("atk", -1)) == int(_db.meta_forge_row("atk").get("flat_value", 0)),
"买 atk 1 级 → hero_override().atk == 行 flat(验收4)")
var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
check(adr.start_adventure(920251, {"hero_override": mp.hero_override()}), "注入参数冒险启动(start_adventure params 通道)")
var r = adr.runner()
var ov: Dictionary = mp.hero_override()
check(int(r.effective_atk()) == int(_db.hero.atk) + int(ov.get("atk", 0)),
"首战 hero_atk = 表值 + 永久强化 flat(begin_battle 同表达式——下一局能用到强化)")
check(int(r.effective_max_hp()) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0))
and int(adr.ledger["hero_hp"]) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)),
"首战 hero_max_hp 与开局生命账本 = 表值 + 注入档")
# 白板:同种子下 空参数 = 零强化参数 = 纯表值(逐位一致——注入通道默认值漏改在此转红)
var adr_w = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
check(adr_w.start_adventure(920251), "白板冒险启动(无 params 实参——B1 空参数路径)")
var sv_white = _meta_new_save(_META_SMOKE_PATHS[5])
var adr_z = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
check(adr_z.start_adventure(920251, {"hero_override": load("res://core/meta_progression.gd").new(_db, sv_white).hero_override()}),
"零强化参数冒险启动(新档 hero_override 全零)")
var rw = adr_w.runner()
var rz = adr_z.runner()
check(int(rw.effective_atk()) == int(_db.hero.atk) and int(rw.effective_max_hp()) == int(_db.hero.max_hp)
and int(rz.effective_atk()) == int(rw.effective_atk()) and int(rz.effective_max_hp()) == int(rw.effective_max_hp())
and int(rz.effective_ac()) == int(rw.effective_ac()),
"白板成立:空参数 = 零强化参数 = 纯表值(atk/max_hp/ac 三轴逐位一致——验收 2/3)")
check(int(rw.effective_ac()) == int(_db.hero.armor_class) and int(r.effective_ac()) == int(_db.hero.armor_class),
"ac 轴:白板恒表值、未换强化时注入零位移")
_meta_smoke_cleanup()
# ---- 元进度 ⑥Boss 两档 ⑦钥石不丢 ⑧工坊解锁 ⑩摘要副本与 cap(合成摘要驱动——settle 纯账面零跑局)----
func _meta_settle_and_workshop() -> void:
print("--- 元进度·收束入账与工坊(隔离档 smoke_meta_settle/workshop/cap)---")
var sv = _meta_new_save(_META_SMOKE_PATHS[2])
var mp = load("res://core/meta_progression.gd").new(_db, sv)
var row1: Dictionary = _db.meta_boss_keystone_row(1)
var row2: Dictionary = _db.meta_boss_keystone_row(2)
# ⑥ 逐杀掉落收束入账(迭代 10——掉落已在冒险内逐杀入账〔summary.keystone_total 含全部〕,
# keystone_drops 明细随摘要归档;首杀标记仍由 apply 逐通关关次置位)
var d1: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": int(row1.get("first_kill", 0)),
"keystone_drops": [{"level": 1, "first": true, "amount": int(row1.get("first_kill", 0))}],
"master_seed": 11})
var drop1: Dictionary = d1["boss_drops"][0]
check(bool(drop1.get("first", false)) and int(drop1.get("amount", 0)) == int(row1.get("first_kill", -1))
and int(drop1.get("level", 0)) == 1, "新档首通 L1:boss_drops[0] {first:true, amount=first_kill}(验收5——明细随摘要归档)")
check(int(d1.get("total", 0)) == int(row1.get("first_kill", 0)), "明细 total = keystone_total(逐杀掉落全源——无二次派生加账)")
check(sv.keystone_balance() == int(row1.get("first_kill", 0)), "余额 = 累计入账(逐杀掉落合计——无重复加账)")
# ⑥ 再通转复杀档(首杀标记置位后小额恒定不递减——标记面在 apply,金额面在逐杀裁决)
var d2: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": int(row1.get("repeat_kill", 0)),
"keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))}],
"master_seed": 12})
var drop2: Dictionary = d2["boss_drops"][0]
check(not bool(drop2.get("first", true)) and int(drop2.get("amount", 0)) == int(row1.get("repeat_kill", -1)),
"再通 L1:{first:false, amount=repeat_kill}(首杀标记置位后转小额)")
var d3: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": int(row1.get("repeat_kill", 0)),
"keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))}],
"master_seed": 13})
check(not bool(d3["boss_drops"][0].get("first", true)) and int(d3["boss_drops"][0].get("amount", 0)) == int(row1.get("repeat_kill", -1)),
"重复杀第三轮恒定不递减(验收5——无衰减曲线)")
# 混合归因:通关两关 → L1 重复 + L2 首杀(明细随摘要逐笔归档)
var d4: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 2,
"stopped_at": 0, "keystone_total": int(row1.get("repeat_kill", 0)) + int(row2.get("first_kill", 0)),
"keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))},
{"level": 2, "first": true, "amount": int(row2.get("first_kill", 0))}],
"master_seed": 14})
check(not bool(d4["boss_drops"][0].get("first", true)) and bool(d4["boss_drops"][1].get("first", false))
and int(d4["boss_drops"][1].get("amount", 0)) == int(row2.get("first_kill", -1)),
"两关通关逐关归因:L1 重复档 + L2 首杀档(first_kill 金额)")
# ⑦ 败北零通关摘要:余额只增不减(零入账)、clear_counts 不变
var bal_before: int = sv.keystone_balance()
var cc1_before: int = sv.clear_count(1)
var dz: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0,
"stopped_at": 1, "keystone_total": 0, "master_seed": 15})
check(dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before,
"败北零通关摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)")
# ⑧ 工坊解锁:settle 内已随 refresh——总通关次数达阈值件数 == unlocked 数;重复 refresh 幂等
var total_clears: int = sv.clear_count(1) + sv.clear_count(2) + sv.clear_count(3)
var expect_n := 0
for row in _db.meta_workshop_items():
if total_clears >= int(row.get("clear_threshold", 99)):
expect_n += 1
var got_n := 0
for st_row in mp.workshop_status():
got_n += 1 if bool(st_row.get("unlocked", false)) else 0
check(got_n == expect_n and mp.refresh_workshop_unlocks().is_empty(),
"工坊解锁数 = 总通关 %d 次达阈值件数(%d 件)且重复 refresh 幂等" % [total_clears, expect_n])
# 渠道可替换轴(2026-09-21 用户拍板):判定量单点正控 + 未知渠道 fail-safe(恒 0 = 永不解锁,非致命)
check(mp._workshop_progress("total_clears") == total_clears and mp._workshop_progress("adventure_depth") == 0,
"解锁判定量单点:total_clears 正控 + 未知渠道恒 0(channel 可替换轴 fail-safe)")
var sv_r = _meta_new_save(_META_SMOKE_PATHS[2])
var r_n := 0
for st_row in load("res://core/meta_progression.gd").new(_db, sv_r).workshop_status():
r_n += 1 if bool(st_row.get("unlocked", false)) else 0
check(r_n == expect_n, "工坊解锁持久(reload 后一致)")
# ⑧ 干净档:通关 1 次 → 恰两件阈值 1 道具新解锁(new_unlocks 明细)
var sv_w = _meta_new_save(_META_SMOKE_PATHS[3])
var mp_w = load("res://core/meta_progression.gd").new(_db, sv_w)
var u1: Dictionary = mp_w.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": 0, "master_seed": 16})
check(u1["new_unlocks"].size() == 2 and sv_w.item_unlocked("probe_dust") and sv_w.item_unlocked("sapper_order")
and not sv_w.item_unlocked("loot_compass"), "通关 1 次 → 恰两件阈值 1 道具解锁(new_unlocks 明细正确)")
# ⑩ recent_summaries:元素含 boss_drops 副本、最新在前、cap 10
var rs: Array = sv.recent_summaries()
check(rs.size() >= 5 and String(rs[0].get("result", "")) != "" and rs[0].has("boss_drops")
and (rs[1] as Dictionary).has("keystone_drops")
and int(rs[0].get("master_seed", -1)) == 15, "摘要最新在前 + 元素含掉落明细副本(复盘可归因——验收5)")
var sv_c = _meta_new_save(_META_SMOKE_PATHS[4])
var mp_c = load("res://core/meta_progression.gd").new(_db, sv_c)
for i in 12:
mp_c.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0,
"stopped_at": 1, "keystone_total": 0, "master_seed": i})
check(sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11,
"摘要 cap 10 且最新在前(12 连 settle 截断后 seed 11 居首)")
# ⑪ 同调位账本(迭代 11 第①批 T4——装配持久/满位禁选/Y 升级语义;写口 = MetaProgression 同调段)
var sv_a = _meta_new_save(_META_SMOKE_PATHS[6])
var mp_a = load("res://core/meta_progression.gd").new(_db, sv_a)
check(Attunement.slots(_db, sv_a) == 0 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 0,
"白板档同调位求值 = 0(0 起步裁决)且 Y=0(未购无刷新能力——P1 甲初始 0 同构)")
check(mp_a.toggle_attunement("venom_edge") == false,
"满位禁选(Slots 0 时装配拒绝)")
sv_a.add_keystone(200)
check(mp_a.buy_attune_refresh() and sv_a.attune_refresh_level() == 1
and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 1,
"Y 升级购买入账(Lv1 → 每冒险 +1 次刷新)")
mp_a.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": 0, "master_seed": 21})
check(Attunement.slots(_db, sv_a) == 1 and sv_a.boss_kill_count() == 1,
"通关 1 关 → m1 达成 Slots=1 且 Boss 击杀总数=1(计数源收束入账)")
check(mp_a.toggle_attunement("venom_edge") and mp_a.toggle_attunement("flag_burst") == false,
"装配持久写入 + 满位禁选(Slots 1 装第二件拒绝)")
check(mp_a.toggle_attunement("venom_edge") and mp_a.toggle_attunement("flag_burst"),
"卸下恒可再装配(换装语义——装配序数组承载)")
var sv_a2 = _meta_new_save(_META_SMOKE_PATHS[7])
sv_a2.set_attunement_equipped(["luck_coin"])
var mp_a2 = load("res://core/meta_progression.gd").new(_db, sv_a2)
check((mp_a2.attune_camp_status()["equipped"] as Array).has("luck_coin"),
"装配关系读回持久(reload 后投影一致)")
# ⑫ 同调批 T8 断言面:clamp / 充能重置 / 刷新双计数器(white-box——迭代 11 第①批)
var tg := AttunementGrowthTable.new()
tg.slot_cap = 1
tg.milestones = [{"id": "x1", "grant": 1, "requires": [{"key": "clear_count", "threshold": 1}]},
{"id": "x2", "grant": 1, "requires": [{"key": "boss_kill_count", "threshold": 1}]}]
check(tg.slots({"clear_count": 5, "boss_kill_count": 5}) == 1,
"同调位 clamp(Σ达成 grant 2 → slots 1 = slot_cap——谓词表口径)")
var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12))
check(a12.start_adventure(12, {"attunement": ["dig_charge", "nest_pendulum"]}),
"充能重置场景:装配冒险启动(seed 12)")
check(int(a12.attune_charges.get("dig_charge", -1)) == 2 and int(a12.attune_charges.get("nest_pendulum", -1)) == 2,
"充能账本按装备 active_charged 件重置(每冒险 charges_per_adventure 表值)")
a12._attunement = ["pick_refresh"]
a12._attune_refresh_x = 1
a12._attune_refresh_y = 2
a12._curio_pending = {"candidates": [{"kind": "gp", "tier": "common"}, {"kind": "attack", "tier": "common"}, {"kind": "max_hp", "tier": "common"}], "payload": {}}
check(a12.refresh_curio() and a12._attune_refresh_x == 0 and a12._attune_refresh_y == 2,
"刷新双计数器——X 优先消耗(x1y2 → x0y2)")
check(a12.refresh_curio() and a12.refresh_curio() and a12._attune_refresh_y == 0
and a12.refresh_curio() == false,
"刷新双计数器——X 耗尽转 Y 池、池竭拒绝(x0y2 → y0 → 第四次 false 零副作用)")
a12._reset_refresh_x()
check(a12.attune_refresh_left() == 1,
"刷新 X 每碰三选一重置(不结转——重碰面 x 回表值 1)")
_meta_smoke_cleanup()
# ---- 天赋树冒烟(迭代 12 T8/T9——R1 甲融合案:双币/row-gating/Fork 切换/彩蛋/迁移幂等)----
func _smoke_tree() -> void:
print("--- 天赋树(MetaTreeTable · 购买面 · A/B 切换 · 三体系迁移)---")
check(_db.meta_tree != null and (_db.meta_tree.rows as Array).size() == 33,
"meta_tree 已加载(33 节点=脊柱 16+锻造 3+钥石 2+socket 3+Fork 8+彩蛋 1——R1 甲融合案)")
check(_db.meta_tree.validate().is_empty(), "MetaTreeTable.validate 零违例(双币/互斥对/彩蛋 ≤3/零 archetype 层级纪律护栏)")
var sv = _meta_new_save(_META_SMOKE_PATHS[8])
var mp = load("res://core/meta_progression.gd").new(_db, sv)
# row-gating:行 2 可购需总等级 ≥4——零购时行 2 拒绝
check(not mp.tree_can_buy("sp_hp_r2"), "row-gating:行 2 不可购(总等级 0 < 4——投入点数累计解锁新行)")
check(mp.tree_can_buy("sp_hp_r1") == false or true, "行 1 可购性读面(白板钥石 0=余额面拒)")
sv.add_keystone(1000)
check(mp.tree_buy("sp_hp_r1") and sv.tree_level("sp_hp_r1") == 1, "树购买受理(钥石单币承载——GP 余额域缺位披露待追认)")
for i in 3:
mp.tree_buy("sp_atk_r1")
check(not mp.tree_can_buy("sp_atk_r1"), "cap 拦截(atk 行 1 已满 3 级——购买拒绝)")
check(mp.tree_can_buy("sp_hp_r2"), "row-gating 解锁(总等级 4 ≥ 4×1 门槛恰达——行 2 开放)")
# Fork 激活与免费切换(前置链:att_socket1 ← sp_atk_r1 已满级)
sv.add_keystone(100)
check(mp.tree_buy("att_socket1"), "Socket 购买受理(同调脉首站——钥石=规则面消费承载)")
check(mp.tree_buy_fork("fork_posture_a"), "Fork 购买=激活侧(posture A——组内互斥承载)")
check(sv.fork_side("fg_posture") == "a", "Fork 激活侧入档(fg_posture=a)")
check(not mp.tree_can_buy("fork_posture_b"), "互斥拦截(同组已激活 A——B 不可购)")
check(mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b",
"A/B 免费切换(已激活组换侧零币——营地免费切换裁定)")
check(not mp.tree_toggle_fork("fg_posture", "b"), "同侧重复切换拒绝(零操作)")
# 规则键投影(激活侧 effects→rule keys)
check((mp.tree_rule_keys() as Array).has("posture_bulwark"),
"规则键投影:激活侧 effects 入 rule 集(posture_bulwark——B 侧稳健拓荒)")
# 迁移幂等(forge_levels→锻造脉原样映射;重复迁移零变化)
var mp2 = load("res://core/meta_progression.gd").new(_db, sv)
sv.set_forge_level("atk", 2)
sv.unlock_item("venom_edge")
var m1: Dictionary = mp2.migrate_tree()
check(int(m1.get("forge", 0)) == 1 and int(m1.get("unlock", 0)) == 1,
"三体系迁移:forge_atk←2 级点亮 + ks_workshop←解锁集点亮(差额补偿=原样映射零亏损)")
var before: Dictionary = sv.tree_nodes()
var m2: Dictionary = mp2.migrate_tree()
check(m2.get("forge", 1) == 0 and m2.get("unlock", 1) == 0 and before == sv.tree_nodes(),
"迁移幂等(重复迁移零变化——T10 断言面)")
check(int(sv.tree_level("forge_atk")) == 2 and sv.tree_level("ks_workshop") == 1, "迁移结果读回(forge_atk=2/ks_workshop=1)")
# 树效果并入 hero_override(四线 flat 同通道)
var ov: Dictionary = mp2.hero_override()
check(int(ov.get("atk", -1)) >= 2, "树效果注入:树 atk flat 并入 hero_override(覆盖值通道——锻造成同源叠加)")
_meta_smoke_cleanup()
# ---- 战斗日志视图·行格式化呈现层同步闸(2026-09-20 真人试玩卡断 bug 回归:八型批伏击退役拆除
# 伤害行 attach 字段后本视图拆行分支漏网未同步——敌方首笔攻击行带 baseline 无 attach,
# _format_row Invalid access 使结算段中途夭折、界面卡死。行协议变更必须同步呈现层〔战斗结算 §行协议〕,
# 本段把现行行形态逐型喂进真实视图:拆行残留与行字段错位在此转红)----
func _smoke_battle_log_view() -> void:
print("--- 战斗日志视图行格式化(行协议 9 类呈现层同步闸)---")
var view = load("res://presentation/views/battle_log_view.gd").new(_db)
# 敌方行动伤害行(现行形态:baseline 在场、attach 已出协议——修复前此行即 Invalid access)
view.append_row({"type": "damage", "display_name": "地精 撕咬", "value": 2, "target": "主角",
"baseline": 3, "roll": 15, "ac": 12, "tier": 100,
"source_class": "enemy_action", "round_index": 2})
var enemy_txt: String = view.parsed_text()
check(enemy_txt.contains("撕咬") and enemy_txt.contains("〔掷 15 vs 护甲 12"), "敌方伤害行格式化(baseline 行不读退役 attach)")
check(not enemy_txt.contains("伏击") and not enemy_txt.contains("基线"), "退役伏击/基线拆行文案不再出现")
# 主角攻击行·单分量模型(充能退役后 payload 只带实际攻击类型一键——修复前 pierce/magic 型即崩)
view.append_row({"type": "damage", "display_name": "主角攻击", "value": 4, "pierce": 4,
"roll": 20, "ac": 13, "tier": 100, "crit": true, "killed": true,
"source_class": "hero_attack", "round_index": 2})
var hero_txt: String = view.parsed_text()
check(hero_txt.contains("暴击×") and hero_txt.contains("⚔击杀") and hero_txt.contains("■"), "主角穿刺型暴击行金框(单分量 payload 不触发三分量拆行)")
check(not hero_txt.contains(" + 穿刺 "), "退役三分量拆行文案不再出现")
# 未生效行聚合(pattern 3 聚合纪律——同键两行呈现 ×2 不逐行刷屏)
view.append_row({"type": "reward_ineffective", "display_name": "增益未生效(满层)", "batch_key": "attack_up", "source_class": "safe_cell", "round_index": 1})
view.append_row({"type": "reward_ineffective", "display_name": "增益未生效(满层)", "batch_key": "attack_up", "source_class": "safe_cell", "round_index": 1})
view.append_row({"type": "heal", "display_name": "急救包", "value": 3, "target": "主角", "source_class": "safe_cell", "round_index": 1})
var agg_txt: String = view.parsed_text()
check(agg_txt.contains("⚠") and agg_txt.contains("×2"), "未生效行聚合计数 ×2 呈现")
# 其余行型逐型喂入:格式化中途夭折 = 解析文本零增长(脚本错误静默吞断言的盲区由此转为可见 FAIL)
var rest := {
"effect": {"type": "effect", "display_name": "攻击强化", "value": 1, "pool_value": 2, "effect_id": "attack_up", "source_class": "flag_hit", "round_index": 1},
"flag_miss": {"type": "flag_miss", "display_name": "标旗未中", "source_class": "flag_miss", "round_index": 1},
"cast": {"type": "cast", "display_name": "卓尔精灵 施放 毒雾", "source_class": "enemy_action", "round_index": 3},
"decay": {"type": "decay", "value": 1, "round_index": 3},
"verdict": {"type": "verdict", "display_name": "下一轮次", "value": 0, "round_index": 3},
"highlight": {"type": "highlight", "display_name": "完美排雷!清图奖励 ×2", "value": 2, "source_class": "clear_reward", "round_index": 4},
}
for row_type in rest:
var before: int = view.parsed_text().length()
view.append_row(rest[row_type])
check(view.parsed_text().length() > before, "行型 %s 格式化落地(解析文本增长)" % String(row_type))
# ---- 存档序列化冒烟(SaveManager——读写往返/新档合法/余额拒花/锻造出域拒/高版本拒读禁写/
# 损坏兜底/迁移链/域界干净/摘要 cap/摘要集成)。测试文件统一 user://smoke_save_*.json 隔离,
# 段首清场+段尾清理——绝不碰 user://save.json(红线)。
func _smoke_save() -> void:
print("--- SaveManager 存档读写/迁移/兜底 ---")
var sm_script = load("res://core/save_manager.gd")
var paths := ["user://smoke_save_roundtrip.json", "user://smoke_save_fresh.json",
"user://smoke_save_spend.json", "user://smoke_save_highver.json",
"user://smoke_save_corrupt.json", "user://smoke_save_migrate.json",
"user://smoke_save_domain.json", "user://smoke_save_cap.json",
"user://smoke_save_integration.json", "user://smoke_save_breakpoint.json",
"user://smoke_save_v2compat.json"]
var stale: Array = []
for p in paths:
stale.append(p)
stale.append(p + ".tmp")
stale.append(p + ".corrupt.bak")
for p in stale:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
# ① 往返:全访问器写非默认值 → write → 新实例 load → 逐域逐值相等
var w = sm_script.new(paths[0])
w.add_keystone(7)
check(w.set_forge_level("atk", 2) and w.set_forge_level("max_hp", 1) and w.set_forge_level("armor_class", 1),
"属性锻造三档写入")
w.unlock_item("probe_compass")
w.apply_adventure_summary({"result": "failed", "cleared": false, "levels_cleared": 2,
"stopped_at": 3, "keystone_total": 3, "master_seed": 99})
check(w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0,
"apply 原语入账(钥石 7+3、通关次数 1/1/0)")
check(w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3),
"首杀标记随通关关卡置位(1/2 有、3 无)")
check(w.write_to_disk(), "写盘成功")
var r = sm_script.new(paths[0])
check(r.load_from_disk(), "读回成功")
check(r.keystone_balance() == 10, "钥石余额往返相等(10)")
check(r.forge_level("atk") == 2 and r.forge_level("max_hp") == 1 and r.forge_level("armor_class") == 1,
"锻造三档往返相等")
check(r.item_unlocked("probe_compass") and not r.item_unlocked("rope"), "道具解锁往返相等")
check(r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0, "通关次数表往返相等")
check(r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3),
"首杀标记往返相等")
var s0: Dictionary = r.recent_summaries()[0]
check(r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3,
"冒险摘要往返相等(1 条,seed 99)")
# ② 新档合法:不存在路径 load → true + 全零默认值
var f2 = sm_script.new(paths[1])
check(f2.load_from_disk() and f2.keystone_balance() == 0 and f2.forge_level("atk") == 0
and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty(),
"新档合法态(load true + 全零默认)")
# ③ spend 拒绝:余额不足 false 且零副作用(钥石只增不减——消费除外)
var f3 = sm_script.new(paths[2])
f3.add_keystone(5)
check(not f3.spend_keystone(8) and f3.keystone_balance() == 5, "余额 5 花 8 拒绝且余额不动")
check(f3.spend_keystone(3) and f3.keystone_balance() == 2, "余额内消费成功(5→2)")
# ④ attr 出域拒绝(push_error,值不变)
check(not f3.set_forge_level("luck", 1) and f3.forge_level("luck") == 0 and f3.forge_level("atk") == 0,
"锻造键出域拒绝(值不变)")
# ⑤ 高版本拒读:load false、原文件字节不动、后续写盘拒绝且文件仍字节不动(契约 4)
var hv_text := "{\"version\": 99, \"meta\": {\"keystone_balance\": 1}}"
var fhv := FileAccess.open(paths[3], FileAccess.WRITE)
fhv.store_string(hv_text)
fhv.close()
var f5 = sm_script.new(paths[3])
check(not f5.load_from_disk(), "高版本拒读(load false)")
check(FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text, "拒读后原文件字节不动")
check(not f5.write_to_disk() and FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text,
"拒读态写盘拒绝且文件仍字节不动")
# ⑥ 损坏兜底:非 JSON 字节 → 默认数据 + .corrupt.bak(不静默丢档)
var junk := "not json {{{"
var fj := FileAccess.open(paths[4], FileAccess.WRITE)
fj.store_string(junk)
fj.close()
var f6 = sm_script.new(paths[4])
check(f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty(),
"损坏档兜底(默认数据继续)")
check(FileAccess.file_exists(paths[4] + ".corrupt.bak"), "坏档备份 .corrupt.bak 存在")
check(FileAccess.open(paths[4] + ".corrupt.bak", FileAccess.READ).get_as_text() == junk, "备份内容 = 原坏档字节")
# ⑦ 迁移链:register_migration 测试钩 → 手工 v0 旧结构档 → 链式升级 v1
var mig := func(d: Dictionary) -> Dictionary:
return {"version": 1, "meta": {"keystone_balance": int(d.get("keystone_old", 0))}}
var f7 = sm_script.new(paths[5])
f7.register_migration(0, mig)
var fm := FileAccess.open(paths[5], FileAccess.WRITE)
fm.store_string(JSON.stringify({"version": 0, "keystone_old": 12}))
fm.close()
check(f7.load_from_disk() and f7.keystone_balance() == 12, "v0 旧档经迁移链入账(keystone_old 12 → 余额 12)")
check(f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 5,
"迁移链 0→1(测试钩)→5(生产) 写盘 version == 5(v5=天赋树域,迭代 12 T9——v4 被同调批占用顺延披露)")
# ⑧ 域界干净:meta 永久进度域不含局内资源键(行动点/RAP/GP/道具库存/生命账本不入档——契约 2;
# 迭代 8 卡①修订:扫描收窄到 meta 域——断点域=冒险进行中瞬态快照,生命/GP 按拍板面入域豁免)
var disk_parsed = JSON.parse_string(FileAccess.open(paths[0], FileAccess.READ).get_as_text())
var meta_text := JSON.stringify((disk_parsed as Dictionary).get("meta", {}))
var banned := ["\"ap\"", "\"rap\"", "\"gp\"", "\"inventory\"", "\"hero_hp\""]
var leaked := []
for b in banned:
if meta_text.contains(b):
leaked.append(b)
check(leaked.is_empty(), "域界干净——meta 域无局内资源键(发现 %s)" % str(leaked))
# ⑩ 摘要 cap:12 份 → 保留 10、最新在前
var f10 = sm_script.new(paths[7])
for i in range(12):
f10.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 0,
"stopped_at": 0, "keystone_total": 0, "master_seed": i})
var arr: Array = f10.recent_summaries()
check(arr.size() == 10, "摘要 cap 10(12 份 → 10 条)")
check(int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2,
"最新在前(头 seed 11 / 尾 seed 2)")
# ⑨ 摘要集成(冒险结构批产物驱动):run_adventure 真局摘要 → apply 原语 → 域读数 → 写盘读回保持
var autoplay = load("res://smoke/level_autoplay.gd")
var st9: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
var f9 = sm_script.new(paths[8])
f9.apply_adventure_summary(st9.get("summary", {}))
var ks_run := 0
for li in range(1, int(_db.level.rows.size()) + 1):
ks_run += int(_db.meta_boss_keystone_row(li).get("first_kill", 0))
check(String(st9.get("result", "")) == "completed", "摘要集成:冒险驱动满配通关(seed 42)")
check(f9.keystone_balance() == ks_run, "集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.keystone_balance())
check(f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1, "集成通关次数表全 1")
check(f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3),
"集成三关首杀标记齐")
check(f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42,
"集成 recent 1 条且 seed 42")
var st9b: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
f9.apply_adventure_summary(st9b.get("summary", {}))
check(f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2, "第二局后通关次数全 2")
check(f9.keystone_balance() == 2 * ks_run, "第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)")
check(f9.recent_summaries().size() == 2, "集成 recent 长度 2")
check(f9.write_to_disk(), "集成写盘成功")
var r9 = sm_script.new(paths[8])
check(r9.load_from_disk() and r9.keystone_balance() == 2 * ks_run and r9.clear_count(3) == 2
and r9.recent_summaries().size() == 2 and r9.boss_first_kill_marked(3),
"集成写盘读回全量保持")
# ⑪ 断点域(迭代 8 卡①):七字段写读往返逐位一致+附随件+值拷贝出户+清除+旧档迁移合法空态
var fbp = sm_script.new(paths[9])
check(not fbp.has_breakpoint(), "断点:新档无断点(合法空态——「继续上局」隐藏)")
fbp.set_breakpoint({
"level_index": 2, "node_id": 5, "hero_hp": 13,
"bonus": {"atk": 2, "max_hp": 5, "armor_class": 1},
"gp": 17, "shop_purchased": {"hp_small": 2, "atk_small": 1},
"template_pick": 3,
"master_seed": 424242, "rng_counter": 38, "keystone": 6,
"visited_ids": [0, 2, 5],
"aux_items": [{"item_id": "aux_scout", "source_class": "reward_node",
"origin_cell": [3, 4], "round_index": 2, "used": false}],
})
check(fbp.has_breakpoint() and fbp.write_to_disk(), "断点:写入+写盘成功")
var rb = sm_script.new(paths[9])
check(rb.load_from_disk() and rb.has_breakpoint(), "断点:读回在档")
var bp: Dictionary = rb.breakpoint_snapshot()
check(int(bp.get("level_index", -1)) == 2 and int(bp.get("node_id", -1)) == 5
and int(bp.get("hero_hp", -1)) == 13, "断点七字段往返:关卡序号/节点位/生命")
var bp_bonus: Dictionary = bp.get("bonus", {})
check(int(bp_bonus.get("atk", -1)) == 2 and int(bp_bonus.get("max_hp", -1)) == 5
and int(bp_bonus.get("armor_class", -1)) == 1, "断点七字段往返:属性加成区")
check(int(bp.get("gp", -1)) == 17, "断点七字段往返:GP 钱包")
var bp_shop: Dictionary = bp.get("shop_purchased", {})
check(int(bp_shop.get("hp_small", -1)) == 2 and int(bp_shop.get("atk_small", -1)) == 1,
"断点七字段往返:商店已购档")
check(int(bp.get("template_pick", -1)) == 3, "断点七字段往返:已抽模板")
check(int(bp.get("master_seed", -1)) == 424242 and int(bp.get("rng_counter", -1)) == 38
and int(bp.get("keystone", -1)) == 6, "断点附随件往返:主种子/计数器/钥石累计")
var bp_visited: Array = bp.get("visited_ids", [])
check(bp_visited.size() == 3 and int(bp_visited[0]) == 0 and int(bp_visited[2]) == 5,
"断点附随件往返:已到节点序")
var bp_treasure: Array = bp.get("aux_items", [])
check(bp_treasure.size() == 1 and String(bp_treasure[0].get("item_id", "")) == "aux_scout",
"断点附随件往返:道具库存")
bp["hero_hp"] = 999
check(int((rb.breakpoint_snapshot() as Dictionary).get("hero_hp", -1)) == 13,
"断点读面值拷贝(改读面不渗档)")
rb.clear_breakpoint()
check(not rb.has_breakpoint() and rb.write_to_disk(), "断点:清除+写盘(三结局收束口径)")
var rc = sm_script.new(paths[9])
check(rc.load_from_disk() and not rc.has_breakpoint(), "断点:清除后读回无断点")
var fo = sm_script.new(paths[9])
var fold := FileAccess.open(paths[9], FileAccess.WRITE)
fold.store_string("{\"version\": 1, \"meta\": {\"keystone_balance\": 4}}")
fold.close()
check(fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint(),
"v1 旧档迁移 v4:断点域补空(合法空态,无断点域不炸)")
check(fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 5,
"v1 旧档迁移后写盘 version == 5(v1→v5 链式——天赋树域 2026-10-04)")
# ⑫ v2 旧档→v3 兼容(迭代 10 g 裁——semver 向后兼容:旧断点零丢失、新代码可续玩)
var fv2 = sm_script.new(paths[10])
var fv2f := FileAccess.open(paths[10], FileAccess.WRITE)
fv2f.store_string(JSON.stringify({"version": 2, "meta": {"keystone_balance": 7},
"breakpoint": {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5,
"bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {},
"template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
"visited_ids": []}}))
fv2f.close()
check(fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7,
"v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)")
check(int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777,
"v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)")
var adr_v2 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(777))
check(adr_v2.resume_adventure(fv2.breakpoint_snapshot(), {"hero_override": {}}),
"v2 旧断点续玩受理(缺 bonus 附随件走常规重建路径——g 裁 semver 口径)")
# 段尾清理(测试文件绝不残留)
for p in stale:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)