test(批B): 断言层级重构大提交——1065 条断言自 smoke_all.gd 全量迁入 16 标准宿主(冒烟/单元/集成/系统+UI 探针);smoke_all.gd 整体退役;变异引擎按用户令删除、验证改核代码(三冷启动审核员 1065 全审:1047 有效,17 存疑+2 段落级,其中 2 段落级经今日闸记录实证解决);10 条核代码修复(恒真/名实/静默跳过/键名等——grilling 一轮修订);验证集清单回写 1068 条三对齐零差异;底册去向补正与笔误清账

This commit is contained in:
DKChan committed 2026-10-23 20:48:20 +08:00
1 parent 8fbd2dd875
commit 957391e13b
29 files changed
+6759 -8446

No files matched your search

@@ -0,0 +1,820 @@
# tests/unit/core/adventure_feats_test.gd — 冒险结构与专长单元测试宿主
# 断言面 = 三套(批 B 断言迁移自 smoke_all.gd,G7 施工组):
# 冒险结构 API 闸(状态机受理面——重复开始拒绝/入口停点/放弃两态)
# 冒险验收断言(三关序列/大奖励三层兑付/账本封闭/败北线/恢复数学/加算区同账/共享池)
# 专长重设计实装(feat_table 四级/案D 池接线/V3 订阅面逐件三列/专长凭证保底)
# 注:断点往返三条(基点 1420–1426)按主会话裁决②改判集成宿主,不在本宿主断言面内。
# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload,
# ConfigDB 显式实例化构造注入(_fresh_rng 同种子隔离夹具——RngService 构造注入 runner);
# 表加载失败不中止(沿被迁断言面的源语义——承 G2–G6 同款)。
# 运行:godot --headless --path game --script res://tests/unit/core/adventure_feats_test.gd
extends SceneTree
const Kit := preload("res://tests/unit/test_kit.gd")
const ConfigDBScript := preload("res://core/config_db.gd")
var _db
func _init() -> void:
_db = ConfigDBScript.new()
# 表加载失败不中止(承 G2/G3/G4/G5 同款:表损坏的红落在具体断言上——
# 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评)
var load_ok: bool = _db.load_tables("res://data/tables", true)
if not load_ok:
push_error("[adventure_feats_test] 表加载返回失败——诊断如下(红由具体断言落)")
for d in _db.diagnostics():
if d.severity == "error" or d.severity == "fatal":
push_error(" [%s] %s: %s" % [d.severity, d.category, d.message])
# 套执行序沿源编排(基点 _run_core_smoke 调用序——本组套的相对序)
_smoke_adventure_api()
_smoke_adventure()
_smoke_feats()
quit(Kit.finish("adventure_feats_test"))
# ---- 冒险结构冒烟(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 {}
# ② 冒险结构 API 闸(状态机受理面——重复开始拒绝 / 入口停点 / 放弃两态)
# ② 冒险结构 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) # 首战速胜档(放弃的非战斗受理要走到选路态)
# 入口停点观测(信号序 + 入口候选快照——容器写回规避 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))
Kit.check("冒险启动受理(主种子注入恰一次——state=running;first_script=固定模板 0——精确路径断言需要现行图,零抽签)", adr.start_adventure(42, {"first_script": true}))
Kit.check("进行中重复 start_adventure 拒绝(重开须待结算完毕)", not adr.start_adventure(43))
Kit.check("停点前移次序契约:level_started 先于入口候选呈现(反序选路口会被呈现层 runner 空防御丢弃)", sig_seq.size() == 2 and String(sig_seq[0]) == "level_started" and String(sig_seq[1]) == "choices_presented")
Kit.check("入口停点:选路态 + 入口单候选 + 首战未开(呈现零 RNG 消耗——pick 前零战斗域)",
adr.runner().awaiting_choice() and (obs["entry_cands"] as Array).size() == 1 \
and adr.runner().current_battle() == null)
Kit.check("pick 入口候选受理(同一 _enter_node——与旧自动进入逐位等价)", adr.runner().pick_next_node(int(adr.runner().next_candidate_ids()[0])))
Kit.check("pick 后 node_entered 到达 + 首战在飞(接口①开始关卡——停点闭合直达战斗节点)", sig_seq.size() == 3 and String(sig_seq[2]) == "node_entered" and adr.runner().current_battle() != null)
Kit.check("战斗中 abandon 拒绝(战斗域单事务在飞)", not adr.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)
Kit.check("首战胜后选路态(非战斗期——current_battle 置空)", adr.runner().current_battle() == null and adr.runner().awaiting_choice())
Kit.check("非战斗期 abandon 受理(选路态——同失败型结算)", adr.abandon())
var sum: Dictionary = adr.summary
Kit.check("放弃结算:result=abandoned · cleared=false(已获面不追回——钥石累计 %d)" % int(sum.get("keystone_total", -1)), String(sum.get("result", "")) == "abandoned" and not bool(sum.get("cleared", true)))
Kit.check("结算后重复 abandon 拒绝(结算受理只算一次——state=idle)", not adr.abandon())
# 观测桩(信号序采集——bind 容器写回,签名按各信号实参定制)
# 观测桩(信号序采集——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 跨关延续/加算区同账/恢复数学/放弃)
# ③ 冒险验收断言(映射 冒险结构.md 验收标准——表覆盖/满配通关/种子复现/大奖励/败北线/
# 重复拒绝/GP 跨关延续/加算区同账/恢复数学/放弃)
func _smoke_adventure() -> void:
print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---")
var autoplay = load("res://smoke/level_autoplay.gd")
# ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行集——覆盖义务断言;
# enemy 侧键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域,
# 同带多行合法,带键聚合天然兼容〔fv24〕)
Kit.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_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = 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_%s_%d" % [int(row.level_index), nt, int(n.get("node_depth", -1))]
if not bg_keys.has("%d_%d" % [int(row.level_index), int(n.get("node_depth", -1))]) or not en_keys.has(key):
miss += 1
Kit.check("行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行集(缺失 %d——带键 (L,row_kind,depth_band))" % miss, miss == 0)
# ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复)
var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
var sum: Dictionary = st["summary"]
Kit.check("满配通关(42 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)", String(st["result"]) == "completed" and bool(sum.get("cleared", false)))
Kit.check("通关几关 = 3(levels_cleared)", int(sum.get("levels_cleared", -1)) == 3)
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))
Kit.check("钥石合计 = ΣBoss 首杀行值(%d——逐杀掉落新口径;断点未注入恒首杀)" % int(sum.get("keystone_total", -1)), int(sum.get("keystone_total", -1)) == ks_exp)
var drops: Array = sum.get("keystone_drops", []) as Array
Kit.check("逐杀掉落明细恰 3 笔(L1/L2/L3——additive 摘要键 keystone_drops)", drops.size() == 3)
Kit.check("摘要携带主种子(42)", int(sum.get("master_seed", -1)) == 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"])
Kit.check("通关三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)", picks.size() == 2 and resolves.size() == 2 + node_picks)
Kit.check("奖励节点三选一 ≥1 次(L1 链必经 1 个;L2/L3 图可选路经若干——实际经停 %d)" % node_picks, node_picks >= 1)
var node_resolves := 0
for r0 in resolves:
if String((r0 as Dictionary).get("context", "")) == "reward_node":
node_resolves += 1
Kit.check("奖励节点兑付回执与经停逐笔相等(context=reward_node——同账计数 %d/%d)" % [node_resolves, node_picks], 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|feat_id(TP0 v2.2 relic 按 feat_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("feat_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))
# 账本生效上限口径:置满/回血封顶按生效上限(flat 后乘调试浮窗 pct)——本段 pct=400
# 加算区 = 树/锻造件(hp_acc)+购血观测值(level_autoplay 停点快照 bonus_max_hp)——精确断言不再窗口拟合
var extra_obs := int(p.get("bonus_max_hp", -1))
var m_obs := int(round(float(int(_db.hero.max_hp) + hp_acc + extra_obs) * 5.0))
var heal_exp := mini(int(floor(float(m_obs) * float(heal_pct) / 100.0)), m_obs - hp_before)
if extra_obs < 0 or heal != heal_exp or hp_after != hp_before + heal or hp_after > m_obs:
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))
Kit.check("三选一候选 2–3 枚且零重复(令牌洗牌不放回——kind+tier 复合身份)", cand_ok)
Kit.check("通关部分回血封顶(floor(观测上限×比例) 封顶——购血观测值精确断言,核代码增补)", heal_ok)
Kit.check("钥石逐杀入账(首杀档位 + 累计逐位)", ks_gain_ok)
Kit.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 件 + 购血余量;
# 大生命 +10(优秀档)——上界按大件最坏构成 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", "excellent")) + 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
Kit.check("各战上限入参 ∈ [基础, 基础+3×件值+购血余量](覆盖值通道——双渠道共享上限 3;模拟档贪心购血合法叠加〔数值修复批 v3.4〕;首战=基础 %d)" % exp_base, seq_ok)
# ③ 种子域可复现(同种子两局:summary + 上限序列 + 行进序列 + 三选一停点载荷逐位一致)
var st2: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
Kit.check("同种子两局逐位可复现(summary/上限序列/行进序列/三选一载荷——reward_pick 封闭流)",
st2["summary"] == sum and st2["encounter_max_hp_seq"] == seq
and st2["visited_ids"] == st["visited_ids"] and st2["reward_events"] == st["reward_events"])
# ⑤ 败北线(零配 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
Kit.check("零配 L1 败北种子可达(seed=%s)" % str(lose.get("s", -1)), not lose.is_empty())
if not lose.is_empty():
var sl: Dictionary = lose["st"]["summary"]
Kit.check("L1 败北摘要:未打穿 · 通关 0 关 · 钥石 0 · 止步第 1 层",
not bool(sl["cleared"]) and int(sl["levels_cleared"]) == 0 \
and int(sl["keystone_total"]) == 0 and int(sl["stopped_at"]) == 1)
var dl: Dictionary = sl.get("defeat", {})
Kit.check("L1 败北败退明细:层号/节点编号/节点类型在案(数值验证取数面 2026-09-22)",
int(dl.get("level_index", -1)) == 1 and int(dl.get("node_id", -1)) >= 0 \
and String(dl.get("node_type", "")) in ["battle", "boss"])
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
Kit.check("半配(atk+50)L2 败北种子可达(seed=%s)" % str(half.get("s", -1)), not half.is_empty())
if not half.is_empty():
var sh: Dictionary = half["st"]["summary"]
Kit.check("L2 败北摘要:L1 已过(钥石=L1 首杀掉落)· 止步第 2 层",
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)
var dh: Dictionary = sh.get("defeat", {})
Kit.check("L2 败北败退明细:层号=2 · 节点在案(数值验证取数面 2026-09-22)",
int(dh.get("level_index", -1)) == 2 and int(dh.get("node_id", -1)) >= 0 \
and String(dh.get("node_type", "")) in ["battle", "boss"])
# ⑥ 重复宣布拒绝(settle 后再调 _on_level_finished——摘要零变化)
var adr6 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(7))
adr6.set_hero_override(900, 400)
Kit.check("重复拒绝场景:冒险启动(seed 7——固定模板 0 同上)", adr6.start_adventure(7, {"first_script": true}))
adr6.runner().pick_next_node(int(adr6.runner().next_candidate_ids()[0])) # 入口停点 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)
Kit.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"})
Kit.check("重复关卡结束宣布拒绝(账本与摘要零变化——契约 7)", adr6.summary == sum6_before)
# ⑦ 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 标记——不计战斗口径)
Kit.check("每战完成入账恰一次(gain %d = battles %d——GP 袋件入账单列)" % [gains, int(st["battles"])], gains == int(st["battles"]))
Kit.check("钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)", clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear")
var shop_seq: Array = st["shop_gp_seq"]
Kit.check("L2 商店房到达余额 > 0(%d——L1 积累跨关延续;两关商店房必经各 1)" % (shop_seq[0] if shop_seq.size() > 0 else -1), shop_seq.size() == 2 and int(shop_seq[0]) > 0)
# ⑧ 加算区同账(手工场景: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
Kit.check("同账场景种子可达(seed=%s——L2 商店房就位)" % str(same.get("s", -1)), not same.is_empty())
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))
Kit.check("进店余额 ≥ hp_up L2 首价(%d = 基价 %d + 关卡加成 %d——配置推导)" % [hp_price + l2_price_bonus, hp_price, l2_price_bonus], gp0 >= hp_price + l2_price_bonus)
Kit.check("购买 hp_up 受理(出账 + 加算区)", r8.buy_product("hp_up"))
var acc_shop := int(a8.run_ledger.bonus.get("max_hp", -1)) # L1 三选一后 + 商店 3(首件随种子)
Kit.check("统一冒险加算区含商店值(max_hp=%d ≥ 商店 3——L1 三选一件值另计)" % acc_shop, acc_shop >= 3)
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 载荷直调——非末关)
Kit.check("L2 通关三选一停点在案(直调兑付前——零自动推进)", not a8._curio_pending.is_empty())
Kit.check("未兑付推进拒绝(选择停点零自动推进契约)", not a8.continue_to_next_level())
var l2_cands: Array = a8._curio_pending["candidates"]
var l2_pick: Dictionary = l2_cands[0]
Kit.check("三选一点选兑付受理(候选取首件)", a8.pick_curio(String(l2_pick.get("kind", "")), String(l2_pick.get("tier", "")), String(l2_pick.get("feat_id", ""))))
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)
Kit.check("三选一属性件与商店购买同账累加(max_hp=%d = %d + 三选一%s)" % [int(a8.run_ledger.bonus.get("max_hp", -1)), acc_shop, picked2], int(a8.run_ledger.bonus.get("max_hp", -1)) == expect_acc)
var max_eff8: int = a8.effective_max_hp() # 账本生效上限含调试浮窗 pct(a8 已 set 400)
Kit.check("通关部分回血封顶(hp %d ≤ 生效上限 %d——血回满语义退役)" % [int(a8.ledger["hero_hp"]), max_eff8], int(a8.ledger["hero_hp"]) <= max_eff8)
Kit.check("兑付后推进受理(下一关开始)", a8.continue_to_next_level())
# ⑨ 恢复点数学(接口③封顶公式三读数 + 同式两落点一致)
var a9 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(99))
Kit.check("恢复点数学:floor(100×30%)=30 · 封顶缺口 5 · 满血回 0 合法",
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)
var lr9 = load("res://core/level_runner.gd").new(_db, _fresh_rng(99))
Kit.check("同一公式两个落点逐位一致(LevelRunner.rest_heal_amount 内置默认)", int(lr9.rest_heal_amount(30, 70, 100)) == 30 and int(lr9.rest_heal_amount(30, 95, 100)) == 5)
# ⑩ 放弃(战斗中拒绝 · 非战斗期受理同失败型结算)
var a10 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(15))
a10.set_hero_override(900, 400)
Kit.check("放弃场景:冒险启动(seed 15——固定模板 0 同上)", a10.start_adventure(15, {"first_script": true}))
Kit.check("放弃场景:入口停点 pick 进首战(停点前移批 2026-09-23 驱动口径)", a10.runner().pick_next_node(int(a10.runner().next_candidate_ids()[0])))
Kit.check("战斗中 abandon 拒绝(战斗域单事务在飞)", not a10.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)
Kit.check("非战斗期(选路态)abandon 受理", a10.runner().current_battle() == null and a10.abandon())
# ⑪ 池耗即枯竭 + 池收缩 + 满血回 0(TP0——white-box:耗尽 attack/ap 令牌后候选零出现;
# 无种类次数上限,「达上限剔除」语义由牌库耗尽承载)
var a11 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(11))
Kit.check("剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)", a11.start_adventure(11, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}))
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
Kit.check("池耗即枯竭(attack/ap 令牌耗尽不出候选——%s)" % str(draw), not draw_kinds.has("attack") and not draw_kinds.has("ap"))
Kit.check("池耗后候选恒 ≥2(稀有/罕见档余量——防「三选项全不可选」死角)", (draw as Array).size() >= 2)
# 池收缩(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
Kit.check("池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)", not treasure_seen)
# 满血回 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"})
Kit.check("满血通关回血 0 合法(封顶口径——heal_gained=0 非负溢出)", int(cap["payload"].get("heal_gained", -1)) == 0)
# ⑬ 奖励节点共享池(同库同池同摘牌;TP0:attack 令牌耗尽后节点域同样零出现)
var a13 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(13))
Kit.check("节点共享池场景:冒险启动(seed 13)", a13.start_adventure(13, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}))
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
Kit.check("奖励节点共享池:令牌耗尽同样不出候选(同库同池——%s)" % str(ncands13), not has_attack13)
a13.present_node_curio(ncands13)
a13.present_node_curio([{"kind": "gp", "tier": "common"}])
var gp13 := int(a13.ledger.get("gp", 0))
Kit.check("奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.adventure.curio_value("gp", "common")), a13.apply_node_curio("gp", "common") and int(a13.ledger.get("gp", 0)) == gp13 + int(_db.adventure.curio_value("gp", "common")))
Kit.check("节点停点态兑付后清零(恰一次语义同构)", a13._node_curio_pending.is_empty())
var s10: Dictionary = a10.summary
Kit.check("放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)", String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true)))
# ---- 专长冒烟(feats 四级 · 案D 池接线 · V3 订阅面逐件三列)----
# ---- 专长冒烟(feats 四级 · 案D 池接线 · V3 订阅面逐件三列)----
func _smoke_feats() -> void:
print("--- 专长重设计实装(feat_table 四级 · 专长带池接线 · 订阅面逐件)---")
# ① 现行表牌库构成:30 件全量入池——102 数值令牌+30 relic 令牌(四级 rarity 直映射)
Kit.check("feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)", _db.feats != null and (_db.feats.rows as Array).size() == 30)
Kit.check("feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)", _db.feats.validate().is_empty())
var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71))
Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71, {"first_script": true}))
var feat_tokens := 0
var tier_counts := {}
for t in a_r0._curio_deck:
if String((t as Dictionary).get("kind", "")) == "relic":
feat_tokens += 1
var tk := String((t as Dictionary).get("tier", ""))
tier_counts[tk] = int(tier_counts.get(tk, 0)) + 1
Kit.check("专长带入池:30 件四级直映射(普通 12/优秀 9/稀有 9/传说 0)——牌库 102+30=132〔专长批 V2+卡② 完美保险迁入〕", feat_tokens == 30 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 9 and a_r0._curio_deck.size() == 132)
# ② 旧 27 件零残留钉面(V2——arcane_ember 系旧件表首行;全仓 grep 零残留归静态核查)
Kit.check("旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕", _db.feats.row_by_id("arcane_ember").is_empty() and _db.feats.row_by_id("magma_body").is_empty())
Kit.check("迁出收尾:treasure_items 无 venom_edge/seal_burst 行(同调目录自然收窄)〔专长批 V13〕", _db.treasure_items.row_by_id("venom_edge").is_empty() and _db.treasure_items.row_by_id("seal_burst").is_empty())
# ③ 契约面静态钉扎(V18/V19/V21/V22——人工核对 V20/V21 裁决面以外的可机检半)
var kinds_ok := true
for t in a_r0._curio_deck:
var kk := String((t as Dictionary).get("kind", ""))
if kk != "relic" and not _db.adventure.CURIO_KINDS.has(kk):
kinds_ok = false
Kit.check("层级纪律:牌库 kind 全集=CURIO_KINDS+relic(秘宝 item_id 零入池)〔专长批 V18〕", kinds_ok)
var rewrite_rows := 0
for row_v in (_db.feats.rows as Array):
for tv in ((row_v as Dictionary).get("triggers", []) as Array):
if String(((tv as Dictionary).get("do", {}) as Dictionary).get("kind", "")) == "rewrite_board":
rewrite_rows += 1
Kit.check("棋盘事实不可变:新 30 行零棋盘改写类订阅(rewrite_board 已除名消费面)〔专长批 V19〕", rewrite_rows == 0)
var bad_words := 0
for row_v in (_db.feats.rows as Array):
var r_d: Dictionary = row_v as Dictionary
for txt_v in [String(r_d.get("display_name", "")), String(r_d.get("desc", ""))]:
if String(txt_v).contains("雷") or String(txt_v).contains("旗"):
bad_words += 1
Kit.check("对外命名纪律:30 行 display_name/desc 零「雷/旗」字面(封印/陷阱风味系)〔专长批 V21 初筛〕", bad_words == 0)
Kit.check("数值纪律:陷阱爆发倍率>1 · 血蜜回复<典型陷阱伤 · 石肤上限 3 在册(结构锚形态内 provisional)〔专长批 V22 机检半〕",
float((_db.feats.row_by_id("trap_burst").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("mult", 0)) > 1.0
and int((_db.feats.row_by_id("blood_honey").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("value", 99)) < 5
and int((_db.feats.row_by_id("stone_skin").get("triggers", [{}])[0] as Dictionary).get("do", {}).get("cap", 0)) == 3)
# ④ 临时表注入(新 schema 形态——零 archetype/cap 直用):5 行 relic 入牌库/兑付/踢池/断点往返
var rt := FeatTable.new()
rt.rows = [
{"feat_id": "x1", "display_name": "试一", "rarity": "common", "desc": "测试揭示", "cap": 1,
"triggers": [{"on": "mine_hit", "do": {"kind": "reveal_tiles", "value": 1}}]},
{"feat_id": "x2", "display_name": "试二", "rarity": "excellent", "desc": "测试回复", "cap": 1,
"triggers": [{"on": "mine_hit", "do": {"kind": "heal", "value": 2}}]},
{"feat_id": "x3", "display_name": "试三", "rarity": "rare", "desc": "测试群伤", "cap": 1,
"triggers": [{"on": "seal_hit", "do": {"kind": "open_ticket", "channel": "blast", "value": 3}}]},
{"feat_id": "x4", "display_name": "试四", "rarity": "rare", "desc": "测试减伤", "cap": 2,
"triggers": [{"on": "damage_taken", "when": [{"fact": "hp_ratio", "op": "lte", "value": 50}], "do": {"kind": "damage_reduce", "value": 2}}]},
{"feat_id": "x5", "display_name": "试五", "rarity": "legendary", "desc": "测试AC", "cap": 1,
"triggers": [{"on": "seal_hit", "do": {"kind": "ac_bonus", "value": 1, "mode": "round"}}]},
]
Kit.check("新 schema 构造表 validate 零违例(四级 rarity/cap 字段/词表全过——流派标签已退役零 archetype)", rt.validate().is_empty())
var live_rows: FeatTable = _db.feats
_db.feats = rt
var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72))
Kit.check("专长带场景:冒险启动(seed 72——注入 5 行临时表)", a_r.start_adventure(72, {"first_script": true}))
var deck_relic := 0
for t in a_r._curio_deck:
if String((t as Dictionary).get("kind", "")) == "relic":
deck_relic += 1
Kit.check("专长带入池:5 行 relic 每件 1 张(四级 rarity 直映射)——牌库 102+5=107", deck_relic == 5 and a_r._curio_deck.size() == 107)
# 渠道白名单断言(渠道轴定位:通关渠道只出专长/节点渠道零专长)
var feat_seen: Dictionary = {}
var node_relic_seen := false
for i in 60:
for t in a_r._draw_curio_candidates("level_clear"):
if String((t as Dictionary).get("kind", "")) == "relic":
feat_seen[String(t.get("feat_id", ""))] = true
for t in a_r._draw_curio_candidates("reward_node"):
if String((t as Dictionary).get("kind", "")) == "relic":
node_relic_seen = true
Kit.check("通关渠道只出专长(60 次重抽 ≥2 种不同 relic——身份键 feat_id 同档可多件)", feat_seen.size() >= 2)
Kit.check("节点渠道零专长(60 次重抽零 relic——专长在非 Boss 渠道只以专长凭证形态出现)", not node_relic_seen)
# T5 专长凭证行为断言(A5 拿到即开 / A6 保底计数——契约④四口径;独立 runner 防污染 a_r 牌库)
var a_t = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(91))
a_t.start_adventure(91, {"first_script": true})
var stock_before := {}
for kk in ["attack", "gp", "heal"]:
stock_before[kk] = a_t.pool_stock(kk)
a_t._node_curio_pending = {"candidates": [{"kind": "feat_ticket", "tier": "excellent"}, {"kind": "attack", "tier": "common"}]}
Kit.check("A5 专长凭证兑付受理(节点渠道——拿到即开)", a_t.apply_node_curio("feat_ticket", "excellent"))
var tkt_cands: Array = (a_t.node_curio_pending_state().get("candidates", []) as Array)
var tkt_all_relic := tkt_cands.size() > 0
for t in tkt_cands:
if String((t as Dictionary).get("kind", "")) != "relic":
tkt_all_relic = false
Kit.check("A5 拿到即开——停点候选已替换为专长三选一(全 relic)", tkt_all_relic)
Kit.check("A5 零摘牌——专长凭证兑付不动令牌库(不进持有集)", a_t.pool_stock("attack") == int(stock_before["attack"]) and a_t.pool_stock("gp") == int(stock_before["gp"]))
Kit.check("A5 无专长凭证残留持有集", not (a_t.feats_held() as Array).has("feat_ticket"))
var feats_n := (a_t.feats_held() as Array).size()
a_t.apply_node_curio("relic", String((tkt_cands[0] as Dictionary).get("tier", "rare")), String((tkt_cands[0] as Dictionary).get("feat_id", "")))
Kit.check("A5 专长面兑付受理(专长凭证开出候选可正常入持有集)", (a_t.feats_held() as Array).size() == feats_n + 1)
# A6 保底:pity 直填 5 → 下次展示必出(第 6 次必出口径);限购满 → 恒不触发
a_t._feat_ticket_sold = 0 # A5 已兑付一枚专长凭证(sold=1=limit)——A6 段重置计数隔离验证
a_t._feat_ticket_pity = int(a_t.db.adventure.feat_ticket_pity_step)
var rg_t: RandomNumberGenerator = a_t.rng.open_stream("reward_pick")
Kit.check("A6 保底触发(pity=5 → 第 6 次展示必出——概率无关)", a_t._feat_ticket_consider(rg_t))
Kit.check("A6 触发后清零", a_t._feat_ticket_pity == 0)
a_t._feat_ticket_sold = int(a_t.db.adventure.feat_ticket_limit)
a_t._feat_ticket_pity = int(a_t.db.adventure.feat_ticket_pity_step)
Kit.check("A6 限购满不再触发(保底冻结)", not a_t._feat_ticket_consider(rg_t))
a_t._feat_ticket_sold = 0
a_t._feat_ticket_pity = 0
var offered := false
for i in 200:
if a_t._feat_ticket_consider(rg_t):
offered = true
break
Kit.check("A6 概率面存在(chance=20% 200 展示内必出现——零触发=概率面失守)", offered)
a_r._curio_pending = {"candidates": [{"kind": "relic", "tier": "rare", "feat_id": "x3"}, {"kind": "gp", "tier": "common"}], "payload": {}}
Kit.check("relic 兑付:入持有集 + 牌库摘牌(选中踢池——池库存 %d→%d)" % [deck_relic, deck_relic - 1], a_r.pick_curio("relic", "rare", "x3") and (a_r.feats_held() as Array).has("x3") and a_r.pool_stock("relic") == deck_relic - 1)
# 断点往返三条(基点 1420–1426:relics 快照/计数器键/余库逐枚还原)按主会话裁决②改判集成宿主——
# 驱动代码(save_manager 装配+_on_level_finished+快照取读)随三条同去,集成批落位时重建。
_db.feats = live_rows # 还原现行表(共享 _db 防污染——后续冒烟段零感知)
# ⑤ V3 订阅面逐件(附表 A 三列——命中面=EffectEngine dispatch 直测;账本面=counter 直读;
# 落地行为=发射点施加面〔T1 施加段承载——本段按声明逐件钉命中与边界/反例〕)
var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(73))
var fx = orch._fx
# counter 落账 helper(dispatch 只返回命中不落账——施加面同构:counter_add 逐条落账本 helper 承载)
var apply_fx := func(hits: Array) -> void:
for h in hits:
if String(h.get("kind", "")) == "counter_add":
var sv_c = h.get("set", null)
EffectEngine.counter_add(orch.counters_ctx, String(h.get("counter_id", "")), int(h.get("value", 0)) if sv_c == null else int(sv_c), sv_c != null)
# 开格系
fx.rebuild(["twin_pick"], [], [])
for i in 4:
var hits_dp: Array = fx.dispatch(orch.counters_ctx, "dig_paid", {"ap_cost": 1})
var refund := false
for h in hits_dp:
if String(h.get("kind", "")) == "grant_ap":
refund = true
EffectEngine.counter_add(orch.counters_ctx, "adv_twin_digs", 1)
if i == 1 or i == 3:
Kit.check("双管镐:第 %d 次付费挖格返 1 AP(每第 2 次——mod_eq 预评估口径)" % (i + 1), refund)
else:
Kit.check("双管镐反例:第 %d 次不返(未满 2 次)" % (i + 1), not refund)
fx.rebuild(["geologist"], [], [])
var geo_hits: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"new_resource_type": true})
apply_fx.call(geo_hits)
Kit.check("地质学家:首开新类型集 1 点〔专长批 V3〕", orch._hits_of(geo_hits, "counter_add").size() == 1)
EffectEngine.counter_add(orch.counters_ctx, "geo_types", 2) # 手动基数补齐(row1 已落账 1+2=3——满 4 种的预评估现值)
var geo_hits2: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"new_resource_type": true})
apply_fx.call(geo_hits2)
Kit.check("地质学家:满 4 种(预评估现值 3+本格)→揭示 1 格·计数清零〔专长批 V3〕", orch._hits_of(geo_hits2, "reveal_tiles").size() == 1 and EffectEngine.counter(orch.counters_ctx, "geo_types") == 0)
var geo_hits3: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"new_resource_type": false})
Kit.check("地质学家反例:同类型重复首开不叠点〔专长批 V3〕", orch._hits_of(geo_hits3, "counter_add").is_empty())
fx.rebuild(["hard_hat"], [], [])
apply_fx.call(fx.dispatch(orch.counters_ctx, "dig_reveal", {}))
apply_fx.call(fx.dispatch(orch.counters_ctx, "dig_reveal", {}))
Kit.check("安全帽:开安全格攒层 ×2〔专长批 V3〕", EffectEngine.counter(orch.counters_ctx, "helm_layers") == 2)
var hh_hits: Array = fx.dispatch(orch.counters_ctx, "mine_hit", {"damage": 6})
var hh_reduce := 0
for h in hh_hits:
if String(h.get("kind", "")) == "damage_reduce":
hh_reduce = EffectEngine.counter(orch.counters_ctx, String(h.get("value_counter", "")))
Kit.check("安全帽:踩雷耗层减伤=层值 2(一次清零随施加面 set 行)〔专长批 V3〕", hh_reduce == 2)
fx.rebuild(["skip_mining"], [], [])
var sk_zero: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_blank_zero": true})
apply_fx.call(sk_zero)
Kit.check("跳格采掘:空白 0 格攒 1 点〔专长批 V3〕", orch._hits_of(sk_zero, "counter_add").size() == 1)
EffectEngine.counter_add(orch.counters_ctx, "skip_pts", 1) # 手动基数补齐(已落账 1+1=2——满 3 点的预评估现值)
var sk_full: Array = fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_blank_zero": true})
apply_fx.call(sk_full)
Kit.check("跳格采掘:满 3 点→揭示 1 格〔专长批 V3〕", orch._hits_of(sk_full, "reveal_tiles").size() == 1)
Kit.check("跳格采掘反例:普通开格不计点〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_blank_zero": false}), "counter_add").is_empty())
fx.rebuild(["blast_accounting"], [], [])
for i in 5:
var ba_hits: Array = fx.dispatch(orch.counters_ctx, "dig_paid", {"ap_cost": 1})
for h in ba_hits:
if String(h.get("kind", "")) == "counter_add":
EffectEngine.counter_add(orch.counters_ctx, String(h.get("counter_id", "")), 1)
Kit.check("爆破会计:本轮挖格花费≥5→+1 GP〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "round_end", {}), "grant_gp").size() == 1)
fx.rebuild(["frontier_miner"], [], [])
Kit.check("边陲矿工:边缘格产出 +1·中心格反例零产出〔专长批 V3〕",
orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_edge_cell": true}), "grant_material").size() == 1
and orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_reveal", {"is_edge_cell": false}), "grant_material").is_empty())
# 封印系
fx.rebuild(["seal_echo"], [], [])
apply_fx.call(fx.dispatch(orch.counters_ctx, "seal_hit", {}))
apply_fx.call(fx.dispatch(orch.counters_ctx, "seal_hit", {}))
var se_hits: Array = fx.dispatch(orch.counters_ctx, "round_end", {})
Kit.check("封印回响:标中计数≥2→轮末返 1 AP(下轮 queue)〔专长批 V3〕", orch._hits_of(se_hits, "grant_ap").size() == 1 and String((orch._hits_of(se_hits, "grant_ap")[0] as Dictionary).get("mode", "")) == "queue")
fx.rebuild(["seal_smith"], [], [])
var sm_remove := 0
for i in 3:
var sm_dispatch: Array = fx.dispatch(orch.counters_ctx, "seal_hit", {})
apply_fx.call(sm_dispatch)
for h in sm_dispatch:
if String(h.get("kind", "")) == "remove_mines":
sm_remove += 1
Kit.check("封印匠:标中每第 3 次→中立移除恰一次(频率闸死)〔专长批 V3〕", sm_remove == 1)
fx.rebuild(["seal_stake"], [], [])
Kit.check("封印桩:标中本轮 AC +N(round 域——round_start 清零)〔专长批 V3〕",
orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "ac_bonus").size() == 1
and String((orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "ac_bonus")[0] as Dictionary).get("mode", "")) == "round")
fx.rebuild(["warning_beacon"], [], [])
Kit.check("示警烟火:标中揭示 3×3 资源类型(不揭安危位置)〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "preview_area").size() == 1)
# 踩雷/受伤系
fx.rebuild(["pain_memory"], [], [])
apply_fx.call(fx.dispatch(orch.counters_ctx, "mine_hit", {}))
apply_fx.call(fx.dispatch(orch.counters_ctx, "mine_hit", {}))
var pm_hits: Array = fx.dispatch(orch.counters_ctx, "attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0})
var pm_bonus := 0
for h in orch._hits_of(pm_hits, "attack_bonus"):
pm_bonus = EffectEngine.counter(orch.counters_ctx, String(h.get("value_counter", "")))
Kit.check("疼痛记忆:攻击附加层值伤害=层值 2·用后清零(set 行随施加面)〔专长批 V3〕", pm_bonus == 2)
fx.rebuild(["fuse_wire"], [], [])
var fw_hits: Array = fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "terrain", "amount": 6})
var fw_shield := 0
for h in fw_hits:
if String(h.get("kind", "")) == "shield":
fw_shield += int(h.get("value", 0))
Kit.check("保险丝反例:单次 <5 不攒点不触发〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "terrain", "amount": 3}), "shield").is_empty())
for h in fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "terrain", "amount": 6}):
if String(h.get("kind", "")) == "counter_add":
EffectEngine.counter_add(orch.counters_ctx, String(h.get("counter_id", "")), int(h.get("value", 0)))
Kit.check("保险丝:首点不出盾(2 点才出——预评估口径)〔专长批 V3〕", fw_shield == 0)
fx.rebuild(["blood_insight"], [], [])
Kit.check("见血开窍:踩雷揭示 1 格〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {}), "reveal_tiles").size() == 1)
fx.rebuild(["trap_instinct"], [], [])
apply_fx.call(fx.dispatch(orch.counters_ctx, "mine_hit", {}))
var ti_hits: Array = fx.dispatch(orch.counters_ctx, "attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0})
var ti_val := 0
for h in orch._hits_of(ti_hits, "attack_bonus"):
ti_val = EffectEngine.counter(orch.counters_ctx, String(h.get("value_counter", "")))
Kit.check("陷阱本能:踩雷本轮攻击 +2(attack_bonus 通道——面板加算)〔专长批 V3〕", ti_val == 2)
fx.rebuild(["chain_traps", "pain_memory"], [], [])
apply_fx.call(fx.dispatch(orch.counters_ctx, "mine_hit", {}))
var ct_before: int = EffectEngine.counter(orch.counters_ctx, "pain_layers")
apply_fx.call(fx.dispatch(orch.counters_ctx, "mine_hit", {}))
Kit.check("连环陷阱:同轮第 2 颗起每颗额外 +1 层(疼痛记忆同账)〔专长批 V3〕", EffectEngine.counter(orch.counters_ctx, "pain_layers") == ct_before + 2)
fx.rebuild(["trap_burst"], [], [])
var tb_hits: Array = fx.dispatch(orch.counters_ctx, "mine_hit", {"damage": 8})
Kit.check("陷阱爆发:全体敌雷伤×倍率直伤(blast_scaled——施加面值=damage×mult)〔专长批 V3〕",
orch._hits_of(tb_hits, "blast_scaled").size() == 1
and float((orch._hits_of(tb_hits, "blast_scaled")[0] as Dictionary).get("mult", 0)) > 1.0)
fx.rebuild(["blood_honey"], [], [])
Kit.check("血蜜:踩雷回复 N 生命〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {}), "heal").size() == 1)
fx.rebuild(["stone_skin"], [], [])
var ss_hits: Array = fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1})
Kit.check("石肤:受伤 AC+1(本场域 cap 3——达上限不再涨由累积器封顶)〔专长批 V3〕",
orch._hits_of(ss_hits, "ac_bonus").size() == 1
and int((orch._hits_of(ss_hits, "ac_bonus")[0] as Dictionary).get("cap", 0)) == 3)
fx.rebuild(["last_stand"], [], [])
Kit.check("背水:生命≤50% 减伤·过半血反例零减伤〔专长批 V3〕",
orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1, "hp_ratio": 40}), "damage_reduce").size() == 1
and orch._hits_of(fx.dispatch(orch.counters_ctx, "damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 2, "hp_ratio": 80}), "damage_reduce").is_empty())
# 跨族桥
fx.rebuild(["breaker_strike"], [], [])
Kit.check("破封一击:挖开带封印危险格 +1 层·无封印反例零触发〔专长批 V3〕",
orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {"dug_under_seal": true}), "counter_add").size() == 1
and orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {"dug_under_seal": false}), "counter_add").is_empty())
# 节奏系
fx.rebuild(["map_exchange"], [], [])
var me_hits: Array = fx.dispatch(orch.counters_ctx, "dig_stop", {"ap_left": 4})
apply_fx.call(me_hits)
EffectEngine.counter_add(orch.counters_ctx, "stop_swap", 4) # value_event="ap_left" 解析归施加面(action_economy 载荷取值)——冒烟手动落账
Kit.check("弃图兑换:停手剩余 AP≥3 入账〔专长批 V3〕", orch._hits_of(me_hits, "counter_add").size() == 1)
var me_atk := 0
for h in orch._hits_of(fx.dispatch(orch.counters_ctx, "attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}), "attack_bonus"):
me_atk = EffectEngine.counter(orch.counters_ctx, String(h.get("value_counter", "")))
Kit.check("弃图兑换:折为攻击伤害=剩余 AP 全额·用后清零〔专长批 V3〕", me_atk == 4)
Kit.check("弃图兑换反例:剩余 AP<3 不触发〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "dig_stop", {"ap_left": 2}), "counter_add").is_empty())
fx.rebuild(["last_drop"], [], [])
var ld_hits: Array = fx.dispatch(orch.counters_ctx, "round_start", {"round": 2, "ap_exhausted_last": true})
Kit.check("一滴不剩:上轮 AP 耗尽→+1 AP〔专长批 V3〕", orch._hits_of(ld_hits, "grant_ap").size() == 1)
Kit.check("一滴不剩反例:上轮有余不发〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "round_start", {"round": 3, "ap_exhausted_last": false}), "grant_ap").is_empty())
fx.rebuild(["market_close"], [], [])
for i in 8:
for h in fx.dispatch(orch.counters_ctx, "dig_reveal", {}):
if String(h.get("kind", "")) == "counter_add":
EffectEngine.counter_add(orch.counters_ctx, String(h.get("counter_id", "")), 1)
Kit.check("收盘:本轮开格≥8→+1 GP〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "round_end", {}), "grant_gp").size() == 1)
# 战斗系
fx.rebuild(["execution_scholar"], [], [])
Kit.check("斩首学者:击杀揭示 1 格〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "kill", {"target": "敌", "overflow": 0}), "reveal_tiles").size() == 1)
fx.rebuild(["executioner"], [], [])
var ex_hits: Array = fx.dispatch(orch.counters_ctx, "enemy_hp_threshold", {"unit": "e1", "pct": 30, "is_boss": false})
Kit.check("处刑台:敌血量≤30% 挂单位标记加伤·阈值外反例零命中〔专长批 V3〕",
orch._hits_of(ex_hits, "target_attack_bonus").size() == 1
and orch._hits_of(fx.dispatch(orch.counters_ctx, "enemy_hp_threshold", {"unit": "e1", "pct": 60, "is_boss": false}), "target_attack_bonus").is_empty())
fx.rebuild(["field_dressing"], [], [])
Kit.check("战地包扎:战斗胜利回复 N 生命(battle_won 新挂点)〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "battle_won", {"round": 2}), "heal").size() == 1)
# 契约尖点
fx.rebuild(["blood_tax"], [], [])
Kit.check("血税:双向同局账本(踩陷阱−2max_hp/封印命中+1atk——bonus_delta 战末汇入)〔专长批 V3〕",
orch._hits_of(fx.dispatch(orch.counters_ctx, "mine_hit", {}), "bonus_delta").size() == 1
and String((orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "bonus_delta")[0] as Dictionary).get("axis", "")) == "atk")
# 迁入 2 件
fx.rebuild(["venom_edge"], [], [])
Kit.check("淬毒刃纹(迁入):克制正面(magic_vs_heavy 矩阵值>0)追加·中性面反例零触发〔专长批 V3〕",
orch._hits_of(fx.dispatch(orch.counters_ctx, "clamp_check", {"matchup": "magic_vs_heavy"}), "modify_ticket").size() == 1
and orch._hits_of(fx.dispatch(orch.counters_ctx, "clamp_check", {"matchup": "normal_vs_light"}), "modify_ticket").is_empty())
fx.rebuild(["seal_burst"], [], [])
Kit.check("封印爆裂(迁入):标中全体敌群伤〔专长批 V3〕", orch._hits_of(fx.dispatch(orch.counters_ctx, "seal_hit", {}), "open_ticket").size() == 1)
# 零持有
var orch0 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(74))
orch0._fx.rebuild([], [], [])
Kit.check("零持有订阅=零命中(relic 关着零扰动——伤害管线既有行为逐位不变)",
orch0._fx_emit("attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}).is_empty()
and orch0._fx_emit("dig_reveal", {"cell": Vector2i(1, 1), "round": 1, "is_first_dig_of_battle": true, "terrain_kind": []}).is_empty()
and orch0._fx_emit("mine_hit", {"damage": 5}).is_empty()
and orch0._fx_emit("battle_won", {"round": 1}).is_empty())
# ⑦c 特性持有呈现段(C 区「特性」节;呈现面在 06 在册)
var panel = load("res://presentation/views/hero_info_panel_view.gd").new()
panel.set_feat_holdings([])
Kit.check("特性段空态隐藏(视图空态纪律同特性段)", not panel._feat_section.visible)
panel.set_feat_holdings(["疼痛记忆 ×1", "双管镐 ×1"])
Kit.check("特性段持有呈现(逐件行常驻可读——「局内奖励选了什么看不到」缺口承载)", panel._feat_section.visible and panel._feat_list.get_child_count() == 2)
Kit.check("特性卡文案表值化:desc 行字段直读(零硬编码映射)", String(load("res://presentation/battle_narrator.gd").feat_card_text({"feat_id": "pain_memory", "display_name": "疼痛记忆", "desc": "触陷阱攒 1 层"})).contains("触陷阱攒 1 层"))
# 手工场景驱动:L1 全程 + L2 推进到商店房(_drive_level 复用 + 兑付确认/选路/大奖励确认手工编排)
# 手工场景驱动: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:
# 三选一停点确认(确定性选件后推进,与 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("feat_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":
# 奖励节点三选一(确定性选件、替换放弃新件、兑付确认回图;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)
var npick: Dictionary = autoplay._pick_token(ncands)
a.apply_node_curio(String(npick.get("kind", "")), String(npick.get("tier", "")), String(npick.get("feat_id", "")))
r.continue_after_node()
elif t == "rest":
r.continue_after_node()
return false
# 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集)
# 手工场景状态袋(_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}
# ---- 未开格类型微光·数据侧断言(V1 作用集恒等/V2 九型归组封闭/V7 零玩法写入)----
# 夹具纪律:身份回调形状与 reward_economy.query_identity 同构({reward_id, cell_type, tier});
# 位点覆盖表 _glow_type_overrides(Vector2i → cell_type)+封锁集 _glow_blocked(Vector2i → true,
# 返回 null = 不在摇号清单的封锁格)。哈希兜底按坐标确定性散列——全缸可复现。
# 手搓战斗状态袋单点(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}
# ==================== 敌人技能池(开轮摇号出招系统) ====================
# 六项验收断言(proposal-skill-pool.md〔提案已吸收退役 git a6833b6a——现行事实源=敌人系统.md 技能池节〕 §8 批 1):同种子回放逐位复现 / 单敌轨迹独立复放 /
# EV 抽样对锚 / 死亡打断作废 / 空池不可能 / 加载校验拒绝。
# 冒烟隔离用全新 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+商店房冒烟 ====================
# ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支)
# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移)
# ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。
# ---- ① GP 冒险钱包·到账 ----
# 模板感知取节点表(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 []
@@ -232,8 +232,10 @@ func _smoke_power_interval() -> void:
for i in range(6):
eligible.append({"cell": Vector2i(i, 0)})
var seen := {}
var draws := {} # rid → 总抽数(流动判据用——seen 只记不同值)
for rid in interval_rows.keys():
seen[rid] = {} # rid → {roll 值集}(区间行随表注册)
draws[rid] = 0
var in_domain := true
var fixed_ok := true
for trial in range(24):
@@ -246,6 +248,7 @@ func _smoke_power_interval() -> void:
if interval_rows.has(rid):
var v := int(snap.power)
seen[rid][v] = true
draws[rid] = int(draws.get(rid, 0)) + 1
if v < interval_rows[rid][0] or v > interval_rows[rid][1]:
in_domain = false
elif int(snap.power) != _row_power(rid):
@@ -255,7 +258,10 @@ func _smoke_power_interval() -> void:
var c_rid := String(cs.reward_id)
if not seen.has(c_rid):
seen[c_rid] = {}
if not draws.has(c_rid):
draws[c_rid] = 0
seen[c_rid][cv] = true
draws[c_rid] = int(draws.get(c_rid, 0)) + 1
if interval_rows.has(c_rid) and (cv < interval_rows[c_rid][0] or cv > interval_rows[c_rid][1]):
in_domain = false
Kit.check("24 板全区间行快照 power ∈ [power_min, power_max](含清图槽)", in_domain)
@@ -271,7 +277,7 @@ func _smoke_power_interval() -> void:
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:
elif int(draws.get(rid, 0)) >= 2 and seen[rid].size() < 2:
variance_ok = false
Kit.check("区间行上下阶实际流动(≥2 样本的区间行须 ≥2 个不同 roll 值)", variance_ok)
Kit.check("定值行(lo=hi)roll 恒等表值", fixed_rows_ok)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,734 @@
# tests/unit/core/meta_progression_forge_test.gd — 元进度与锻造单元测试宿主
# 断言面 = 六套(批 B 断言迁移自 smoke_all.gd,G9 施工组;六套按源编排序迁入):
# 元进度·树购买面(购买受理/持久/余额不足拒绝/未知节点拒绝——行价=表值 flat 单价)
# 元进度·收束入账与工坊(Boss 两档/钥石不丢/秘宝解锁/摘要副本与 cap/同调位/开局传参面)
# 元进度·注入可见与白板(hero_override 公式面/三轴白板逐位一致)
# 锻造工坊·两根类型轴所有权移交(静态表两轴删除/写口族/并联双通道/两币分工/注入接线/三层 AC)
# 天赋树(12 节点 schema 反例/购买面行门槛/Fork 一节点双态/彩蛋觉醒)
# 天赋树六键行为(先手窗/危险直觉限次/守侧五场景/财富之道聚散)
# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload,
# ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断在此展开,
# 表值变异的红由具体断言落,承 G2/G3/G4/G5 同款);全局 class_name(Attunement/EffectEngine/
# EffectContext)裸用(--script 模式全局类在册,G3 先例)。
# 运行:godot --headless --path game --script res://tests/unit/core/meta_progression_forge_test.gd
extends SceneTree
const Kit := preload("res://tests/unit/test_kit.gd")
const ConfigDBScript := preload("res://core/config_db.gd")
const AutoPlayer := preload("res://smoke/auto_player.gd") # 自动玩家策略单一实现(迁移期按现路径 preload——账本 §五夹具前提)
var _db
func _init() -> void:
_db = ConfigDBScript.new()
# 表加载失败不中止(承 G2/G3/G4/G5 同款:表损坏的红落在具体断言上——
# 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评)
var load_ok: bool = _db.load_tables("res://data/tables", true)
if not load_ok:
push_error("[meta_progression_forge_test] 表加载返回失败——诊断如下(红由具体断言落)")
for d in _db.diagnostics():
if d.severity == "error" or d.severity == "fatal":
push_error(" [%s] %s: %s" % [d.severity, d.category, d.message])
# 套执行序沿源编排序(_smoke_meta 薄壳序 + _run_core_smoke 尾段序)
_meta_pricing_and_buy()
_meta_settle_and_workshop()
_meta_injection()
_smoke_forge_axes()
_smoke_tree()
_smoke_tree_rules()
quit(Kit.finish("meta_progression_forge_test"))
# ---- 隔离夹具(smoke_all.gd helper 的宿主自持私有副本——源文件收尾整体删除,宿主自给自足)----
# 冒烟隔离存档名册(段首清陈旧 + 段尾清理——契约:测试不污染 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", "user://smoke_meta_career.json",
"user://smoke_meta_career_v5.json", "user://smoke_meta_treasure_unlock.json", "user://smoke_meta_treasure_blank.json",
"user://smoke_meta_forge.json", "user://smoke_meta_forge2.json", "user://smoke_meta_forge3.json",
"user://smoke_meta_forge4.json", "user://smoke_meta_forge5.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
# 冒烟隔离用全新 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
# ---- 全战斗回放驱动(过渡副本——G6 属主件 `_run_battle`/`_auto_play_round` 的宿主侧一致副本:
# forge_axes「enc 两轴直达 spawn 英雄快照」需真实流驱动而 G6 宿主未建;smoke_all.gd 原件保留
# 供未迁套消费,收尾批与原件去重。副本去 G6 专属「收束相位回 Idle」断言——该条随 `_run_battle`
# 本体迁其宿主,本副本保持零断言纯驱动形态)----
func _run_battle(L: int, D: int, axes: Dictionary = {}) -> 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.seal_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 enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp}
enc.merge(axes, true) # 两根类型轴注入尾参(缺省零注入=回退面)
if not settlement.begin_battle(enc):
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"
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"}])
# ---- 元进度 ②树购买面(购买受理/持久/余额不足拒绝/未知节点拒绝;
# 行价=表值 flat 单价)----
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)
# ③ 购买成功:级+1、余额−价;buy 内含写盘——reload 后持久(树全钥石单币)
sv.add_keystone(1000)
var bal0: int = sv.keystone_balance()
var price0: int = int(_db.tree_node("sp_hp").get("price", 0))
Kit.check("树购买受理(Lv 0→1、余额 %d−%d)" % [bal0, price0],
mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1 and sv.keystone_balance() == bal0 - price0)
var sv_r = _meta_new_save(_META_SMOKE_PATHS[0])
Kit.check("购买持久(reload 后级与余额不变)",
sv_r.tree_level("sp_hp") == 1 and sv_r.keystone_balance() == bal0 - price0)
# ③ 余额不足 / 未知节点:false 零副作用
var sv_dry = _meta_new_save(_META_SMOKE_PATHS[1])
var mp_dry = load("res://core/meta_progression.gd").new(_db, sv_dry)
var tn_before: Dictionary = sv_dry.tree_nodes()
var fs_before: Dictionary = sv_dry.fork_sides()
Kit.check("余额不足 false 零副作用(0 余额购买拒绝——三域零变化〔天赋树批 V3〕)",
not mp_dry.tree_buy("sp_hp") and sv_dry.keystone_balance() == 0
and sv_dry.tree_nodes() == tn_before and sv_dry.fork_sides() == fs_before)
Kit.check("未知节点 false 零副作用(词表外出域)", not mp_dry.tree_buy("no_such_node"))
# ---- 元进度 ⑥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)
# ⑥ 逐杀掉落收束入账(掉落已在冒险内逐杀入账〔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]
Kit.check("新档首通 L1:boss_drops[0] {first:true, amount=first_kill}(验收5——明细随摘要归档)",
bool(drop1.get("first", false)) and int(drop1.get("amount", 0)) == int(row1.get("first_kill", -1))
and int(drop1.get("level", 0)) == 1)
Kit.check("明细 total = keystone_total(逐杀掉落全源——无二次派生加账)",
int(d1.get("total", 0)) == int(row1.get("first_kill", 0)))
Kit.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]
Kit.check("再通 L1:{first:false, amount=repeat_kill}(首杀标记置位后转小额)",
not bool(drop2.get("first", true)) and int(drop2.get("amount", 0)) == int(row1.get("repeat_kill", -1)))
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})
Kit.check("重复杀第三轮恒定不递减(验收5——无衰减曲线)",
not bool(d3["boss_drops"][0].get("first", true)) and int(d3["boss_drops"][0].get("amount", 0)) == int(row1.get("repeat_kill", -1)))
# 混合归因:通关两关 → 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})
Kit.check("两关通关逐关归因:L1 重复档 + L2 首杀档(first_kill 金额)",
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)))
# ⑦ 败北零通关摘要:余额只增不减(零入账)、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})
Kit.check("败北零通关摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)",
dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before)
# ⑧ 秘宝解锁服务(V3 钥石购买/V4 条件解锁;注入=直调解锁写口+直置存档生涯计数,
# 不走正常资源积累流程——卡片 7.1 测试方式)
var sv_u = _meta_new_save(_META_SMOKE_PATHS[11])
var mp_u = load("res://core/meta_progression.gd").new(_db, sv_u)
sv_u.add_keystone(10)
Kit.check("条件件误购拒绝(购买件专属——契约 8 分配面)〔秘宝批 V3 反例〕",
not mp_u.buy_treasure_unlock("sky_eye") and not sv_u.item_unlocked("sky_eye")
and not mp_u.buy_treasure_unlock("ranged_pick"))
Kit.check("余额不足购买拒绝且零副作用(余额 10 < 价 25)〔秘宝批 V3〕",
not mp_u.buy_treasure_unlock("twin_loader") and sv_u.keystone_balance() == 10)
Kit.check("钥石购买解锁:余额足→解锁态落档+余额扣减正确(价 10)〔秘宝批 V3〕",
mp_u.buy_treasure_unlock("morning_order") and sv_u.item_unlocked("morning_order")
and sv_u.keystone_balance() == 0)
Kit.check("已解锁重复购买拒绝〔秘宝批 V3〕",
not mp_u.buy_treasure_unlock("morning_order"))
Kit.check("条件未达不解锁(远程镐=通关 1 次)〔秘宝批 V4〕",
mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.item_unlocked("ranged_pick"))
sv_u._data["meta"]["adventure_cleared"] = 1 # 直置存档生涯计数(调试注入口径)
Kit.check("远程镐=adventure_cleared≥1 达标恰解锁一次+重复刷新幂等(C6:通关=打通整局冒险)〔秘宝批 V4〕",
(mp_u.refresh_treasure_unlocks() as Array).has("ranged_pick")
and sv_u.item_unlocked("ranged_pick") and mp_u.refresh_treasure_unlocks().is_empty())
sv_u._data["meta"]["boss_kill_count"] = 3
Kit.check("刷新执照=boss_kill_count≥3(阈值读 unlock_requires 行值不硬编)〔秘宝批 V4〕",
(mp_u.refresh_treasure_unlocks() as Array).has("refresh_license"))
sv_u._data["meta"]["adventure_cleared"] = 3
Kit.check("天眼单条件不足不解锁(AND 语义——cleared 3 达 boss 4 未达)〔秘宝批 V4〕",
not sv_u.item_unlocked("sky_eye"))
sv_u._data["meta"]["boss_kill_count"] = 4
Kit.check("天眼双条件 AND(通关≥3 且 Boss≥4)达标解锁〔秘宝批 V4〕",
(mp_u.refresh_treasure_unlocks() as Array).has("sky_eye"))
sv_u._data["meta"]["vision_used"] = 10
Kit.check("信标网络=真视类工具累计使用≥10(行为计数新域——use_treasure 受理成功累计)〔秘宝批 V4〕",
(mp_u.refresh_treasure_unlocks() as Array).has("beacon_network"))
var r_n := 0
for st_row in mp_u.treasure_shop_status():
r_n += 1 if bool(st_row.get("unlocked", false)) else 0
Kit.check("解锁态持久投影一致(reload 面同源读数;5 件=购买 1+条件 4)〔秘宝批 V4〕", r_n == 5)
# ⑩ recent_summaries:元素含 boss_drops 副本、最新在前、cap 10
var rs: Array = sv.recent_summaries()
Kit.check("摘要最新在前 + 元素含掉落明细副本(复盘可归因——验收5)",
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)
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})
Kit.check("摘要 cap 10 且最新在前(12 连 settle 截断后 seed 11 居首)",
sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11)
# ⑪ 同调位账本(装配持久/满位禁选/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)
Kit.check("白板档同调位求值 = 0(0 起步裁决)且 Y=0(未购无刷新能力——P1 甲初始 0 同构)",
Attunement.slots(_db, sv_a) == 0 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 0)
Kit.check("满位禁选(Slots 0 时装配拒绝;兼未解锁拒绝前置——直置解锁后下方装配)〔秘宝批 V5〕",
mp_a.toggle_attunement("morning_order") == false)
sv_a.add_keystone(200)
sv_a.unlock_item("morning_order")
sv_a.unlock_item("twin_loader")
Kit.check("Y 升级购买入账(Lv1 → 每冒险 +1 次刷新)",
mp_a.buy_attune_refresh() and sv_a.attune_refresh_level() == 1
and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 1)
mp_a.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": 0, "master_seed": 21})
Kit.check("通关 1 关 → Slots 恒 0(旧「按击杀 Boss 次数自动涨」条件已删——槽位唯一来源=树点亮)且 Boss 击杀总数=1(计数域保留)〔天赋树批 V14〕",
Attunement.slots(_db, sv_a) == 0 and sv_a.boss_kill_count() == 1)
sv_a.set_tree_level("att_socket1", 1)
Kit.check("树上点亮同调位 1 → Slots=1(槽位收编树节点——Q2 裁甲)",
Attunement.slots(_db, sv_a) == 1)
Kit.check("稀有件入普通格拒绝(件档 rank 3 > 装入位序 1——契约 2 档位格校验)〔秘宝批 V5〕",
not mp_a.toggle_attunement("sky_eye"))
Kit.check("装配持久写入 + 满位禁选(Slots 1 装第二件拒绝)〔秘宝批 V5 换主语〕",
mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("twin_loader") == false)
Kit.check("卸下恒可再装配(自卸自装——营地换装=冒险开始前合法面,装配不可变只锁运行中)〔秘宝批换主语〕",
mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("morning_order"))
var sv_a2 = _meta_new_save(_META_SMOKE_PATHS[7])
sv_a2.set_attunement_equipped(["morning_order"])
var mp_a2 = load("res://core/meta_progression.gd").new(_db, sv_a2)
Kit.check("装配关系读回持久(reload 后投影一致——写新件 id)〔秘宝批 V15〕",
(mp_a2.attune_camp_status()["equipped"] as Array).has("morning_order"))
# ⑫ 充能重置 / 刷新双计数器
var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12))
Kit.check("充能重置场景:装配冒险启动(seed 12)〔秘宝批 V8 换主语〕",
a12.start_adventure(12, {"attunement": ["incense_stick", "sky_eye"]}))
Kit.check("充能账本按装备 active_charged 件重置(新目录表值:线香 battle 级 1 次/天眼 level 级 1 次——周期重置点归 V8 逐形态面)", # 充能账住 RunLedger
int(a12.run_ledger.attune_charges.get("incense_stick", -1)) == 1 and int(a12.run_ledger.attune_charges.get("sky_eye", -1)) == 1)
a12._attunement = []
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": {}}
Kit.check("刷新双计数器——X 优先消耗(x1y2 → x0y2)",
a12.refresh_curio() and a12._attune_refresh_x == 0 and a12._attune_refresh_y == 2)
Kit.check("刷新双计数器——X 耗尽转 Y 池、池竭拒绝(x0y2 → y0 → 第四次 false 零副作用)",
a12.refresh_curio() and a12.refresh_curio() and a12._attune_refresh_y == 0
and a12.refresh_curio() == false)
a12._reset_refresh_x()
Kit.check("刷新 X 每碰三选一重置(不结转;新目录无 bonus_refresh 行源——X 机制保留恒 0 空转,存量加仓走 _refresh_stock=刷新练习 V8)〔秘宝批换代〕",
a12._attune_refresh_x == 0 and a12.attune_refresh_left() == 0)
# ⑬ 主菜单开局传参面(开新/续玩两入口同调三键随开局下传防回归:真实入口曾
# 三键恒空(缺陷①),冒烟夹具直呼 runner 参数面拦不住——此处直呼 main.build_adventure_params
# 单测统一构造面(static 免实例化整菜单场景;真实两入口共用本构造),取值与营地投影同源)
var sv_e = _meta_new_save(_META_SMOKE_PATHS[8])
sv_e.set_attunement_equipped(["morning_order"])
sv_e.set_attune_refresh_level(2)
sv_e.set_fork_side("fg_posture", "a")
var mp_e = load("res://core/meta_progression.gd").new(_db, sv_e)
var rules_e: Array = mp_e.tree_rule_keys()
Kit.check("开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 2(防断言空转)",
rules_e.has("posture_first_strike") and mp_e.attune_refresh_charges() == 2)
var p_new: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e,
{"first_script": true, "treasure_unlocked": {}, "shop_mode": "pool"})
Kit.check("主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)",
(p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array))
Kit.check("主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)",
int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges())
Kit.check("主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)",
(p_new.get("tree_rules", []) as Array) == rules_e)
Kit.check("主菜单开新冒险参数面:入口特有键(first_script/shop_mode)随 extra 原样并入",
bool(p_new.get("first_script", false)) and String(p_new.get("shop_mode", "")) == "pool")
var p_resume: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e,
{"treasure_unlocked": {}})
Kit.check("主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)",
(p_resume.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array)
and int(p_resume.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges()
and (p_resume.get("tree_rules", []) as Array) == rules_e)
# 续玩路径 tree_rules 受理断言:直呼 runner 续玩参数面
# (v2 旧断点夹具同式最小断点——smoke ⑫ 受理路径同形)验 _tree_rules 与传入一致。
var adr_res = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(19))
var bp_min: Dictionary = {"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": []}
Kit.check("继续上局受理面:resume_adventure 受理三键参数面(尾裁微批b 受理闭环——受理不再吃白板)",
adr_res.resume_adventure(bp_min, p_resume))
Kit.check("继续上局受理面:tree_rules 随参数面进 runner._tree_rules(消费面 set_tree_rules 下传现成)",
(adr_res._tree_rules as Array) == rules_e)
_meta_smoke_cleanup()
# ---- 元进度 ④注入可见 + 白板成立(验收 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)
# 树生命淬炼注入(公式:基础值+树淬炼 flat+锻造路线加值位预留 0——2 级 = +10;
# 注入走调试口径直置等级〔两分法——购买链已在 _smoke_tree 覆盖,此处隔离行门槛干扰〕)
sv.set_tree_level("sp_hp", 2)
Kit.check("生命淬炼 2 级 → hero_override().max_hp == Σ 节点 effects×等级(验收4 换代〔天赋树批 V12〕)",
int(mp.hero_override().get("max_hp", -1)) == 2 * int(_db.tree_node("sp_hp").get("effects", {}).get("max_hp", 0)))
Kit.check("攻/AC 树零来源(契约 9——树表零 atk/armor_class 行,override 二轴恒 0+预留位 0)",
int(mp.hero_override().get("atk", -1)) == 0 and int(mp.hero_override().get("armor_class", -1)) == 0)
Kit.check("命中线在账未注入(契约 6——override 无 hit 字段〔天赋树批 V13〕)",
not mp.hero_override().has("hit"))
var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
Kit.check("注入参数冒险启动(start_adventure params 通道)",
adr.start_adventure(920251, {"hero_override": mp.hero_override()}))
var r = adr.runner()
var ov: Dictionary = mp.hero_override()
Kit.check("首战 hero_atk = 表值 + 永久强化 flat(begin_battle 同表达式——下一局能用到强化)",
int(r.effective_atk()) == int(_db.hero.atk) + int(ov.get("atk", 0)))
Kit.check("首战 hero_max_hp 与开局生命账本 = 表值 + 注入档",
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)))
# 白板:同种子下 空参数 = 零强化参数 = 纯表值(逐位一致——注入通道默认值漏改在此转红)
var adr_w = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
Kit.check("白板冒险启动(无 params 实参——B1 空参数路径)", adr_w.start_adventure(920251))
var sv_white = _meta_new_save(_META_SMOKE_PATHS[5])
var adr_z = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
Kit.check("零强化参数冒险启动(新档 hero_override 全零)",
adr_z.start_adventure(920251, {"hero_override": load("res://core/meta_progression.gd").new(_db, sv_white).hero_override()}))
var rw = adr_w.runner()
var rz = adr_z.runner()
Kit.check("白板成立:空参数 = 零强化参数 = 纯表值(atk/max_hp/ac 三轴逐位一致——验收 2/3)",
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()))
Kit.check("ac 轴:白板恒表值、未换强化时注入零位移",
int(rw.effective_ac()) == int(_db.hero.armor_class) and int(r.effective_ac()) == int(_db.hero.armor_class))
_meta_smoke_cleanup()
# ---- 锻造工坊·两根类型轴所有权移交(V5 前半:hero.tres 两轴删除+锻造配置默认值)----
# 后半(build_adventure_params 注入默认值/选中覆盖)随 T4 注入接线同批断言;写口/解锁面归 T3。
func _smoke_forge_axes() -> void:
print("--- 锻造工坊·类型轴所有权移交(T2)---")
# V5:hero.tres 静态表两轴已删(全表加载零违例——config_db_test 同款闸;此处钉字段不在位)
Kit.check("hero.tres 无 attack_type/armor_type 字段(契约 2——静态表两轴删除)〔锻造工坊批 V5〕",
not ("attack_type" in _db.hero) and not ("armor_type" in _db.hero))
# 锻造配置默认值 = normal/light(常驻持有——与柜台解锁状态解耦,未点亮也照常可读)
Kit.check("锻造配置默认值 attack_type=normal / armor_type=light(常驻持有)〔锻造工坊批 V5〕",
_db.forge_default_attack_type() == "normal" and _db.forge_default_armor_type() == "light")
# 单位快照回退面:spawn 三参形态(零注入)= 锻造配置默认值
var uc = load("res://core/unit_container.gd").new(_db)
Kit.check("spawn 受理(三参形态——两轴缺省回退面)", uc.spawn(1, 0, int(_db.hero.max_hp)))
Kit.check("主角单位快照两轴 = 锻造配置默认值 normal/light(零注入回退——行为连续)〔锻造工坊批 V5〕",
String(uc.hero_unit().get("attack_type", "")) == "normal"
and String(uc.hero_unit().get("armor_type", "")) == "light")
# ---- T3 写口族(V2 购买/几何价 · V3 并联双通道 · V4 两币分工 · V6 选中切换)----
# 注入口径(两分法):add_keystone 置余额/直写存档域构造状态 → 直调写口 → 断余额/等级/读回
var sv2 = _meta_new_save(_META_SMOKE_PATHS[13])
var mp2 = load("res://core/meta_progression.gd").new(_db, sv2)
# V2①:长剑首购=20(0 级位几何价)、等级 0→1、扣币即盘、reload 持久
sv2.add_keystone(100)
Kit.check("长剑锻造首购:扣 20 钥石、等级 0→1(Q1——第一级=获得装备=首个 +1)〔锻造工坊批 V2〕",
mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 1
and sv2.keystone_balance() == 80)
var sv2r = _meta_new_save(_META_SMOKE_PATHS[13])
Kit.check("购买持久(reload 后等级与余额不变——即时写盘)〔锻造工坊批 V2〕",
sv2r.forge_route_level("sword") == 1 and sv2r.keystone_balance() == 80)
# V2②:二级=30(1 级位);六路线独立(买长剑不动轻甲)
Kit.check("长剑二级购:扣 30、等级→2;轻甲独立零扰动(六路线各练各的)〔锻造工坊批 V2〕",
mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 2
and sv2.keystone_balance() == 50 and sv2.forge_route_level("light") == 0)
# V2③:余额不足拒且零副作用(等级/选中/解锁三面零变化)
var sv2d = _meta_new_save(_META_SMOKE_PATHS[14])
var mp2d = load("res://core/meta_progression.gd").new(_db, sv2d)
var fr_dry: Dictionary = sv2d.forge_routes_snapshot()
Kit.check("余额不足购买拒绝且零副作用(0 余额——三域零变化)〔锻造工坊批 V2〕",
not mp2d.forge_buy_level("sword") and sv2d.keystone_balance() == 0
and sv2d.forge_routes_snapshot() == fr_dry)
# V2④:已满级拒购(cap 闸);未解锁拒购
sv2.set_forge_route_level("sword", 5)
Kit.check("已满级拒购/未解锁拒购(cap 闸+解锁闸——零副作用)〔锻造工坊批 V2〕",
not mp2.forge_buy_level("sword") and not mp2d.forge_buy_level("spear"))
# V3① 通道一:练满前一档(长剑 cap)→ 长枪/法杖派生解锁(零收费);轻甲未满→甲线仍锁
Kit.check("通道一:长剑练满→长枪/法杖派生解锁;轻甲未练满→重甲/魔法护甲仍锁〔锻造工坊批 V3〕",
mp2.forge_route_unlocked("spear") and mp2.forge_route_unlocked("staff")
and not mp2.forge_route_unlocked("heavy") and not mp2.forge_route_unlocked("ward"))
Kit.check("派生解锁后长枪可购(首购 20——余额 50−20)〔锻造工坊批 V3〕",
mp2.forge_buy_level("spear") and sv2.forge_route_level("spear") == 1
and sv2.keystone_balance() == 30)
# V3② 通道二:一次性解锁价直购(未练满也可买——两通道互不影响)
var sv3 = _meta_new_save(_META_SMOKE_PATHS[15])
var mp3 = load("res://core/meta_progression.gd").new(_db, sv3)
sv3.add_keystone(500)
Kit.check("通道二:魔法护甲一次性解锁价 200 直购开(轻甲未练满也可买)〔锻造工坊批 V3〕",
not mp3.forge_route_unlocked("ward") and mp3.forge_buy_unlock("ward")
and sv3.keystone_balance() == 300 and mp3.forge_route_unlocked("ward"))
# V3③ 直购后练满不重复收(练满通道=派生零收费;已解锁再直购=拒绝)
sv3.set_forge_route_level("light", 5)
Kit.check("直购后练满不重复收(已解锁=拒绝零收费——两通道互不影响)〔锻造工坊批 V3〕",
mp3.forge_route_unlocked("ward") and not mp3.forge_buy_unlock("ward")
and sv3.keystone_balance() == 300)
# V3④ 不设串联门槛(法杖直购开——长枪零状态不阻;dry 档充值走直购)
sv2d.add_keystone(500)
Kit.check("不设串联门槛(法杖直购开——长枪零状态不阻)〔锻造工坊批 V3〕",
mp2d.forge_buy_unlock("staff") and mp2d.forge_route_unlocked("staff")
and sv2d.forge_route_level("spear") == 0 and not mp2d.forge_route_unlocked("spear"))
# V4:任一购买/解锁只扣钥石——GP 侧零触碰(meta 域无 GP 键=域界扫描同款;此处钉余额差=当级价)
var ks4: int = sv3.keystone_balance()
Kit.check("两币分工:购买只扣钥石(余额差=当级价 20——GP 零触碰、无材料计数)〔锻造工坊批 V4〕",
mp3.forge_buy_level("ward") and sv3.keystone_balance() == ks4 - 20
and sv3.forge_route_level("ward") == 1)
# V6①:营地切换=写存档域+免费(余额零动);旧路线练度保留(只不生效)
var ks6: int = sv3.keystone_balance()
Kit.check("选中切换免费(余额零动——写存档域即盘)〔锻造工坊批 V6〕",
mp3.forge_select_armor("ward") and sv3.forge_armor() == "ward"
and sv3.keystone_balance() == ks6)
Kit.check("切换后旧路线练度保留(轻甲 5 级不丢——回家待命只不生效)〔锻造工坊批 V6〕",
sv3.forge_route_level("light") == 5)
# V6②:未解锁路线拒选零副作用(dry 档:长枪未解锁)
Kit.check("未解锁路线拒选零副作用〔锻造工坊批 V6〕",
not mp2d.forge_select_weapon("spear") and sv2d.forge_weapon() == "sword")
# V6③:类型轴读数随选中走(切换后读 ward;未解锁选中态防御回退默认值)
Kit.check("类型轴当前生效值随选中走(ward;未解锁档回退默认 normal/light)〔锻造工坊批 V5/V6〕",
mp3.hero_armor_type() == "ward" and mp2d.hero_attack_type() == "normal"
and mp2d.hero_armor_type() == "light")
# ---- T4 注入接线(V5 后半 · V6 锁死 · V7 公式对拍 · V10 树零攻防复核)----
# V5:build_adventure_params 注入默认值(新档=树钥匙未点亮)+点亮后读存档选中覆盖
var sv4 = _meta_new_save(_META_SMOKE_PATHS[16])
var mp4 = load("res://core/meta_progression.gd").new(_db, sv4)
var bp_params: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv4, mp4)
Kit.check("build_adventure_params 注入默认值 normal/light(树钥匙未点亮——新档初始持有态)〔锻造工坊批 V5〕",
String(bp_params.get("attack_type", "")) == "normal"
and String(bp_params.get("armor_type", "")) == "light")
sv4.add_keystone(1000)
Kit.check("长枪直购解锁+首购+选中(注入面夹具——200+20 钥石)",
mp4.forge_buy_unlock("spear") and mp4.forge_buy_level("spear") and mp4.forge_select_weapon("spear"))
var bp_sel: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv4, mp4)
Kit.check("build_adventure_params 读存档选中覆盖(选中长枪→pierce)〔锻造工坊批 V5〕",
String(bp_sel.get("attack_type", "")) == "pierce"
and String(bp_sel.get("armor_type", "")) == "light")
# V7:hero_override 公式对拍=树 flat+锻造路线加值(旧路线练度待命不生效;切回即生效)
Kit.check("hero_override=锻造路线加值(选中长枪 Lv1→atk +1;甲 0 级→AC +0;树零分量;无 hit 键)〔锻造工坊批 V7〕",
int(mp4.hero_override().get("atk", -1)) == 1
and int(mp4.hero_override().get("max_hp", -1)) == 0
and int(mp4.hero_override().get("armor_class", -1)) == 0
and not mp4.hero_override().has("hit"))
sv4.set_forge_route_level("sword", 3) # 长剑练度待命(未选中)
Kit.check("换当前武器→攻击加值随切换(长剑 3 级待命不生效——只认选中)〔锻造工坊批 V7〕",
int(mp4.hero_override().get("atk", -1)) == 1)
Kit.check("切回长剑→加值=3 级量(旧路线练度保留且生效)〔锻造工坊批 V7〕",
mp4.forge_select_weapon("sword") and int(mp4.hero_override().get("atk", -1)) == 3)
# V7 三层 AC:重甲(基础 12)+路线 Lv1 加值+局内 0——经参数面起冒险断 runner 生效值
var sv5 = _meta_new_save(_META_SMOKE_PATHS[17])
var mp5 = load("res://core/meta_progression.gd").new(_db, sv5)
sv5.add_keystone(1000)
Kit.check("重甲直购解锁+首购+选中(三层 AC 夹具)",
mp5.forge_buy_unlock("heavy") and mp5.forge_buy_level("heavy") and mp5.forge_select_armor("heavy"))
var adr5 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(77))
var p5: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv5, mp5)
Kit.check("注入参数冒险启动(两轴+override 同面)", adr5.start_adventure(77, p5))
var r5 = adr5.runner()
Kit.check("入战 AC 三层:甲型基础 12(重甲)+路线加值 1+局内 0〔锻造工坊批 V7〕",
int(r5.effective_ac()) == 12 + 1)
Kit.check("参数面快照入 runner(attack_type=normal/armor_type=heavy)〔锻造工坊批 V6〕",
adr5.hero_attack_type == "normal" and adr5.hero_armor_type == "heavy")
# V6 锁死:开冒险后改存档选中→参数面/注入值不变(快照冻结;light=初始持有已解锁可切)
Kit.check("冒险开始后锁死:改存档选中不影响参数面快照与生效值(契约 8)〔锻造工坊批 V6〕",
mp5.forge_select_armor("light") and adr5.hero_armor_type == "heavy"
and int(r5.effective_ac()) == 12 + 1)
# V5/V7 末端:enc 两轴直达 spawn 英雄快照(hero_attack 行携带选中类型键)
var run_ax = _run_battle(1, 0, {"attack_type": "pierce", "armor_type": "ward"})
var pierce_rows := 0
for row in run_ax.stream:
if row.get("source_class", "") == "hero_attack" and row.has("pierce"):
pierce_rows += 1
Kit.check("enc 两轴直达 spawn 英雄快照(hero_attack 行携带 pierce 键——注入链末端)〔锻造工坊批 V5〕",
pierce_rows >= 1)
# spawn 显式注入面(三参旧形态零注入已在上方覆盖——此处钉显式值)
var uc2 = load("res://core/unit_container.gd").new(_db)
Kit.check("spawn 显式两轴注入(magic/heavy 直入英雄快照)〔锻造工坊批 V5〕",
uc2.spawn(1, 0, int(_db.hero.max_hp), [], -1, -1, [], "magic", "heavy")
and String(uc2.hero_unit().get("attack_type", "")) == "magic"
and String(uc2.hero_unit().get("armor_type", "")) == "heavy")
# V10 复核:树表 12 行 effects 键集无 atk/armor_class(契约 1——树零攻防来源)
var tree_atk_ac := false
for trow in _db.tree_rows():
var eff: Dictionary = trow.get("effects", {})
if eff.has("atk") or eff.has("armor_class"):
tree_atk_ac = true
Kit.check("树零攻防来源复核(12 行 effects 键集无 atk/armor_class——契约 1)〔锻造工坊批 V10〕",
not tree_atk_ac)
# ---- 天赋树冒烟(v0.8 12 节点:schema/购买面/Fork 一节点双态/彩蛋特化)----
func _smoke_tree() -> void:
print("--- 天赋树(12 节点 · 购买面 · Fork 双态 · 彩蛋觉醒)---")
# 〔V1〕结构:12 行 + schema 零违例
Kit.check("meta_tree 已加载(12 节点=脊柱 2〔生命/命中淬炼一线一节点 cap4〕+锻造 2〔钥匙+彩蛋——彩蛋含在锻造 2 内非第 13 行〕+钥石 2〔秘宝匣/刷新升级〕+同调 6〔socket 3+Fork 3 双态〕——v0.8 终态)〔天赋树批 V1〕",
_db.meta_tree != null and (_db.meta_tree.rows as Array).size() == 12)
Kit.check("MetaTreeTable.validate 零违例(脉/类型/币词表出域即拒/Fork 三组每组恰一且持 a/b 双效果键/彩蛋 ≤3/effects 键封闭/零 archetype)",
_db.meta_tree.validate().is_empty())
# 彩蛋行硬要求(契约 9 红线落点):effects 键集 ⊆ {max_hp, hit}
var ley: Dictionary = _db.meta_tree.row_by_id("easter_ley")
var ley_ok: bool = not ley.is_empty()
for k in (ley.get("effects", {}) as Dictionary).keys():
if not (String(k) == "max_hp" or String(k) == "hit"):
ley_ok = false
Kit.check("easter_ley effects 键集 ⊆ {max_hp, hit}(攻/护分量已摘除——契约 9)〔天赋树批 V8 落点〕",
ley_ok)
# V1/V4 反例:出域行逐类构造被拒(复制行副本塞词表外值)
var t2 = load("res://data/schemas/meta_tree_table.gd").new()
var bad_vein: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true)
bad_vein["node_id"] = "bad_vein"
bad_vein["vein"] = "underworld"
t2.rows = [bad_vein]
Kit.check("schema 反例:vein 出域被拒", not t2.validate().is_empty())
var bad_fork: Dictionary = (_db.meta_tree.row_by_id("fork_posture") as Dictionary).duplicate(true)
bad_fork["node_id"] = "bad_fork"
bad_fork["fork_group"] = "fg_mastery"
t2.rows = [bad_fork]
Kit.check("schema 反例:fork_group 出域(fg_mastery 已退役组)被拒", not t2.validate().is_empty())
var bad_arch: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true)
bad_arch["node_id"] = "bad_arch"
bad_arch["effects"] = {"archetype": 1}
t2.rows = [bad_arch]
Kit.check("schema 反例:effects.archetype 加载致命(🔴 层级纪律护栏面)〔天赋树批 V4〕",
not t2.validate().is_empty())
var bad_dangling: Dictionary = (_db.meta_tree.row_by_id("sp_hp") as Dictionary).duplicate(true)
bad_dangling["requires"] = ["ghost_node"]
t2.rows = [bad_dangling]
Kit.check("schema 反例:requires 悬空被拒", not t2.validate().is_empty())
# 购买面(V2 主干)
var sv = _meta_new_save(_META_SMOKE_PATHS[8])
var mp = load("res://core/meta_progression.gd").new(_db, sv)
sv.add_keystone(1000)
Kit.check("树购买受理(脊柱生命 L1——树全钥石单币)",
mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1)
Kit.check("行门槛:第 2 级不可购(总等级 1 < 4——一线一节点主语,买第 N 级需全树总投入 ≥4×(N−1))〔天赋树批 V2〕",
not mp.tree_can_buy("sp_hp"))
Kit.check("命中 L1/锻造钥匙(free 无前置=初始零购零币可点亮)/同调位 1(根节点无前置)受理(总等级 1→4)",
mp.tree_buy("sp_hit") and mp.tree_buy("forge_workshop") and mp.tree_buy("att_socket1"))
Kit.check("锻造工坊钥匙零币(free)", int(_db.tree_node("forge_workshop").get("price", -1)) == 0)
Kit.check("行门槛恰达开放(总等级 4 ≥ 4×1)", mp.tree_can_buy("sp_hp"))
Kit.check("第 2 级购买受理(门槛恰达当刻开放)",
mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 2)
# Fork 一节点双态(V9)
sv.add_keystone(100)
Kit.check("Fork 购买=解锁+默认 A 态(一节点双态——单行承载)",
mp.tree_buy_fork("fork_posture") and sv.tree_level("fork_posture") == 1 and sv.fork_side("fg_posture") == "a")
Kit.check("已解锁 Fork 重复购买拒绝(cap 1)", not mp.tree_can_buy("fork_posture"))
Kit.check("同态重复切换零操作(false)", not mp.tree_toggle_fork("fg_posture", "a"))
Kit.check("未解锁 Fork 切换拒绝(解锁唯一入口=购买面)", not mp.tree_toggle_fork("fg_sense", "b"))
var bal_pre_toggle: int = sv.keystone_balance()
Kit.check("零币切态 b(购买解锁后 A/B 可切)",
mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b")
Kit.check("切态零消费(钥石余额差分不动)", int(sv.keystone_balance()) == bal_pre_toggle)
Kit.check("规则键投影=激活态效果键(B 态稳健拓荒——tree_rule_keys 随切态换键集)",
(mp.tree_rule_keys() as Array).has("posture_bulwark") and not (mp.tree_rule_keys() as Array).has("posture_first_strike"))
Kit.check("切回 A 态键集随换(六键词表在册——V11 校验面归单元段)",
mp.tree_toggle_fork("fg_posture", "a") and (mp.tree_rule_keys() as Array).has("posture_first_strike"))
# 彩蛋特化觉醒(V8)
Kit.check("彩蛋不经购买(手动购亮=红——觉醒唯一入口=特化判定)", not mp.tree_can_buy("easter_ley"))
Kit.check("觉醒前置未满足不亮(两线未拉满)", sv.tree_level("easter_ley") == 0)
sv.set_tree_level("sp_hp", 4)
sv.set_tree_level("sp_hit", 4)
sv.set_tree_level("att_socket2", 1)
mp.tree_buy("att_socket3") # 经购买口触发 _check_easter_egg
Kit.check("两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)", sv.tree_level("easter_ley") == 1)
var bal_now: int = sv.keystone_balance()
mp.tree_buy("sp_hp")
Kit.check("觉醒零消费(钥石余额不动)", int(sv.keystone_balance()) == bal_now)
_meta_smoke_cleanup()
# ---- 天赋树六键行为冒烟(V10:开局姿态窗/危险直觉限次/财富之道聚散/守侧失误补偿;
# 引擎直构 + 编排授予段直注(_grant_seg._judge_seal=结算授予段封印判定单口))----
func _smoke_tree_rules() -> void:
print("--- 天赋树六键行为(V10:窗/限次/聚散/守侧)---")
# 开局姿态组:每关首轮窗(battle_index 事实——引擎直构 + 自建 ctx)
var fx = load("res://core/effect_engine.gd").new(null)
fx.rebuild([], [], ["posture_first_strike", "posture_bulwark"])
var ctx_p := EffectContext.new()
Kit.check("先手压制:每关首战首轮窗命中(battle_index=1 且 round=1 → grant_ap queue)〔天赋树批 V10〕",
(fx.dispatch(ctx_p, "round_start", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1)
Kit.check("先手压制:首轮窗外地不触发(第二轮/第二关首战首轮——「每战首轮」旧语义换代件)",
(fx.dispatch(ctx_p, "round_start", {"battle_index": 1, "round_index": 2}, Callable()) as Array).is_empty()
and (fx.dispatch(ctx_p, "round_start", {"battle_index": 2, "round_index": 1}, Callable()) as Array).is_empty())
Kit.check("稳健拓荒:首轮窗内受击出盾、窗外不出(shield damage_taken 同窗条件)",
(fx.dispatch(ctx_p, "damage_taken", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1
and (fx.dispatch(ctx_p, "damage_taken", {"battle_index": 2, "round_index": 1}, Callable()) as Array).is_empty())
# 危险直觉·锋:限次闸(引擎直构——mine_hit 挂点现成;限次计数住 level 桶,跨战持久=共享同一字典引用)
var fz = load("res://core/effect_engine.gd").new(null)
fz.rebuild([], [], ["sense_sharp"])
var lvl_shared := {} # 同一关的 level 桶(两编排器共享引用 = 跨战持久,替代旧副本搬运)
var ctx_fz := EffectContext.new()
ctx_fz.level_bucket = lvl_shared
var got_mult := false
var got_add := false
for h in (fz.dispatch(ctx_fz, "mine_hit", {}, Callable()) as Array):
if String(h.get("kind", "")) == "damage_mult":
got_mult = true
if String(h.get("kind", "")) == "counter_add":
got_add = true
Kit.check("锋:踩雷减半命中+限次记账(counter 0<2 闸开——damage_mult+counter_add 双行同预评估态)",
got_mult and got_add)
EffectEngine.counter_add(ctx_fz, "lvl_tree_sense_sharp_used", 2) # 直调置数(V10 注入口径——置 level 桶计数后走事件路径)
Kit.check("锋:限次耗尽全额(counter 2 不<2 闸关——每关限次 provisional 2)〔无状态化 V5〕",
(fz.dispatch(ctx_fz, "mine_hit", {}, Callable()) as Array).is_empty())
var fz2 = load("res://core/effect_engine.gd").new(null)
fz2.rebuild([], [], ["sense_sharp"])
var ctx_fz2 := EffectContext.new()
ctx_fz2.level_bucket = lvl_shared # 挂同一 level 桶引用(新形态跨战持久:无 export/seed,同字典即持久)
Kit.check("锋:限次跨战持久(level 桶共享引用——替代旧副本搬运链)〔无状态化 V4〕",
(fz2.dispatch(ctx_fz2, "mine_hit", {}, Callable()) as Array).is_empty())
# 守侧五场景(契约 12):编排 rig——begin_battle 携 sense_guard,授予段 _grant_seg._judge_seal 直注
var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777001))
Kit.check("守侧 rig:战斗编排启动(tree_rules=sense_guard 携带)",
orch.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40,
"hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {},
"feats": [], "tree_rules": ["sense_guard"], "battle_index": 1,
"bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {},
"voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}))
var board = orch.minesweeper.current_board()
var safe := Vector2i(-1, -1)
for c in board.all_cells():
if not board.is_mine(c):
safe = c
break
Kit.check("守侧 rig:安全格定位(非危险格=标错负样本)", safe.x >= 0)
Kit.check("守侧 rig:首轮后 queue 干净(开局 posture queue 已兑现)",
int(orch.action_economy.pending_ap_delta()) == 0)
# 场景①:当回合新封印、结算判标错且封印仍在场 → queue +1(本轮 AP 零变化=queue 承载、下轮 notify_round_start 兑现)
orch._grant_seg._judge_seal({"cell": safe, "hit": false}, 1)
Kit.check("守①:当回合标错且封印在场 → 下轮 AP+1(queue 排队——本轮 AP 逐位零变化)",
int(orch.action_economy.pending_ap_delta()) == 1)
# 场景③④:之前回合标错重判(封印在场状态)与同格重复——去重闸挡回,最多返还一次
orch._grant_seg._judge_seal({"cell": safe, "hit": false}, 2)
orch._grant_seg._judge_seal({"cell": safe, "hit": false}, 3)
Kit.check("守③④:旧封印重判零触发+同格最多一次(_seal_miss_seen 去重闸=「当回合标错动作」判别器)",
int(orch.action_economy.pending_ap_delta()) == 1)
# 场景②负样本:封印命中(hit=true 正向兑付路径)零触发
orch._grant_seg._judge_seal({"cell": safe, "hit": true}, 4)
Kit.check("守②负样本:标中/被挖封印零触发(标错事件只在 miss 分支发射——发射面过滤承载)",
int(orch.action_economy.pending_ap_delta()) == 1)
# 场景⑤:末轮判出→无下一轮=queue 不兑现(编排器每战新建——队列实例随战亡,合法作废态)
var orch2 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777002))
Kit.check("守⑤ rig:次战编排启动(新战新队列实例)",
orch2.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40,
"hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {},
"feats": [], "tree_rules": ["sense_guard"], "battle_index": 1,
"bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {},
"voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}))
Kit.check("守⑤:末轮判出不返(queue 只在 notify_round_start 兑现、战亡即弃——无跨战泄漏)",
int(orch2.action_economy.pending_ap_delta()) == 0)
# 树限次按关清零改由 LevelRunner.level_counters 随 new 承载(旧硬编码名单冒烟断言随函数删除——换代同生死;新语义由 V4/V5 段承载)
# 财富之道·散:商品标价折价(level_runner 计价单点)
var lr = load("res://core/level_runner.gd").new(_db, _fresh_rng(777004))
lr.set_tree_rules([])
Kit.check("散未激活:商品标价原价(无 economy_spend 键)", int(lr._spend_discounted(100)) == 100)
lr.set_tree_rules(["economy_spend"])
Kit.check("散激活:商品购买价 ×80%(provisional 折率——floor 偏向玩家、下限 1;刷新价不折=另一计价点零触碰)",
int(lr._spend_discounted(100)) == 80 and int(lr._spend_discounted(1)) == 1)
# 财富之道·聚:战斗掉落乘算·入账前(编排 _roll_gp_drop——同种子配对对拍 1.5×)
var orch3 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777005))
Kit.check("聚 rig:基线编排启动(无增益侧)",
orch3.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40,
"hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {},
"feats": [], "tree_rules": [], "battle_index": 1,
"bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {},
"voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}))
orch3.encounter["tree_rules"] = []
var gp_base: int = orch3._roll_gp_drop()
var orch4 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777005))
Kit.check("聚 rig:增益编排启动(tree_rules=economy_hoard)",
orch4.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40,
"hero_armor_class": 10, "hero_ap_bonus": 0, "attunement": [], "attune_charges": {},
"feats": [], "tree_rules": ["economy_hoard"], "battle_index": 1,
"bonus_ref": {"atk": 0, "max_hp": 0, "armor_class": 0}, "adventure_counters": {},
"voucher_rap": 0, "voucher_time_sand": 0, "node_type": "battle"}))
orch4.encounter["tree_rules"] = ["economy_hoard"]
var gp_hoard: int = orch4._roll_gp_drop()
Kit.check("聚:战斗掉落乘算 ×1.5(provisional——同种子配对 %d→%d,入账前=结算框显示实得)",
gp_hoard == int(round(float(gp_base) * 1.5)) and gp_hoard >= gp_base)
_meta_smoke_cleanup()
@@ -0,0 +1,504 @@
# tests/unit/core/save_manager_domain_test.gd — 存档域单元测试宿主
# 断言面 = 两套(批 B 断言迁移自 smoke_all.gd,G10 施工组;两套按源编排序迁入):
# SaveManager 存档读写/迁移/兜底(读写往返/新档合法/余额拒花/高版本拒读禁写/损坏兜底/
# 迁移链/域界干净/摘要 cap/断点域七字段/旧档迁移族/收束链入账)
# SaveManager 三存档位承载(三槽往返/位间隔离/删档/新档白板/高版本拒读恢复/窥视三态/旧单档迁移)
# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload,
# ConfigDB 显式实例化构造注入(v2 断点续玩受理与收束链夹具消费 _db);表加载失败不中止
# (沿被迁断言面的源语义——诊断在此展开,红由具体断言落,承 G2–G5 同款)。
# 测试文件统一 user://smoke_save_*.json 隔离,段首清场+段尾清理——绝不碰 user://save.json(红线)。
# 分宿主注记:源 `_smoke_save` ⑨ 摘要集成块(level_autoplay 真局驱动×落盘,基点 :6807–6831)
# 经主会话裁决三改判集成测试——不随本宿主,见迁移账本转办清单。
# 运行:godot --headless --path game --script res://tests/unit/core/save_manager_domain_test.gd
extends SceneTree
const Kit := preload("res://tests/unit/test_kit.gd")
const ConfigDBScript := preload("res://core/config_db.gd")
var _db
func _init() -> void:
_db = ConfigDBScript.new()
# 表加载失败不中止(承 G2–G5 同款:表损坏的红落在具体断言上)
var load_ok: bool = _db.load_tables("res://data/tables", true)
if not load_ok:
push_error("[save_manager_domain_test] 表加载返回失败——诊断如下(红由具体断言落)")
for d in _db.diagnostics():
if d.severity == "error" or d.severity == "fatal":
push_error(" [%s] %s: %s" % [d.severity, d.category, d.message])
# 套执行序沿源编排序
_smoke_save()
_smoke_save_slots()
quit(Kit.finish("save_manager_domain_test"))
# ---- 隔离夹具(smoke_all.gd helper 的宿主自持私有副本——源文件收尾整体删除,宿主自给自足)----
# 冒烟隔离用全新 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
# ---- 存档序列化冒烟(SaveManager——读写往返/新档合法/余额拒花/锻造出域拒/高版本拒读禁写/
# 损坏兜底/迁移链/域界干净/摘要 cap/摘要集成/生涯终身计数域 v6)。测试文件统一 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", "user://smoke_save_v5upgrade.json",
"user://smoke_save_v5legacy.json", "user://smoke_save_v16discard.json",
"user://smoke_save_forge_v6legacy.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)
w.set_tree_level("sp_hp", 2)
w.set_tree_level("sp_hit", 1)
w.set_fork_side("fg_posture", "b") # 树域写入
w.set_forge_route_level("spear", 3) # 锻造工坊域写入(等级+直购解锁+选中标记)
w.add_forge_unlock("spear")
w.set_forge_weapon("spear")
w.set_forge_armor("ward")
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})
Kit.check("apply 原语入账(钥石 7+3、通关次数 1/1/0)",
w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0)
Kit.check("首杀标记随通关关卡置位(1/2 有、3 无)",
w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3))
Kit.check("生涯终身计数随 apply 自增恰一(中止局:总局数 1、通关局数 0——迭代 18 卡④)",
w.adventure_total() == 1 and w.adventure_cleared() == 0)
Kit.check("写盘成功", w.write_to_disk())
var r = sm_script.new(paths[0])
Kit.check("读回成功", r.load_from_disk())
Kit.check("钥石余额往返相等(10)", r.keystone_balance() == 10)
Kit.check("树域往返相等(tree_nodes/fork_sides——迭代 21 换代承载)",
r.tree_level("sp_hp") == 2 and r.tree_level("sp_hit") == 1 and r.fork_side("fg_posture") == "b")
Kit.check("锻造工坊域往返相等(levels/unlocked/weapon/armor——迭代 23 T5)〔锻造工坊批 V8〕",
r.forge_route_level("spear") == 3 and r.forge_route_level("sword") == 0
and r.forge_unlocked_purchases() == ["spear"]
and r.forge_weapon() == "spear" and r.forge_armor() == "ward")
Kit.check("道具解锁往返相等", r.item_unlocked("probe_compass") and not r.item_unlocked("rope"))
Kit.check("通关次数表往返相等", r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0)
Kit.check("首杀标记往返相等",
r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3))
Kit.check("生涯终身计数往返相等(1/0)", r.adventure_total() == 1 and r.adventure_cleared() == 0)
var s0: Dictionary = r.recent_summaries()[0]
Kit.check("冒险摘要往返相等(1 条,seed 99)",
r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3)
# ② 新档合法:不存在路径 load → true + 全零默认值
var f2 = sm_script.new(paths[1])
Kit.check("新档合法态(load true + 全零默认)",
f2.load_from_disk() and f2.keystone_balance() == 0 and f2.tree_level("sp_hp") == 0
and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty())
Kit.check("新档生涯终身计数缺省全零(迭代 18 卡④两新域——白板存档完整可玩)",
f2.adventure_total() == 0 and f2.adventure_cleared() == 0)
# ③ spend 拒绝:余额不足 false 且零副作用(钥石只增不减——消费除外)
var f3 = sm_script.new(paths[2])
f3.add_keystone(5)
Kit.check("余额 5 花 8 拒绝且余额不动", not f3.spend_keystone(8) and f3.keystone_balance() == 5)
Kit.check("余额内消费成功(5→2)", f3.spend_keystone(3) and f3.keystone_balance() == 2)
# ⑤ 高版本拒读: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])
Kit.check("高版本拒读(load false)", not f5.load_from_disk())
Kit.check("拒读后原文件字节不动", FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text)
Kit.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])
Kit.check("损坏档兜底(默认数据继续)",
f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty())
Kit.check("坏档备份 .corrupt.bak 存在", FileAccess.file_exists(paths[4] + ".corrupt.bak"))
Kit.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()
Kit.check("v0 旧档经迁移链入账(keystone_old 12 → 余额 12)",
f7.load_from_disk() and f7.keystone_balance() == 12)
Kit.check("迁移链 0→1(测试钩)→7(生产) 写盘 version == 7(v7=锻造工坊域,迭代 23 T5——v6=生涯终身计数域迭代 18 卡④)",
f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 7)
# ⑧ 域界干净:meta 永久进度域不含局内资源键(行动点/RAP/GP/道具库存/生命账本不入档——契约 2;
# 扫描收窄到 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)
Kit.check("域界干净——meta 域无局内资源键(发现 %s)" % str(leaked), leaked.is_empty())
# ⑩ 摘要 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()
Kit.check("摘要 cap 10(12 份 → 10 条)", arr.size() == 10)
Kit.check("最新在前(头 seed 11 / 尾 seed 2)",
int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2)
# ⑨ 摘要集成块(level_autoplay 真局驱动×落盘,基点 :6807–6831)——经主会话裁决三
# 改判集成测试,不随本宿主(迁移账本转办清单)。
# ⑪ 断点域:七字段写读往返逐位一致+附随件+值拷贝出户+清除+旧档迁移合法空态
var fbp = sm_script.new(paths[9])
Kit.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}],
})
Kit.check("断点:写入+写盘成功", fbp.has_breakpoint() and fbp.write_to_disk())
var rb = sm_script.new(paths[9])
Kit.check("断点:读回在档", rb.load_from_disk() and rb.has_breakpoint())
var bp: Dictionary = rb.breakpoint_snapshot()
Kit.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", {})
Kit.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)
Kit.check("断点七字段往返:GP 钱包", int(bp.get("gp", -1)) == 17)
var bp_shop: Dictionary = bp.get("shop_purchased", {})
Kit.check("断点七字段往返:商店已购档",
int(bp_shop.get("hp_small", -1)) == 2 and int(bp_shop.get("atk_small", -1)) == 1)
Kit.check("断点七字段往返:已抽模板", int(bp.get("template_pick", -1)) == 3)
Kit.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", [])
Kit.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", [])
Kit.check("断点附随件往返:道具库存",
bp_treasure.size() == 1 and String(bp_treasure[0].get("item_id", "")) == "aux_scout")
bp["hero_hp"] = 999
Kit.check("断点读面值拷贝(改读面不渗档)",
int((rb.breakpoint_snapshot() as Dictionary).get("hero_hp", -1)) == 13)
rb.clear_breakpoint()
Kit.check("断点:清除+写盘(三结局收束口径)", not rb.has_breakpoint() and rb.write_to_disk())
var rc = sm_script.new(paths[9])
Kit.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()
Kit.check("v1 旧档迁移 v7:断点域补空(合法空态,无断点域不炸)",
fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint())
Kit.check("v1 旧档迁移后写盘 version == 7(v1→v7 链式——锻造工坊域 2026-10-12)",
fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 7)
# ⑫ v2 旧档→v3 兼容(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()
Kit.check("v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)",
fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7)
Kit.check("v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)",
int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777)
var adr_v2 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(777))
Kit.check("v2 旧断点续玩受理(缺 bonus 附随件走常规重建路径——g 裁 semver 口径)",
adr_v2.resume_adventure(fv2.breakpoint_snapshot(), {"hero_override": {}}))
# ⑬ 生涯终身计数域:v5 旧档升 v6 缺省补零 + 升档幂等 + 收束自增恰一
#(真实旧档形态=计数域两键不存在;升档只增不改——既有 meta 值零触碰,存档红线 S2 断言面)
var f13 = sm_script.new(paths[11])
var fv5u := FileAccess.open(paths[11], FileAccess.WRITE)
fv5u.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": 9}}))
fv5u.close()
Kit.check("v5 旧档升 v7:计数域缺省补零且既有余额原样(迁移只增字段不改值——v5→v6→v7 链式)",
f13.load_from_disk() and f13.adventure_total() == 0 and f13.adventure_cleared() == 0
and f13.keystone_balance() == 9)
Kit.check("升档幂等:v7 档写盘再载零漂移(重复迁移零副作用——锻造工坊域同批补齐)",
f13.write_to_disk() and f13.load_from_disk()
and int(JSON.parse_string(FileAccess.open(paths[11], FileAccess.READ).get_as_text()).get("version", -1)) == 7
and f13.adventure_total() == 0 and f13.adventure_cleared() == 0)
var f13b = sm_script.new(paths[12])
f13b.load_from_disk()
f13b.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3,
"stopped_at": 0, "keystone_total": 0, "master_seed": 1})
Kit.check("收束自增恰一·通关局(三关全通 cleared=true:总局数+1 且通关局数+1)",
f13b.adventure_total() == 1 and f13b.adventure_cleared() == 1)
f13b.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": 0, "master_seed": 2})
Kit.check("收束自增恰一·中止局(放弃局:仅总局数+1,通关局数不动)",
f13b.adventure_total() == 2 and f13b.adventure_cleared() == 1)
# ⑮ 旧档破坏性废弃(契约 4:换代前结构检测=树域废弃重置,禁迁移禁折算)〔V16〕
var f15 = sm_script.new(paths[13])
var fv16 := FileAccess.open(paths[13], FileAccess.WRITE)
fv16.store_string(JSON.stringify({"version": 6, "meta": {"keystone_balance": 33,
"forge_levels": {"max_hp": 3, "atk": 4, "armor_class": 2},
"tree_nodes": {"sp_hp_r1": 3, "forge_atk": 2, "fork_posture_a": 1},
"fork_sides": {"fg_posture": "a", "fg_mastery": "b"}}}))
fv16.close()
Kit.check("换代前结构存档:树域废弃重置(旧节点 id/fork 侧/forge_levels 旧域残留清退)、钥石余额零折算零变动",
f15.load_from_disk() and f15.keystone_balance() == 33
and f15.tree_nodes().is_empty() and f15.fork_sides().is_empty())
var mp16 = load("res://core/meta_progression.gd").new(_db, f15)
f15.add_keystone(50)
Kit.check("废弃重置后后续购买正常受理(无残留脏节点)",
mp16.tree_buy("sp_hp") and f15.tree_level("sp_hp") == 1)
# ⑯ 锻造工坊域 forge_routes(V8):v6 旧档缺键补齐=初始持有态+无特效域+域界干净
var f16 = sm_script.new(paths[14])
var fv6 := FileAccess.open(paths[14], FileAccess.WRITE)
fv6.store_string(JSON.stringify({"version": 6, "meta": {"keystone_balance": 21,
"tree_nodes": {"sp_hp": 1}, "tree_struct": 2, "adventure_total": 4, "adventure_cleared": 1}}))
fv6.close()
Kit.check("v6 旧档升 v7:既有域逐位不动(钥石/树/生涯计数零折算——迁移只增不改)〔锻造工坊批 V8〕",
f16.load_from_disk() and f16.keystone_balance() == 21
and f16.tree_level("sp_hp") == 1 and f16.adventure_total() == 4 and f16.adventure_cleared() == 1)
Kit.check("v6 旧档缺键补齐=初始持有态(六路线 0 级/直购清单空/选中=sword+light)〔锻造工坊批 V8〕",
f16.forge_route_level("sword") == 0 and f16.forge_route_level("spear") == 0
and f16.forge_route_level("light") == 0 and f16.forge_route_level("ward") == 0
and f16.forge_unlocked_purchases().is_empty()
and f16.forge_weapon() == "sword" and f16.forge_armor() == "light")
var fr_keys: Array = (f16.forge_routes_snapshot() as Dictionary).keys()
fr_keys.sort()
Kit.check("forge_routes 域键集恰四项(无特效域——满级特效本期不做,契约 7)〔锻造工坊批 V8/V11〕",
fr_keys == ["armor", "levels", "unlocked", "weapon"])
var lv_keys: Array = ((f16.forge_routes_snapshot() as Dictionary).get("levels", {}) as Dictionary).keys()
lv_keys.sort()
Kit.check("levels 六路线键集恰全(sword/spear/staff/light/heavy/ward)〔锻造工坊批 V8〕",
lv_keys == ["heavy", "light", "spear", "staff", "sword", "ward"])
# 值拷贝出户(改快照不渗档——本类契约)
var snap: Dictionary = f16.forge_routes_snapshot()
(snap.get("levels", {}) as Dictionary)["sword"] = 99
Kit.check("forge_routes_snapshot 值拷贝出户(改快照不渗档)〔锻造工坊批 V8〕",
f16.forge_route_level("sword") == 0)
# ⑭ 收束链入账(真实收束挂点全链恰一:主流程 adventure_finished →
# MetaProgression.settle_adventure → apply_adventure_summary 单口;两夹具经同链入账)
var mp13 = load("res://core/meta_progression.gd").new(_db, f13b)
mp13.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 2,
"stopped_at": 3, "keystone_total": 0, "master_seed": 3})
Kit.check("收束链自增·中止局(settle_adventure 真实挂点:总局数+1、通关局数不动)",
f13b.adventure_total() == 3 and f13b.adventure_cleared() == 1)
mp13.settle_adventure({"result": "completed", "cleared": true, "levels_cleared": 3,
"stopped_at": 0, "keystone_total": 0, "master_seed": 4})
Kit.check("收束链自增·通关局(三关全通:总局数+1 且通关局数+1——全链恰一)",
f13b.adventure_total() == 4 and f13b.adventure_cleared() == 2)
# 段尾清理(测试文件绝不残留)
for p in stale:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
# ---- 三存档位承载(迭代 33 卡①:三槽往返/新档/进档/删档/窥视三态/各槽迁移/旧单档迁移)----
# 测试文件统一 user://smoke_save_slot_%d.json 模板隔离(attach_slot_template 注入口——禁各自拼槽名),
# 段首清场+段尾清理——绝不碰 user://save.json 与真实槽 user://save_slot_N.json(红线)。
func _smoke_save_slots() -> void:
print("--- SaveManager 三存档位承载(迭代 33 卡①)---")
var sm_script = load("res://core/save_manager.gd")
var slot_tpl := "user://smoke_save_slot_%d.json"
var legacy_src := "user://smoke_save_legacy_src.json"
var stale: Array = []
for i in range(1, 4):
for suf in ["", ".tmp", ".corrupt.bak"]:
stale.append(slot_tpl % i + suf)
stale.append_array([legacy_src, legacy_src + ".migrated.bak"])
for p in stale:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
var t = sm_script.new()
t.attach_slot_template(slot_tpl)
# ① 三槽往返×3+位间隔离+进档(sv1/sv5/sv10)
for i in range(1, 4):
Kit.check("sv5 进档受理·槽 %d(激活+读盘)〔卡① sv5〕" % i, t.activate_slot(i))
t.apply_adventure_summary({"result": "completed", "cleared": i == 1, "levels_cleared": i,
"stopped_at": 0, "keystone_total": 100 * i, "master_seed": i})
Kit.check("sv3 激活态写盘受理·槽 %d〔卡① sv1〕" % i, t.write_to_disk())
var t2 = sm_script.new()
t2.attach_slot_template(slot_tpl)
for i in range(1, 4):
Kit.check("sv1 三槽往返一致·槽 %d(异值按位读回)〔卡① sv1〕" % i,
t2.activate_slot(i) and t2.keystone_balance() == 100 * i
and t2.adventure_total() == 1 and t2.adventure_cleared() == (1 if i == 1 else 0))
var slot2_before := FileAccess.get_file_as_bytes(slot_tpl % 2)
t.activate_slot(1)
t.add_keystone(7)
Kit.check("sv3 写盘受理·槽 1 加值后〔卡① sv1〕", t.write_to_disk())
Kit.check("sv1 位间隔离:改槽 1 槽 2 字节不动〔卡① sv1〕",
FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_before)
# ② 删档原语(sv6):删激活槽(指针连清除)→ 删非激活槽(指针保持+他槽字节不动)
Kit.check("sv5 切激活至槽 3(删激活槽夹具前置)〔卡① sv5〕", t.activate_slot(3))
Kit.check("sv6 删档受理·激活槽 3〔卡① sv6〕", t.delete_slot(3))
Kit.check("sv6 删后文件不在盘+无 .tmp 残留〔卡① sv6〕",
not FileAccess.file_exists(slot_tpl % 3) and not FileAccess.file_exists(slot_tpl % 3 + ".tmp"))
Kit.check("sv6 删激活槽激活指针连清除(该槽不再可写)〔卡① sv6〕",
t.active_slot() == 0 and not t.has_write_target())
var slot1_before := FileAccess.get_file_as_bytes(slot_tpl % 1)
t.activate_slot(1)
Kit.check("sv6 删非激活槽受理·槽 2〔卡① sv6〕", t.delete_slot(2))
Kit.check("sv6 删非激活槽:文件不在盘+激活指针保持〔卡① sv6〕",
not FileAccess.file_exists(slot_tpl % 2) and t.active_slot() == 1)
Kit.check("sv6 删槽 2 槽 1 字节不动(位间互隔离)〔卡① sv6〕",
FileAccess.get_file_as_bytes(slot_tpl % 1) == slot1_before)
# ③ 新档原语(sv4):非白板旧内容覆盖为全零可玩白板(判别锚:新档禁读旧档——变异可红),
# 创建即激活并落盘(生产路径只在空槽调用;本段预置旧内容是对原语白板保证的钉死,超生产面)
var fold := FileAccess.open(slot_tpl % 3, FileAccess.WRITE)
fold.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 999,
"adventure_total": 5, "adventure_cleared": 2, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}}))
fold.close()
Kit.check("sv4 新档受理·槽 3(创建即激活)〔卡① sv4〕", t.create_slot(3))
Kit.check("sv4 新档=全零可玩合法白板态(旧内容 999/5 零带入——钥石/总局数/断点全零)〔卡① sv4〕",
t.active_slot() == 3 and t.keystone_balance() == 0 and not t.has_breakpoint()
and t.adventure_total() == 0)
var created: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 3))
Kit.check("sv4 新档落盘=白板内容(盘面核验非 999 旧值)〔卡① sv4〕",
int((created.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 0)
# ④ 高版本拒读槽按槽成立+激活失败恢复切换前三态(sv9/sv10)
var fhv := FileAccess.open(slot_tpl % 2, FileAccess.WRITE)
fhv.store_string(JSON.stringify({"version": 99, "meta": {}}))
fhv.close()
var hv = sm_script.new()
hv.attach_slot_template(slot_tpl)
Kit.check("sv5 进档槽 1 读回(107)〔卡① sv10〕",
hv.activate_slot(1) and hv.keystone_balance() == 107)
var slot2_hv := FileAccess.get_file_as_bytes(slot_tpl % 2)
Kit.check("sv9 高版本槽进档拒绝(拒读禁写链按槽)〔卡① sv9〕", not hv.activate_slot(2))
Kit.check("sv10 激活失败恢复切换前三态(路径/激活号/数据归槽 1)〔卡① sv10〕",
hv.active_slot() == 1 and hv.keystone_balance() == 107)
hv.add_keystone(1)
Kit.check("sv10 恢复后写盘落激活槽 1(高版本槽 2 零误写)〔卡① sv10〕",
hv.write_to_disk() and FileAccess.get_file_as_bytes(slot_tpl % 2) == slot2_hv)
# ⑤ 迁移链各槽成立(sv9):三槽各喂 v5 旧档 → 各自激活读回迁移值
for i in range(1, 4):
var fm := FileAccess.open(slot_tpl % i, FileAccess.WRITE)
fm.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": i * 11}}))
fm.close()
for i in range(1, 4):
var mi = sm_script.new()
mi.attach_slot_template(slot_tpl)
Kit.check("sv9 迁移链按槽成立·槽 %d(v5→v7 各槽独立+写盘 version=7)〔卡① sv9〕" % i,
mi.activate_slot(i) and mi.keystone_balance() == i * 11
and mi.write_to_disk())
# ⑥ 窥视三态零副作用(sv7/sv8):异值三槽逐槽三项+空槽零值+坏/高版本字节 mtime 双比对
var pv = sm_script.new()
pv.attach_slot_template(slot_tpl)
for i in range(1, 4):
var fp := FileAccess.open(slot_tpl % i, FileAccess.WRITE)
fp.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 200 * i,
"adventure_total": 5 * i, "adventure_cleared": i,
"tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}}))
fp.close()
for i in range(1, 4):
var ps: Dictionary = pv.peek_slot_summary(i)
Kit.check("sv7 窥视三项逐槽正确·槽 %d〔卡① sv7〕" % i,
bool(ps.get("readable", false)) and int(ps.get("keystone", -1)) == 200 * i
and int(ps.get("cleared", -1)) == i and int(ps.get("total", -1)) == 5 * i)
Kit.check("sv6 删槽 3 备空槽窥视〔卡① sv6〕", pv.delete_slot(3))
var pe: Dictionary = pv.peek_slot_summary(3)
Kit.check("sv7 空槽窥视=零值合法态〔卡① sv7〕",
bool(pe.get("readable", false)) and int(pe.get("keystone", -1)) == 0
and int(pe.get("cleared", -1)) == 0 and int(pe.get("total", -1)) == 0)
var fbad := FileAccess.open(slot_tpl % 1, FileAccess.WRITE)
fbad.store_string("not json{{{")
fbad.close()
var bad_bytes := FileAccess.get_file_as_bytes(slot_tpl % 1)
var bad_mtime := FileAccess.get_modified_time(slot_tpl % 1)
var pb: Dictionary = pv.peek_slot_summary(1)
Kit.check("sv8 坏槽窥视=显式损坏标记〔卡① sv8〕", not bool(pb.get("readable", true)))
Kit.check("sv8 坏槽窥视零副作用(字节+mtime 双比对不变+零 .corrupt.bak 改名)〔卡① sv8〕",
FileAccess.get_file_as_bytes(slot_tpl % 1) == bad_bytes
and FileAccess.get_modified_time(slot_tpl % 1) == bad_mtime
and not FileAccess.file_exists(slot_tpl % 1 + ".corrupt.bak"))
var fhv2 := FileAccess.open(slot_tpl % 2, FileAccess.WRITE)
fhv2.store_string(JSON.stringify({"version": 99, "meta": {}}))
fhv2.close()
var hv_bytes := FileAccess.get_file_as_bytes(slot_tpl % 2)
var hv_mtime := FileAccess.get_modified_time(slot_tpl % 2)
var ph: Dictionary = pv.peek_slot_summary(2)
Kit.check("sv8 高版本槽窥视=不可读标记〔卡① sv8〕", not bool(ph.get("readable", true)))
Kit.check("sv8 高版本窥视零副作用(字节+mtime 不变+零改名)〔卡① sv8〕",
FileAccess.get_file_as_bytes(slot_tpl % 2) == hv_bytes
and FileAccess.get_modified_time(slot_tpl % 2) == hv_mtime)
# ⑦ 旧单档迁移(sv12):字节搬运+源改名留痕+幂等+他槽零触碰
var ml = sm_script.new()
ml.attach_slot_template(slot_tpl)
var fsrc := FileAccess.open(legacy_src, FileAccess.WRITE)
fsrc.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 1234}}))
fsrc.close()
DirAccess.remove_absolute(slot_tpl % 1)
ml._migrate_legacy_save(legacy_src, slot_tpl % 1, legacy_src + ".migrated.bak")
var migrated: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 1))
Kit.check("sv12 旧档字节迁入槽 1(逐域相等——余额 1234)〔卡① sv12〕",
int((migrated.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234)
Kit.check("sv12 迁移后源改名留痕(.migrated.bak 在盘、原路径不在)〔卡① sv12〕",
not FileAccess.file_exists(legacy_src) and FileAccess.file_exists(legacy_src + ".migrated.bak"))
var s2_before := FileAccess.get_file_as_bytes(slot_tpl % 2)
var s3_before := FileAccess.get_file_as_bytes(slot_tpl % 3)
var fsrc2 := FileAccess.open(legacy_src, FileAccess.WRITE)
fsrc2.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 777}}))
fsrc2.close()
ml._migrate_legacy_save(legacy_src, slot_tpl % 1, legacy_src + ".migrated.bak")
var slot1_now: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(slot_tpl % 1))
Kit.check("sv12 幂等:槽 1 已存在不重迁(预置 777 零覆盖、源保留)〔卡① sv12〕",
int((slot1_now.get("meta", {}) as Dictionary).get("keystone_balance", -1)) == 1234
and FileAccess.file_exists(legacy_src))
Kit.check("sv12 迁移他槽零触碰(槽 2/3 字节不动)〔卡① sv12〕",
FileAccess.get_file_as_bytes(slot_tpl % 2) == s2_before
and FileAccess.get_file_as_bytes(slot_tpl % 3) == s3_before)
# 段尾清理(测试文件绝不残留)
for p in stale:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)