merge(master)+禁令残留清扫:并回 master 新进提交(gdstyle 闸件批/S9 卡/iter36 收尾),冲突 test-conventions §8.4 保重编号版+吸收 master 新条款「判据绑可观测提交面」(措辞去变异化);随批清扫——待办清单 gdstyle/S9 两条目「变异验证义务」改撤销证明口径;S9 活卡全量改写(契约 3/T2/D2/D4/来源行/对照表列头/审计面/两轮 grilling 记录——旧「闸件豁免」定界标注经用户全项目禁令推翻,验证义务统一撤销证明,禁令条款原文保留)

This commit is contained in:
DKChan committed 2026-10-25 01:31:21 +08:00
commit e89f486aa9
94 files changed
+4608 -3451

No files matched your search

@@ -290,7 +290,7 @@ func _smoke_encounter_roll() -> void:
# 战斗结算 roll_lo 下限 + 敌人系统 spawn count<1 fail-fast 双闸;单深度单种子是盲区,全域扫描才钉死不变式)
var depths: Array = []
for node in _level_battle_nodes(1):
depths.append(int(node.get("node_depth", -1)))
depths.append(int(node.get("depth", -1)))
var scan_bad := 0
var scan_total := 0
for i in range(12):
@@ -317,22 +317,11 @@ func _smoke_encounter_roll() -> void:
# 节点类型 → 敌人表 row_kind(非 Boss 战斗位恒 battle——深度带批寻址第三维)
func _row_kind_of(level_index: int, depth: int) -> String:
for n in _level_active_nodes(level_index):
if int(n.get("node_depth", -1)) == depth and String(n.get("type", "")) == "boss":
if int(n.get("depth", -1)) == depth and String(n.get("type", "")) == "boss":
return "boss"
return "battle"
# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标)
# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标)
func _boss_depth(level_index: int) -> int:
for n in _level_active_nodes(level_index):
if String(n.get("type", "")) == "boss":
return int(n.get("node_depth", -1))
return -1
# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字
@@ -350,18 +339,12 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int:
return best
# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义)
# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义)
# 生成图取数面(图 = f(关卡行参数档, 种子 42)——与 runner 起关同图;节点键 id/type/depth/next)
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 []
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(42)
var gen = NodeMapGenerator.new(rng)
return (gen.generate(_db.level_gen_params_row(level_index), 42) as Dictionary).get("nodes", [])
func _level_battle_nodes(level_index: int) -> Array:
@@ -596,6 +579,16 @@ func _smoke_skill_pool_data() -> void:
"3_battle_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1},
"3_battle_7_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2},
"3_boss_9_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
# 迭代 35 生成图深度扩面覆盖批八行=同关同 row_kind 顶档行的池克隆(零新池、零新技能引用):
# 战斗带顶 L1→6/L2→9/L3→13;Boss 按终点深度分带 L1→6、L2→8、L3→11/12/13
"1_battle_6_bugbear": {"attack": 6, "charge": 2, "heavy": 2},
"1_boss_6_ogre_chief": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2},
"2_battle_9_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2},
"2_boss_8_drider": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2},
"3_battle_13_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2},
"3_boss_11_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
"3_boss_12_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
"3_boss_13_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
}
var pools_ok := true
for row in _db.enemy.rows:
@@ -608,7 +601,7 @@ func _smoke_skill_pool_data() -> void:
got[String(entry.skill_id)] = int(entry.weight)
if got != expect_pools[rk]:
pools_ok = false
Kit.check("二十行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕,其余带键仍恰一行)", pools_ok)
Kit.check("二十八行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕;迭代 35 覆盖批八行为顶档池克隆,逐行同检)", pools_ok)
var basic_count_ok := true
for row in _db.enemy.rows:
var n := 0
+113 -36
View File
@@ -58,7 +58,7 @@ func _smoke_adventure_api() -> void:
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("冒险启动受理(主种子注入恰一次——state=running;种子 42=首局固定种子生成图——精确路径断言需要确定图(卡② 代差))", adr.start_adventure(42))
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 前零战斗域)",
@@ -108,26 +108,27 @@ func _api_mark_node(_node: Dictionary, _info: Dictionary, seq: Array) -> void:
func _smoke_adventure() -> void:
print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---")
var autoplay = load("res://sim/level_autoplay.gd")
# ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行集——覆盖义务断言;
# enemy 侧键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域,
# 同带多行合法,带键聚合天然兼容〔fv24〕)
# ① 表与覆盖义务(序列关数 = 行集关数;节点源 = 参数档在固定种子下的生成图,
# 每个战斗/Boss 节点经 ConfigDB 真实解析链查敌行集与棋盘行——顶 clamp 消解禁〔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 cov_miss := 0
var cov_scan := 0
for li in range(1, int(_db.level.rows.size()) + 1):
for n in _level_active_nodes(li):
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)
var d: int = int(n.get("depth", -1))
cov_scan += 1
var rk := "boss" if nt == "boss" else "battle"
var rs: Array[Dictionary] = _db.resolve_enemy_rowset(li, d, rk)
if rs.is_empty() or int(rs[0].get("depth_band", -1)) < d:
cov_miss += 1
if not (_db.resolve_board_gen(li, d) as Dictionary).is_empty():
continue
cov_miss += 1
Kit.check("生成图每个战斗/Boss 节点经解析链命中敌行集与棋盘行(扫描 %d 节点,缺失 %d)〔fv24〕" % [cov_scan, cov_miss],
cov_scan > 0 and cov_miss == 0)
# ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复)
var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
var sum: Dictionary = st["summary"]
@@ -151,7 +152,44 @@ func _smoke_adventure() -> void:
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)
# 奖励节点在生成图里是可选路位(旧单链模板的「L1 必经 1 个」形状前提随模板消亡)——
# 接线证明拆两半:图侧每关含 ≥1 奖励节点,行进侧种子扫描内确有经停(扫描零经停=三选一接线不可达)
var reward_levels := 0
for li in range(1, int(_db.level.rows.size()) + 1):
var rc := 0
for n in _level_active_nodes(li):
if String(n.get("type", "")) == "reward":
rc += 1
if rc >= 1:
reward_levels += 1
Kit.check("三关生成图各含 ≥1 奖励节点(节点三选一候选位在图)〔v4〕", reward_levels == 3)
# 行进侧两断言走真实选路入口(满配赢下途中战→岔口按偏好显式点选):奖励支路经停即呈三选一、
# 进战载荷带节点深度——确定性评分策略恒选推进支路,奖励位不在扫描路由上,故显式点选验证接线
var rw: Dictionary = _drive_to_pref(autoplay, "reward")
Kit.check("奖励分支点选→三选一候选可取且停点在途(候选 %s 件)" % str((rw.get("cands", []) as Array).size()),
bool(rw.get("pending", false)) and (rw.get("cands", []) as Array).size() >= 1)
var rw_pc: Dictionary = {}
for c in (rw.get("cands", []) as Array):
var cd := c as Dictionary
if String(cd.get("kind", "")) != "feat_ticket":
rw_pc = cd
break
if rw_pc.is_empty() and not (rw.get("cands", []) as Array).is_empty():
rw_pc = (rw.get("cands", []) as Array)[0] as Dictionary
var rw_pick_ok := false
if not rw_pc.is_empty() and rw.get("adr") != null:
rw_pick_ok = rw["adr"].apply_node_curio(String(rw_pc.get("kind", "")), String(rw_pc.get("tier", "")), String(rw_pc.get("feat_id", "")))
Kit.check("奖励三选一点选兑付受理(件=%s)" % String(rw_pc.get("kind", "")), rw_pick_ok)
var enc_run: Dictionary = _drive_to_pref(autoplay, "battle")
var enc_probe: Dictionary = enc_run.get("enc", {}) as Dictionary
var node_probe: Dictionary = enc_run.get("node", {}) as Dictionary
# 定位信息两样(契约 2):战斗侧收到的 node_depth=节点深度,且命中节点句柄≠深度——
# 变异「enc 取句柄」必红(V6)
Kit.check("进战载荷两样=关卡序号+节点深度(实测 depth=%s 句柄=%s)〔v6〕" % [str(enc_probe.get("node_depth", -1)), str(node_probe.get("id", -1))],
not enc_probe.is_empty()
and int(enc_probe.get("node_depth", -1)) == int(node_probe.get("depth", -1))
and int(enc_probe.get("level_index", -1)) == 1
and int(node_probe.get("id", -1)) != int(node_probe.get("depth", -1)))
var node_resolves := 0
for r0 in resolves:
if String((r0 as Dictionary).get("context", "")) == "reward_node":
@@ -260,7 +298,7 @@ func _smoke_adventure() -> void:
# ⑥ 重复宣布拒绝(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}))
Kit.check("重复拒绝场景:冒险启动(seed 7——生成图确定同上)", adr6.start_adventure(7))
adr6.runner().pick_next_node(int(adr6.runner().next_candidate_ids()[0])) # 入口停点 pick 进首战
var b6 = adr6.runner().current_battle()
var st6 := _adv_st()
@@ -284,13 +322,13 @@ func _smoke_adventure() -> void:
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)
Kit.check("三关商店房各到达一次(每关入口→终点路径恰穿一店的驱动投影)且首达余额 > 0(%d——GP 跨关延续)" % (shop_seq[0] if shop_seq.size() > 0 else -1), shop_seq.size() == 3 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——种子扫描语义按现行图
if not a8.start_adventure(s): # 种子扫描语义按生成图(同种子同图)
continue
if _adv_drive_to_shop(a8, _adv_st(), autoplay):
same = {"a": a8, "s": s}
@@ -335,7 +373,7 @@ func _smoke_adventure() -> void:
# ⑩ 放弃(战斗中拒绝 · 非战斗期受理同失败型结算)
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("放弃场景:冒险启动(seed 15——生成图确定同上)", a10.start_adventure(15))
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()
@@ -348,7 +386,7 @@ func _smoke_adventure() -> void:
# ⑪ 池耗即枯竭 + 池收缩 + 满血回 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}}))
Kit.check("剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)", a11.start_adventure(11, {"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":
@@ -370,7 +408,7 @@ func _smoke_adventure() -> void:
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})
a12.start_adventure(12)
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))
@@ -379,7 +417,7 @@ func _smoke_adventure() -> void:
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}}))
Kit.check("节点共享池场景:冒险启动(seed 13)", a13.start_adventure(13, {"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)
@@ -411,7 +449,7 @@ func _smoke_feats() -> void:
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}))
Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71))
var feat_tokens := 0
var tier_counts := {}
for t in a_r0._curio_deck:
@@ -465,7 +503,7 @@ func _smoke_feats() -> void:
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}))
Kit.check("专长带场景:冒险启动(seed 72——注入 5 行临时表)", a_r.start_adventure(72))
var deck_relic := 0
for t in a_r._curio_deck:
if String((t as Dictionary).get("kind", "")) == "relic":
@@ -485,7 +523,7 @@ func _smoke_feats() -> void:
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})
a_t.start_adventure(91)
var stock_before := {}
for kk in ["attack", "gp", "heal"]:
stock_before[kk] = a_t.pool_stock(kk)
@@ -769,6 +807,50 @@ func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool:
# 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集)
# 真实选路驱动到偏好支路:赢下途中战斗,遇候选含目标类型即显式点选(reward=首个奖励节点、
# battle=首个句柄≠深度的战斗节点);返回 {node, enc, cands, pending, adr}
func _drive_to_pref(autoplay, prefer: String) -> Dictionary:
var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(42))
adr.set_hero_override(900, 400)
var out := {"node": {}, "enc": {}, "cands": [] as Array, "pending": false, "adr": adr}
if not adr.start_adventure(42):
return out
var r = adr.runner()
var st := _adv_st()
var guard := 30
while r != null and guard > 0:
guard -= 1
if r.awaiting_choice():
var hit: Dictionary = {}
for c in r.next_candidates():
var cd := c as Dictionary
if prefer == "reward" and String(cd.get("type", "")) == "reward":
hit = cd
break
if prefer == "battle" and String(cd.get("type", "")) == "battle" and int(cd.get("id", -1)) != int(cd.get("depth", -1)):
hit = cd
break
if not hit.is_empty():
r.pick_next_node(int(hit.get("id", -1)))
out["node"] = hit
if prefer == "battle":
out["enc"] = r.current_battle().encounter
else:
var cands: Array = (adr.node_curio_candidates() as Dictionary).get("candidates", []) as Array
adr.present_node_curio(cands)
out["cands"] = cands
out["pending"] = not (adr._node_curio_pending as Dictionary).is_empty()
return out
var ids: Array = r.next_candidate_ids()
if ids.is_empty():
return out
r.pick_next_node(int(ids[0]))
continue
var b = r.current_battle()
if b == null:
return out
autoplay._autoplay_battle(r, b, st, null)
return out
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": "",
@@ -809,12 +891,7 @@ func _fresh_rng(seed_val: int):
# ---- ① GP 冒险钱包·到账 ----
# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义)
# 生成图取数面(图 = f(关卡行参数档, 种子 42)——与 runner 起关同图;节点键 id/type/depth/next)
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 []
var gen = NodeMapGenerator.new(_fresh_rng(42))
return (gen.generate(_db.level_gen_params_row(level_index), 42) as Dictionary).get("nodes", [])
+17 -2
View File
@@ -134,9 +134,24 @@ func _smoke_board_generator() -> void:
var row = _db.resolve_board_gen(L, 0)
Kit.check("BoardGen 行解析 (%d,0)" % L, not row.is_empty())
if L == 2:
# R1 ceil 口径:无精确行向上取整保证偏难——L2 深度 3 为恢复点无行,解析到深度 4
# 迭代 35 生成图深度扩面覆盖批随动(声明):旧口径「(2,3) 无精确行→ceil 上取到 4」的判据对象
# 是模板时代键域缺口;覆盖批补齐 0–13 后 (2,3) 有精确行,生产表上不再存在可走 ceil 的空位。
# 判据不丢——精确命中改判在册,ceil 能力用「临时摘除该行」的夹具面保留,随后原样还原。
var exact_row: Dictionary = _db.resolve_board_gen(2, 3)
Kit.check("BoardGen 精确命中 (2,3)(覆盖批键域补齐)", int(exact_row.get("node_depth", -1)) == 3)
var gap_idx := -1
for i in _db.board_gen.rows.size():
if int(_db.board_gen.rows[i].get("level_index", -1)) == 2 and int(_db.board_gen.rows[i].get("node_depth", -1)) == 3:
gap_idx = i
break
var saved: Dictionary = _db.board_gen.rows[gap_idx]
var rows_before: int = _db.board_gen.rows.size()
_db.board_gen.rows.remove_at(gap_idx)
var ceil_row: Dictionary = _db.resolve_board_gen(2, 3)
Kit.check("R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)", int(ceil_row.get("node_depth", -1)) == 4)
Kit.check("R1 ceil 能力不失(夹具摘除 (2,3)→向上取整到 4·偏难口径)", int(ceil_row.get("node_depth", -1)) == 4)
_db.board_gen.rows.insert(gap_idx, saved)
Kit.check("夹具表原样还原(行数与解析回到覆盖批现值)",
_db.board_gen.rows.size() == rows_before and int(_db.resolve_board_gen(2, 3).get("node_depth", -1)) == 3)
var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db)
Kit.check("L%d 生成产物非空" % L, b1 != null)
if b1 == null:
+45 -1
View File
@@ -147,8 +147,52 @@ func _init() -> void:
Kit.check_quiet(adr_i.runner().pick_next_node(int(adr_i.runner().next_candidate_ids()[0])), "P6 续玩选路进首战")
Kit.check_quiet(adr_i.runner().current_battle().attune_has("morning_order"), "P6 回局首战装配集仍含 morning_order(停点往返载体态一致)")
# ---- Part 7:生成图换代断言面(快照键换代、停点句柄往返、续玩按种子+参数档重生成同图)----
var rng_j = load("res://core/rng_service.gd").new()
var sm_j = SaveManagerScript.new(_bp_path)
var adr_j = AdventureRunnerScript.new(db, rng_j)
adr_j.set_save_manager(sm_j)
Kit.check_quiet(adr_j.start_adventure(910042, params), "P7 冒险启动(seed 910042——生成图开局)")
var runner_j = adr_j.runner()
var graph_j: Array = runner_j.nodes()
var bp_j: Dictionary = sm_j.breakpoint_snapshot()
Kit.check_quiet(not bp_j.has("template_pick"),
"P7 断点快照无已抽模板键(模板链路退役——件核面在档)〔v9〕")
Kit.check_quiet(int(bp_j.get("graph_gen_v2", 0)) == 1,
"P7 断点含生成图代差标记键(换代承载)〔v8〕")
var entry_id: int = int(runner_j.next_candidate_ids()[0])
var mid_id: int = _first_out_id(graph_j, entry_id)
var bp_mid: Dictionary = bp_j.duplicate(true)
bp_mid["node_id"] = mid_id
bp_mid["visited_ids"] = [entry_id]
var adr_m = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
Kit.check_quiet(adr_m.resume_adventure(bp_mid, params), "P7 中途停点断点续玩受理(句柄落档形态)")
var runner_m = adr_m.runner()
Kit.check_quiet(int(runner_m.current_node_id()) == mid_id,
"P7 停点句柄回读一致(图内 id 单空间寻址)〔v8〕")
Kit.check_quiet(runner_m.visited_ids() == [entry_id], "P7 已到节点序按句柄往返一致")
Kit.check_quiet(runner_m.nodes() == graph_j, "P7 续玩重生成图与中断前逐位同图(断点不存图快照)〔v7〕")
var adr_l = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
var bp_legacy: Dictionary = bp_mid.duplicate(true)
bp_legacy.erase("graph_gen_v2")
Kit.check_quiet(adr_l.resume_adventure(bp_legacy, params) == false,
"P7 缺生成图代差标记键的旧断点显式拒绝(禁按新句柄空间静默回放)〔v8〕")
var adr_n = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
Kit.check_quiet(adr_n.start_adventure(910042, params) and adr_n.runner().nodes() == graph_j,
"P7 同种子重开局同图(开局入口复现承载)〔v7〕")
_clear_disk()
quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归 全绿"))
quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归/生成图换代 全绿"))
# 句柄寻址夹具辅助:取生成图中指定句柄节点的首个出边目标(构造中途停点落档形态)
func _first_out_id(nodes: Array, node_id: int) -> int:
for node in nodes:
var n := node as Dictionary
if int(n.get("id", -1)) == node_id:
var outs: Array = n.get("next", [])
return int(outs[0]) if not outs.is_empty() else -1
return -1
func _board_fingerprint(b) -> String:
@@ -16,7 +16,6 @@ func _init() -> void:
_test_all_links_valid()
_test_dangling_skill_id()
_test_cast_side_mismatch()
_test_level_node_band_rules()
quit(Kit.finish("config_db_cross_validator_test"))
@@ -83,61 +82,3 @@ func _test_cast_side_mismatch() -> void:
var status := _status([{"effect_id": "st_hero_side", "target_side": "hero"}])
var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, status)
Kit.check("buff 挂主角侧状态被报(施放类 side 守卫)", _cats(errs).has("cast_side"))
# ---- ④ 节点 × 敌人表带寻址(单终点零误报/同带双 Boss 报错/顶 clamp 消解报错)----
const LevelTableScript := preload("res://data/schemas/level_table.gd")
const BoardGenTableScript := preload("res://data/schemas/board_gen_table.gd")
const RewardTableScript := preload("res://data/schemas/reward_table.gd")
const SettlementTableScript := preload("res://data/schemas/settlement_table.gd")
func _loaded(level_nodes: Array, bg_depths: Array, boss_bands: Array, battle_bands: Array) -> Dictionary:
var lv := LevelTableScript.new()
lv.rows.assign([{"level_index": 1, "display_name": "夹具关", "templates": [{"nodes": level_nodes}]}])
var bg := BoardGenTableScript.new()
var bg_rows: Array = []
for d in bg_depths:
bg_rows.append({"level_index": 1, "node_depth": int(d), "board_width": 10})
bg.rows.assign(bg_rows)
var en := EnemyTableScript.new()
var en_rows: Array = []
for b in battle_bands:
en_rows.append({"level_index": 1, "row_kind": "battle", "depth_band": int(b), "enemy_id": "goblin",
"unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]})
for b in boss_bands:
en_rows.append({"level_index": 1, "row_kind": "boss", "depth_band": int(b), "enemy_id": "ogre_chief",
"unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]})
en.rows.assign(en_rows)
var st := SettlementTableScript.new()
st.source_label_map.assign({"safe_cell": "a", "seal_hit": "b", "mine_hit": "c", "clear_reward": "d",
"hero_attack": "e", "enemy_action": "f", "seal_miss": "g"})
return {"status_effects": StatusTableScript.new(), "reward": RewardTableScript.new(),
"settlement": st, "level": lv, "board_gen": bg, "enemy": en}
func _cross_cats(loaded: Dictionary, needle: String) -> Array[String]:
var out: Array[String] = []
ValidatorScript.new().cross_validate(loaded,
func(_sev, cat, msg) -> void:
if String(msg).contains(needle):
out.append(String(cat)))
return out
func _test_level_node_band_rules() -> void:
# 正向:单终点三关同构(battle 节点落 band 0、boss 节点精确落顶带)→ ④ 零误报
var ok_nodes := [{"type": "battle", "node_depth": 0}, {"type": "boss", "node_depth": 9}]
var ok_cats := _cross_cats(_loaded(ok_nodes, [0, 9], [9], [0]), "level 1")
Kit.check("单终点图 ④ 敌侧带寻址零误报〔fv17〕", ok_cats.is_empty())
# 负向:一路两终点落同一 Boss 带(深度 3/4 都落带 4)→ rowset_rule
var same_band_nodes := [{"type": "battle", "node_depth": 0},
{"type": "boss", "node_depth": 3}, {"type": "boss", "node_depth": 4}]
var same_band_cats := _cross_cats(_loaded(same_band_nodes, [0, 3, 4], [4, 9], [0]), "同带")
Kit.check("同关双终点共带报 rowset_rule〔fv15〕",
same_band_cats.has("rowset_rule"))
# 负向:Boss 节点深度高于顶带键(顶 clamp 消解)→ level_node_dangling
var over_nodes := [{"type": "battle", "node_depth": 0}, {"type": "boss", "node_depth": 10}]
var over_cats := _cross_cats(_loaded(over_nodes, [0, 10], [9], [0]), "顶 clamp 消解")
Kit.check("Boss 节点越顶带报 level_node_dangling〔fv16〕",
over_cats.has("level_node_dangling"))
@@ -53,12 +53,12 @@ func _init() -> void:
Kit.check_quiet(lr.spend_gp(20) == true and int(lr.gp) == 10, "足额支出扣账(30→10)")
# ---- ⑥ Y 断点随存(缺陷锚:续玩自参数面整额重注入=用完 Y 退菜单再续玩满血复活)----
var bp_y := {"level_index": 1, "hero_hp": 10, "attune_refresh_y": 2}
var bp_y := {"level_index": 1, "hero_hp": 10, "attune_refresh_y": 2, "graph_gen_v2": 1}
var adr_r = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
Kit.check_quiet(adr_r.resume_adventure(bp_y, {"hero_override": {}, "attune_refresh_y": 3}), "续玩受理(最小断点+参数面)")
Kit.check_quiet(adr_r._attune_refresh_y == 2, "断点有键=Y 余量随断点还原(花 1 剩 2——不再整额重注入=满血复活堵死)")
var adr_o = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
Kit.check_quiet(adr_o.resume_adventure({"level_index": 1, "hero_hp": 10}, {"hero_override": {}, "attune_refresh_y": 3}), "旧断点续玩受理(快照缺键)")
Kit.check_quiet(adr_o.resume_adventure({"level_index": 1, "hero_hp": 10, "graph_gen_v2": 1}, {"hero_override": {}, "attune_refresh_y": 3}), "旧断点续玩受理(快照缺键)")
Kit.check_quiet(adr_o._attune_refresh_y == 3, "旧断点缺键=回退参数面整额注入(旧档行为零变化)")
quit(Kit.finish("curio_refresh_paid"))
+1 -1
View File
@@ -22,7 +22,7 @@ func _init() -> void:
quit(1)
return
var a = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
if not a.start_adventure(301, {"first_script": true}):
if not a.start_adventure(301):
Kit.check_quiet(false, "V9 前置:冒险启动")
quit(Kit.finish("feat_vouchers"))
return
+66 -39
View File
@@ -29,61 +29,71 @@ func _init() -> void:
# ---- 夹具 ----
# 图形态夹具(id = node_depth):单干 0→1→2→3→4→5→6→7 加一条 6→3 跨层跳边。
# BFS 层:0=0/1=1/2=1/3=2/4=3/5=4/6=5/7=6——锚 6 时候选 3 层距 3 = N+1。
# 节点构造器(生成图口径:id=图内唯一句柄、depth=自入口最长路径步数、next=出边句柄数组)
static func _gn(id: int, depth: int, type_: String, next_ids: Array) -> Dictionary:
return {"id": id, "type": type_, "depth": depth, "next": next_ids}
# 图形态夹具(id = 图内句柄,depth = 自入口沿边严格前进的深度):干 0→1→{2,8},2→3,
# 8→4→5→6,长跳边 6→3(与 2→3 同落节点 3,目标 depth 严格大于源——生成图连线铁律),
# 3 经尾链 9→10→11 汇入终点 7(7 的 depth = 自入口最长路径步数 10)。
# BFS 层:0=0/1=1/2=1/3=2/4=3/5=4/6=5/7=6(8=2/9=3/10=4/11=5)——锚 6 时候选 3 层距 3 = N+1。
func _graph_nodes() -> Array:
return [
{"node_depth": 0, "type": "battle", "next": [1, 2]},
{"node_depth": 1, "type": "battle", "next": [2]},
{"node_depth": 2, "type": "reward", "next": [3]},
{"node_depth": 3, "type": "rest", "next": [4]},
{"node_depth": 4, "type": "battle", "next": [5]},
{"node_depth": 5, "type": "battle", "next": [6]},
{"node_depth": 6, "type": "battle", "next": [7, 3]},
{"node_depth": 7, "type": "boss", "next": []},
_gn(0, 0, "battle", [1, 2]),
_gn(1, 1, "battle", [2, 8]),
_gn(2, 2, "reward", [3]),
_gn(3, 6, "rest", [9]),
_gn(4, 3, "battle", [5]),
_gn(5, 4, "battle", [6]),
_gn(6, 5, "battle", [7, 3]),
_gn(7, 10, "boss", []),
_gn(8, 2, "battle", [4]),
_gn(9, 7, "battle", [10]),
_gn(10, 8, "battle", [11]),
_gn(11, 9, "battle", [7]),
]
# 单链夹具(无 next 键,仿 L1 现役模板形态):编号空间 = 数组下标,node_depth 只出现在战斗/Boss 上
# 单链退化形态 = 出度 ≤1 的图(显式 next 出边、编号 = id 句柄——无数组下标隐式边)
func _chain_nodes() -> Array:
return [
{"node_depth": 0, "type": "battle"},
{"node_depth": 1, "type": "battle"},
{"node_depth": 2, "type": "battle"},
{"node_depth": 3, "type": "battle"},
{"type": "reward"},
{"type": "rest"},
{"node_depth": 4, "type": "boss"},
_gn(0, 0, "battle", [1]),
_gn(1, 1, "battle", [2]),
_gn(2, 2, "battle", [3]),
_gn(3, 3, "battle", [4]),
_gn(4, 4, "reward", [5]),
_gn(5, 5, "rest", [6]),
_gn(6, 6, "boss", []),
]
# validate 正样本(无跨层跳边的合法图:入口唯一、Boss 编号最大收尾、层宽 ≤5)
# 表行夹具的正样本(无跨层跳边的合法图——表加载期只核行级字段,图合法性判据见 validate_graph·卡② T1 换代)
func _valid_graph_nodes() -> Array:
return [
{"node_depth": 0, "type": "battle", "next": [1, 2]},
{"node_depth": 1, "type": "battle", "next": [3]},
{"node_depth": 2, "type": "reward", "next": [3]},
{"node_depth": 3, "type": "battle", "next": [4]},
{"node_depth": 4, "type": "boss", "next": []},
_gn(0, 0, "battle", [1, 2]),
_gn(1, 1, "battle", [3]),
_gn(2, 1, "reward", [3]),
_gn(3, 2, "battle", [4]),
_gn(4, 3, "boss", []),
]
# 白盒 runner:夹具行集注入 db.level + level_row,停点态直设(anchor_id < 0 = 入口停点)
func _runner(db, nodes: Array, anchor_id: int, candidates: Array,
graph_mode: bool = true, n: int = 2):
# 白盒 runner:夹具行集注入 db.level + level_row,停点态直设(anchor_id < 0 = 入口停点;
# 锚点与候选的编号空间 = 节点 id 句柄——runner._node_id 单点)
func _runner(db, nodes: Array, anchor_id: int, candidates: Array, n: int = 2):
var t := LevelTableScript.new()
t.info_visible_layers = n
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}]
db.level = t
var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes})
r._graph_mode = graph_mode
r.visited.clear()
r._candidates.assign(candidates)
r.node_index = -1
if anchor_id >= 0:
for i in nodes.size():
if int((nodes[i] as Dictionary).get("node_depth", -1)) == anchor_id:
if int((nodes[i] as Dictionary).get("id", -1)) == anchor_id:
r.node_index = i
break
return r
@@ -157,15 +167,15 @@ func _test_single_circle(db) -> void:
Kit.check("单圈只按锚层取窗:已过近端节点 4(层距 2)仍可见〔fv7〕", vis.has(4))
# ---- ⑤ 单链退化形态(编号空间 = 数组下标)〔fv1〕----
# ---- ⑤ 单链退化形态(出度 ≤1 图,编号 = id 句柄)〔fv1〕----
func _test_single_chain_entry(db) -> void:
var nodes := _chain_nodes()
var r = _runner(db, nodes, -1, [nodes[0]], false)
var r = _runner(db, nodes, -1, [nodes[0]])
var vis: Array = r.type_visible_ids()
Kit.check("单链退化形态:入口锚层 0,下标 2 可见〔fv1〕", vis.has(2))
Kit.check("单链退化形态:层距 N+1(下标 3)不可见〔fv1〕", not vis.has(3))
Kit.check("单链编号空间 = 数组下标:Boss 以末位下标 6 入集(非 node_depth 4)〔fv1〕",
Kit.check("单链退化形态:入口锚层 0,句柄 2 可见〔fv1〕", vis.has(2))
Kit.check("单链退化形态:层距 N+1(句柄 3)不可见〔fv1〕", not vis.has(3))
Kit.check("单链编号空间 = 图句柄 id:Boss 以句柄 6 入集(链上层距 4 的战斗节点 4 入雾)〔fv1〕",
vis.has(6) and not vis.has(4))
@@ -189,20 +199,37 @@ func _test_n_from_table(db) -> void:
# ---- ⑦ 续玩重建同值〔fv7〕----
func _test_resume_rebuild(db) -> void:
var nodes := _graph_nodes()
var t := LevelTableScript.new()
t.info_visible_layers = 2
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}]
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30}]
db.level = t
var ledger := {"hero_hp": 10, "gp": 0}
var resumed = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
var ok: bool = resumed.start_level_external(1, ledger, {}, Callable(), {}, false, 0,
var ok: bool = resumed.start_level_external(1, ledger, {}, Callable(), {}, false,
{"node_id": 2, "visited_ids": [0, 1, 2]})
Kit.check_quiet(ok, "续玩用例:resume_state 重建停点成功")
if not ok:
return
Kit.check_quiet(resumed.awaiting_choice(), "续玩用例:重建后处于选路停点")
var direct = _runner(db, nodes, 2, [nodes[3]])
direct.visited.assign([nodes[0], nodes[1], nodes[2]])
# 图源 = 参数化生成图(图 = f(参数档, 种子))——直入对照侧与 resume 侧同吃一张生成图,
# 停点态按句柄对齐:锚 = id 2、visited = id 0/1/2、候选 = id 2 出边指向的节点
var gnodes: Array = resumed.nodes()
var stop: Dictionary = {}
var by_id := {}
for node in gnodes:
var nid: int = int((node as Dictionary).get("id", -1))
by_id[nid] = node
if nid == 2:
stop = node
var cand_nodes: Array = []
for tid in (stop.get("next", []) as Array):
if by_id.has(int(tid)):
cand_nodes.append(by_id[int(tid)])
var visited_nodes: Array = []
for vid in [0, 1, 2]:
if by_id.has(vid):
visited_nodes.append(by_id[vid])
var direct = _runner(db, gnodes, 2, cand_nodes)
direct.visited.assign(visited_nodes)
Kit.check("续玩重建同值:resume 路径与同停点直入 type_visible_ids() 逐位一致〔fv7〕",
resumed.type_visible_ids() == direct.type_visible_ids())
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,102 @@
# tests/unit/core/level_table_validate_test.gd — 消费侧加载校验四检单测(迭代 35 卡② T1/V1)
# 覆盖:validate_graph 正例(多入口多终点/含岔口单终点/单链退化显式出边)+负例八类
#(非 Boss 终点/Boss 带出边/环=回边与自环/双独立链全员可达正证/悬空出边/句柄重复/battle 缺深度/空图)
#+「勿扩勿缩」反证:商店位与入口终点个数不在本检范围(关卡节点地图 v2.1「位置约束由冒烟断言把关」)。
# 运行:godot --headless --path game --script res://tests/unit/core/level_table_validate_test.gd(expect 0)
# 协议(tests/README):extends SceneTree + quit(code);夹具资源直构不碰生产表。
extends SceneTree
const Kit := preload("res://tests/unit/test_kit.gd")
const LevelTableScript := preload("res://data/schemas/level_table.gd")
func _init() -> void:
_test_positive()
_test_negative()
_test_scope_not_extended()
quit(Kit.finish("level_table_validate_test"))
func _n(id: int, type_: String, depth: int, outs: Array) -> Dictionary:
return {"id": id, "type": type_, "depth": depth, "next": outs}
func _errs(nodes: Array) -> Array:
return LevelTableScript.validate_graph(nodes)
func _has(errs: Array, cat: String, needle: String = "") -> bool:
for e in errs:
if String(e["category"]) != cat:
continue
if needle == "" or String(e["message"]).contains(needle):
return true
return false
# ---- 正例(四检全过)----
func _test_positive() -> void:
# 多入口多终点:两入口共汇入一岔口,两终点各在互异深度
var multi := [
_n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]),
_n(2, "battle", 1, [3, 5]),
_n(3, "rest", 2, [4]), _n(4, "shop", 3, [6]), _n(6, "boss", 4, []),
_n(5, "reward", 2, [7]), _n(7, "shop", 3, [8]), _n(8, "boss", 5, [])]
Kit.check("四检正例:多入口多终点图零错误〔v1〕", _errs(multi).is_empty())
# 含岔口单终点:岔口两侧链与主去路同深、汇入同一后继
var forked := [
_n(0, "battle", 0, [1]),
_n(1, "battle", 1, [2, 3]),
_n(2, "reward", 2, [4]), _n(3, "rest", 2, [4]),
_n(4, "shop", 3, [5]), _n(5, "boss", 4, [])]
Kit.check("四检正例:含岔口单终点图零错误〔v1〕", _errs(forked).is_empty())
# 单链退化形态合法(校验不设「必有岔口」)
var chain := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [3]), _n(3, "boss", 3, [])]
Kit.check("四检正例:单链退化图零错误(不设必有岔口)〔v1〕", _errs(chain).is_empty())
# ---- 负例九类(各报对应类别与判据消息)----
func _test_negative() -> void:
var non_boss_end := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [])]
Kit.check("负例:终点非 Boss 命中「终点恒为 Boss」〔v1〕",
_has(_errs(non_boss_end), "structural", "终点 1 非 Boss"))
var boss_out := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, [2]), _n(2, "shop", 2, [])]
Kit.check("负例:Boss 带出边被拒〔v1〕", _has(_errs(boss_out), "structural", "Boss 节点 1 带出边"))
var back_edge := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 0, [3]), _n(3, "boss", 4, [])]
Kit.check("负例:回边(目标深度不严格大于源)命中前进方向判据〔v1〕",
_has(_errs(back_edge), "structural", "违反前进方向"))
var cycle := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [1]), _n(3, "boss", 3, [])]
Kit.check("负例:环(2→1 回边)被前进方向拦下〔v1〕", _has(_errs(cycle), "structural", "违反前进方向"))
# 判据②(无孤儿无断头)与判据④(前进方向)在无环图上互斥:DAG 的每个源点都是入口、每条极长路都止于终点,
# 故 ② 是④失效时的纵深防御面,构造不出独立负例——此处以「两独立链双入口双终点」正证 ② 不误伤。
var two_components := [
_n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []),
_n(3, "battle", 0, [4]), _n(4, "rest", 1, [5]), _n(5, "boss", 2, [])]
Kit.check("判据②正证:两独立链(双入口双终点)不误伤·全员可达〔v1〕", _errs(two_components).is_empty())
var self_loop := [_n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []), _n(3, "battle", 1, [3])]
Kit.check("判据②兜底面:自环先被前进方向拦下(④ 与 ② 互斥的实证)〔v1〕",
_has(_errs(self_loop), "structural", "违反前进方向"))
var dangling := [_n(0, "battle", 0, [9]), _n(1, "boss", 1, [])]
Kit.check("负例:悬空出边报 dangling_next〔v1〕", _has(_errs(dangling), "dangling_next"))
var dup := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, []), _n(1, "shop", 2, [])]
Kit.check("负例:句柄重复报 dup_key〔v1〕", _has(_errs(dup), "dup_key"))
var no_depth := [_n(0, "battle", 0, [1]), {"id": 1, "type": "battle", "next": [2]}, _n(2, "boss", 2, [])]
Kit.check("负例:battle 节点缺深度被良构基底拒〔v1〕", _has(_errs(no_depth), "structural", "缺深度"))
Kit.check("负例:空节点集被拒〔v1〕", _has(_errs([]), "structural", "图节点集为空"))
var bad_type := [_n(0, "mine", 0, [1]), _n(1, "boss", 1, [])]
Kit.check("负例:类型出域报 enum〔v1〕", _has(_errs(bad_type), "enum"))
# ---- 「勿扩勿缩」反证:本检不含商店位与个数对参数档的核对 ----
func _test_scope_not_extended() -> void:
# 合法形状但全程无商店/三个终点同入口图——四检不该因此报错(商店位归冒烟断言、个数归产出侧)
var no_shop := [_n(0, "battle", 0, [1, 2]), _n(1, "battle", 1, [3]), _n(2, "rest", 1, [3]), _n(3, "boss", 2, [])]
Kit.check("范围反证:无商店图不在加载校验内报错(商店位由冒烟断言把关)〔v1〕", _errs(no_shop).is_empty())
var two_entries := [_n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]), _n(2, "boss", 1, [])]
Kit.check("范围反证:双入口合法(入口唯一判据已随模板池退役,个数归产出侧)〔v1〕", _errs(two_entries).is_empty())
# 良构字段缺失(无 next 键)该报错——不属被退役判据
var missing_key := [{"id": 0, "type": "battle", "depth": 0}, _n(1, "boss", 1, [])]
Kit.check("范围反证:节点缺 next 字段仍被良构基底拒〔v1〕", _has(_errs(missing_key), "structural", "缺字段 next"))
@@ -0,0 +1 @@
uid://bhymincbe2j4m
@@ -313,15 +313,15 @@ func _meta_settle_and_workshop() -> void:
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"})
{"treasure_unlocked": {"wide_scout_band": true}, "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")
Kit.check("主菜单开新冒险参数面:入口特有键(treasure_unlocked/shop_mode)随 extra 原样并入",
bool((p_new.get("treasure_unlocked", {}) as Dictionary).get("wide_scout_band", 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("主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)",
@@ -333,7 +333,7 @@ func _meta_settle_and_workshop() -> void:
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,
"graph_gen_v2": 1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
"visited_ids": []}
Kit.check("继续上局受理面:resume_adventure 受理三键参数面(尾裁微批b 受理闭环——受理不再吃白板)",
adr_res.resume_adventure(bp_min, p_resume))
@@ -662,6 +662,30 @@ func _smoke_tree_rules() -> void:
ctx_fz2.level_bucket = lvl_shared # 挂同一 level 桶引用(新形态跨战持久:无 export/seed,同字典即持久)
Kit.check("锋:限次跨战持久(level 桶共享引用——替代旧副本搬运链)〔无状态化 V4〕",
(fz2.dispatch(ctx_fz2, "mine_hit", {}, Callable()) as Array).is_empty())
# 锋·惊巢反制效果行换代断言(迭代36 u13/wa12——_grant_routed 减半分支效果行=战斗日志
# step_row 真实生产面;display_name 玩家可见,禁字「挖雷」由此行清零钉住)
var orch_nc = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777003))
orch_nc.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"}) # 真实战斗上下文(状态容器开域——dispatch 链受理)
var nc_rows: Array = [] # step_row 采集盒(lambda 捕引用容器——捕获变量赋值不出闭包)
orch_nc.step_row.connect(func(r): nc_rows.append(r))
# heal 负向行表值 power=6(极性字典行值——负向 power 走表值不走快照)→ halve 50% ceil=3
orch_nc._grant_routed({"cell_type": "heal", "power": 10}, "negative",
"mine_hit", Vector2i.ZERO, 0, "", "", 50)
# 受理判据=惊巢反制效果行落 step_row(halve 分支先于 dispatch 发行——flat 行 _dispatch_row
# 恒返 false,返回值不承载受理语义;行发行即分支受理)
var nc_hit := ""
for r in nc_rows:
if String((r as Dictionary).get("display_name", "")).contains("惊巢反制"):
nc_hit = String((r as Dictionary).get("display_name", ""))
break
Kit.check("锋:惊巢反制 mine_hit 减半分支受理(wa12 前提——halve_pct=50 效果行落 step_row)〔wa12〕",
nc_hit != "")
Kit.check("惊巢反制效果行==「惊巢反制——危险格挖掘惩罚减半(6→3)」零「挖雷」(u13 换代)〔wa12〕",
nc_hit == "惊巢反制——危险格挖掘惩罚减半(6→3)" and not nc_hit.contains("挖雷"))
# 守侧五场景(契约 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 携带)",
+5 -5
View File
@@ -34,7 +34,7 @@ func _init() -> void:
# T10——空配置验收闸(三选一池空兜底/商店空货架)
var e = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
e.start_adventure(501, {"first_script": true})
e.start_adventure(501)
e._curio_pending = {"candidates": []}
Kit.check_quiet(e.skip_empty_curio() and e._curio_pending.is_empty(),
"空配置闸:三选一池空=停点整体跳过(skip_empty_curio 消化)〔专长批 T10〕")
@@ -65,7 +65,7 @@ func _tiers_of(cands: Array) -> Array:
func check_v7(a, db) -> void:
if not a.start_adventure(201, {"first_script": true}):
if not a.start_adventure(201):
Kit.check_quiet(false, "V7 前置:冒险启动")
return
# 构成①:全池含优秀档以上——曝光槽应出 1 格高档(rank≥excellent)
@@ -110,7 +110,7 @@ func check_v7(a, db) -> void:
func check_v6(b, db) -> void:
if not b.start_adventure(202, {"first_script": true}):
if not b.start_adventure(202):
Kit.check_quiet(false, "V6 前置:冒险启动")
return
# 拿了消耗:直置停点候选后 pick_curio——牌库摘牌单点
@@ -139,7 +139,7 @@ func check_v6(b, db) -> void:
func check_v12(m, db) -> void:
if not m.start_adventure(401, {"first_script": true}):
if not m.start_adventure(401):
Kit.check_quiet(false, "V12 前置:冒险启动")
return
var tier_n := {"common": 0, "excellent": 0, "rare": 0, "legendary": 0}
@@ -157,7 +157,7 @@ func check_v12(m, db) -> void:
func check_v8(c, db) -> void:
if not c.start_adventure(203, {"first_script": true}):
if not c.start_adventure(203):
Kit.check_quiet(false, "V8 前置:冒险启动")
return
# 概率位与 cap 两属性独立:改 cap 不动牌库(概率位)、改牌库不动 cap 字段
@@ -184,7 +184,7 @@ func _smoke_save() -> void:
"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,
"graph_gen_v2": 1,
"master_seed": 424242, "rng_counter": 38, "keystone": 6,
"visited_ids": [0, 2, 5],
"aux_items": [{"item_id": "aux_scout", "source_class": "reward_node",
@@ -205,7 +205,7 @@ func _smoke_save() -> void:
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("graph_gen_v2", 0)) == 1)
Kit.check("断点附随件往返:主种子/计数器/钥石累计",
int(bp.get("master_seed", -1)) == 424242 and int(bp.get("rng_counter", -1)) == 38
and int(bp.get("keystone", -1)) == 6)
@@ -237,7 +237,7 @@ func _smoke_save() -> void:
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,
"graph_gen_v2": 1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
"visited_ids": []}}))
fv2f.close()
Kit.check("v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)",
@@ -72,14 +72,6 @@ static func _errs_has_cross_field(errs: Array) -> bool:
return false
# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标)
func _boss_depth(level_index: int) -> int:
for n in _level_active_nodes(level_index):
if String(n.get("type", "")) == "boss":
return int(n.get("node_depth", -1))
return -1
# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字
func _first_encounter_band(level_index: int, enemy_id: String) -> int:
var best := -1
@@ -94,17 +86,6 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int:
return best
# 模板感知取节点表(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 []
# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池:配额结构/件值对/池总量域检)
func _smoke_adventure_table() -> void:
print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---")
@@ -222,26 +203,31 @@ func _smoke_adventure_table() -> void:
v18_ok and locked_n == 12)
# ---- 敌人表深度带键结构(迭代 34:20 行键迁移 · 键序=迁移表序 · hobgoblin 留任 · 三 Boss 行 kind/band)----
# ---- 敌人表深度带键结构(迭代 34 建·迭代 35 覆盖批换代:28 行键序=覆盖表序·hobgoblin 留任·Boss 行集带键清单)----
func _smoke_enemy_bands() -> void:
print("--- 敌人表深度带键结构(20 行键序 · hobgoblin 留任 · 三 Boss 行)---")
# ① 20 行计数 + 键序 = 迁移表序(文件行序不变——行序即契约)
print("--- 敌人表深度带键结构(28 行键序 · hobgoblin 留任 · Boss 带键覆盖表)---")
# ① 28 行计数 + 键序 = 覆盖表序(文件行序不变——行序即契约)
# 迭代 35 生成图深度扩面覆盖批随动:战斗带顶扩到各关最大生成深度(L1→6/L2→9/L3→13)、
# Boss 行按终点深度分带(L1 6/L2 8·9/L3 11·12·13),模板时代带键 4/9/9 全部在册不动
var expect_keys: Array[String] = [
"1|battle|0|goblin", "1|battle|1|goblin", "1|battle|2|bugbear", "1|battle|3|hobgoblin",
"1|boss|4|ogre_chief",
"1|battle|6|bugbear",
"1|boss|4|ogre_chief", "1|boss|6|ogre_chief",
"2|battle|0|bugbear", "2|battle|1|bugbear", "2|battle|4|giant_spider",
"2|battle|5|bugbear", "2|battle|5|giant_spider", "2|battle|6|giant_spider", "2|battle|7|giant_spider",
"2|boss|9|drider",
"2|battle|9|giant_spider",
"2|boss|8|drider", "2|boss|9|drider",
"3|battle|0|duergar", "3|battle|1|hook_horror", "3|battle|4|hook_horror", "3|battle|5|duergar",
"3|battle|6|hook_horror", "3|battle|7|duergar", "3|boss|9|duergar_king",
"3|battle|6|hook_horror", "3|battle|7|duergar", "3|battle|13|duergar",
"3|boss|9|duergar_king", "3|boss|11|duergar_king", "3|boss|12|duergar_king", "3|boss|13|duergar_king",
]
var got_keys: Array[String] = []
for row in _db.enemy.rows:
got_keys.append("%d|%s|%d|%s" % [int(row.get("level_index", -1)), String(row.get("row_kind", "")),
int(row.get("depth_band", -1)), String(row.get("enemy_id", ""))])
Kit.check("敌人表 20 行且键序 = 迁移表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕",
_db.enemy.rows.size() == 20 and got_keys == expect_keys)
Kit.check("敌人表 28 行且键序 = 覆盖表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕",
_db.enemy.rows.size() == 28 and got_keys == expect_keys)
# ② hobgoblin 缺口行留任随迁(契约 9:不删行不补立绘——缺口登记随行)
var hob := {}
for row in _db.enemy.rows:
@@ -252,23 +238,27 @@ func _smoke_enemy_bands() -> void:
and int(hob.get("depth_band", -1)) == 3)
Kit.check("hobgoblin 缺口登记随行(表文件注释在位——缺口不静默)〔fv25〕",
FileAccess.get_file_as_string("res://data/tables/enemy.tres").contains("hobgoblin 行为缺口行"))
# ③ 三 Boss 行 kind/band 就位(band = 该关图 Boss 节点深度——单终点图数据驱动)
# ③ Boss 行集带键 = 覆盖表(旧判据「各恰一行=图 Boss 节点深度」属单终点模板时代;
# 生成图多终点要求每终点深度解析到互异带键 ⇒ 每关 Boss 行必然多于一行——
# 迭代 35 覆盖批随动改写为带键清单,「带键互异」的运行时把关在 level_shop_economy_test〔v11〕)
var boss_bands := {}
for row in _db.enemy.rows:
if String(row.get("row_kind", "")) == "boss":
var lk: int = int(row.get("level_index", -1))
if not boss_bands.has(lk):
boss_bands[lk] = []
boss_bands[lk].append(int(row.get("depth_band", -1)))
var expect_bands := {1: [4, 6], 2: [8, 9], 3: [9, 11, 12, 13]}
var boss_ok := true
var boss_detail := ""
for L in [1, 2, 3]:
var bdepth := _boss_depth(L)
var hits := 0
for row in _db.enemy.rows:
if int(row.get("level_index", -1)) == L and String(row.get("row_kind", "")) == "boss":
hits += 1
if int(row.get("depth_band", -1)) != bdepth:
boss_ok = false
boss_detail += " L%d:%d≠%d" % [L, int(row.get("depth_band", -1)), bdepth]
if hits != 1:
var got: Array = boss_bands.get(L, [])
got.sort()
if got != expect_bands[L]:
boss_ok = false
boss_detail += " L%d:boss 行×%d" % [L, hits]
Kit.check("三关 Boss 行各恰一行且 row_kind=boss、band=图 Boss 节点深度(%d/%d/%d%s)〔fv13〕" % [
_boss_depth(1), _boss_depth(2), _boss_depth(3), boss_detail], boss_ok)
boss_detail += " L%d:%s≠%s" % [L, str(got), str(expect_bands[L])]
Kit.check("三关 Boss 行集带键 = 覆盖表(模板终点 4/9/9 在册+生成图终点 6/8/9/11/12/13 扩带)%s〔fv13〕" % boss_detail,
boss_ok)
# ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行
@@ -29,7 +29,7 @@ func _run() -> void:
quit(Kit.finish("level_map_info_fog_test"))
# 白盒 runner:夹具行集注入 db.level + level_row,入口停点态(node_index=-1,候选=入边首节点)
# 白盒 runner:夹具行集注入 db.level + level_row,入口停点态(node_index=-1,候选=首层首节点句柄 1)
func _runner(db, nodes: Array, candidates: Array, n: int = 2):
var t := LevelTableScript.new()
t.info_visible_layers = n
@@ -37,7 +37,6 @@ func _runner(db, nodes: Array, candidates: Array, n: int = 2):
db.level = t
var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes})
r._graph_mode = true
r.node_index = -1
r._candidates.assign(candidates)
return r
@@ -46,7 +45,7 @@ func _runner(db, nodes: Array, candidates: Array, n: int = 2):
# ---- ① 范围外只见结构:雾下节点类型图标与文字均不绘制〔fv2〕----
func _test_fog_hides_type() -> void:
var node := {"node_depth": 3, "type": "battle"}
var node := {"id": 3, "type": "battle", "depth": 3, "next": []}
var shown: Dictionary = ScreenScript.node_display(node, 2, [2], false, false)
Kit.check("可见集内节点:图标/类型文字均绘制〔fv2〕",
bool(shown["draw_icon"]) and bool(shown["draw_label"]))
@@ -60,24 +59,24 @@ func _test_fog_hides_type() -> void:
func _test_boss_exempt_and_multi_boss() -> void:
var type_vis: Array = [1, 7, 9] # 双 Boss 图:7/9 均在可见集
var boss_a: Dictionary = ScreenScript.node_display({"node_depth": 7, "type": "boss"}, 7, type_vis, false, false)
var boss_b: Dictionary = ScreenScript.node_display({"node_depth": 9, "type": "boss"}, 9, type_vis, false, false)
var boss_a: Dictionary = ScreenScript.node_display({"id": 7, "type": "boss", "depth": 7, "next": []}, 7, type_vis, false, false)
var boss_b: Dictionary = ScreenScript.node_display({"id": 9, "type": "boss", "depth": 9, "next": []}, 9, type_vis, false, false)
Kit.check("多终点:两个 Boss 节点均带 Boss 描边〔fv3〕",
bool(boss_a["boss_border"]) and bool(boss_b["boss_border"]))
Kit.check("Boss 不入雾:可见集与图标/文字三通道均在〔fv3〕",
bool(boss_a["draw_icon"]) and bool(boss_a["draw_label"]) and bool(boss_b["draw_icon"]))
var far_boss: Dictionary = ScreenScript.node_display({"node_depth": 9, "type": "boss"}, 9, [1], false, false)
var far_boss: Dictionary = ScreenScript.node_display({"id": 9, "type": "boss", "depth": 9, "next": []}, 9, [1], false, false)
Kit.check("Boss 豁免独立于可见集:不在 type_vis 的 Boss 仍出示类型〔fv3〕",
bool(far_boss["draw_icon"]) and bool(far_boss["draw_label"]))
var fogged_battle: Dictionary = ScreenScript.node_display({"node_depth": 5, "type": "battle"}, 5, type_vis, false, false)
var fogged_battle: Dictionary = ScreenScript.node_display({"id": 5, "type": "battle", "depth": 5, "next": []}, 5, type_vis, false, false)
Kit.check("Boss 豁免不扩散:雾下非 Boss 仍隐类型〔fv3〕", not bool(fogged_battle["draw_icon"]))
# ---- ③ 节点内详情图上从不显示〔fv5〕----
func _test_details_never_shown() -> void:
var plain := {"node_depth": 3, "type": "battle"}
var loaded := {"node_depth": 3, "type": "battle", "enemy_id": "goblin_secret", "reward_id": "reward_secret"}
var plain := {"id": 3, "type": "battle", "depth": 3, "next": [4]}
var loaded := {"id": 3, "type": "battle", "depth": 3, "next": [4], "enemy_id": "goblin_secret", "reward_id": "reward_secret"}
var a: Dictionary = ScreenScript.node_display(plain, 3, [3], false, true)
var b: Dictionary = ScreenScript.node_display(loaded, 3, [3], false, true)
var same := true
@@ -92,7 +91,7 @@ func _test_details_never_shown() -> void:
# ---- ④ label_text 三态文案〔fv2〕----
func _test_label_three_states() -> void:
var node := {"node_depth": 3, "type": "battle"}
var node := {"id": 3, "type": "battle", "depth": 3, "next": [4]}
Kit.check("当前位文案=「当前位置」〔fv2〕",
String(ScreenScript.node_display(node, 3, [3], true, false)["label_text"]) == "当前位置")
Kit.check("候选文案=「战斗 · 可选」〔fv2〕",
@@ -103,9 +102,10 @@ func _test_label_three_states() -> void:
# ---- 大图(11 层×6 槽含双 Boss)画布拟合与布局包络〔fv8〕----
# 大图夹具:编号按 12 层×6 槽构造,层 0 入口、末层双 Boss;含层 1 首节点→第 3 层节点的跨层跳边。
# 跳边目标按 BFS 最短路径提前一层,其后继链随之提前——布局落 11 层(最大槽 7:
# 跳边目标并入第 2 层槽组)。断言按布局实测取最大层/最大槽,画布公式与之同源。
# 大图夹具:按 12 层×6 槽构造(id = 图内句柄 = 构造序号;depth 随构造序号严格前进——生成图连线铁律),
# 层 0 入口、末层双 Boss(id 61/62,句柄互异、next 恒空);含层 1 首节点→第 3 层节点的跨层长跳边。
# 跳边目标按 BFS 最短路径提前一层,其后继链随之逐层提前(目标并入第 2 层槽组)——布局落 11 层。
# 断言按布局实测取最大层/最大槽,画布公式与之同源。
func _big_graph_nodes() -> Array:
var layers: Array = []
var total := 0
@@ -118,30 +118,25 @@ func _big_graph_nodes() -> Array:
total += 1
layers.append(ids)
var nodes: Array = []
var jump_from := -1
var jump_to := -1
for l in tiers:
for i in layers[l].size():
var nid: int = layers[l][i]
var n := {"node_depth": nid}
if l == 0:
n["type"] = "battle"
n["next"] = layers[1].duplicate() # 入口扇出到全层(层宽 6)
elif l == tiers - 1:
n["type"] = "boss"
n["next"] = []
var t := "battle"
var next_ids: Array = []
if l == tiers - 1:
t = "boss"
elif l == 0:
next_ids = layers[1].duplicate() # 入口扇出到全层(层宽 6)
else:
if i % 5 == 0:
n["type"] = "reward" if l % 2 == 0 else "rest"
else:
n["type"] = "battle"
t = "reward" if l % 2 == 0 else "rest"
var w2: int = layers[l + 1].size()
n["next"] = [layers[l + 1][i % w2], layers[l + 1][(i + 1) % w2]]
nodes.append(n)
jump_from = layers[1][0]
jump_to = layers[3][2]
next_ids = [layers[l + 1][i % w2], layers[l + 1][(i + 1) % w2]]
nodes.append({"id": nid, "type": t, "depth": nid, "next": next_ids})
var jump_from: int = layers[1][0]
var jump_to: int = layers[3][2]
for n in nodes:
if int(n["node_depth"]) == jump_from:
if int(n["id"]) == jump_from:
(n["next"] as Array).append(jump_to)
break
return nodes
@@ -188,9 +183,9 @@ func _test_big_graph_fit(db) -> void:
var boss_ids: Array = []
for node in nodes:
if String(node["type"]) == "boss":
boss_ids.append(int(node["node_depth"]))
boss_ids.append(int(node["id"]))
Kit.check("大图呈现:两个终点 Boss 均有描边标记〔fv8〕",
disp.has(boss_ids[0]) and disp.has(boss_ids[1])
and bool(disp[boss_ids[0]]["boss_border"]) and bool(disp[boss_ids[1]]["boss_border"]))
Kit.check("大图呈现:入口停点远处战斗节点(编号 32,层 6)类型入雾〔fv8〕", not bool(disp[32]["draw_icon"]))
Kit.check("大图呈现:入口停点远处战斗节点(句柄 32,层 6)类型入雾〔fv8〕", not bool(disp[32]["draw_icon"]))
screen.queue_free()
@@ -0,0 +1,174 @@
# tests/unit/presentation/player_copy_sweep_test.gd — 玩家面文案清扫直建断言宿主(迭代36 卡A)
# 断言面 = 玩家面字符串字面量换代钉值(u1–u13 钉值表——分类词/渠道旧话/「雷」族禁字清零):
# ① hero 面板两段头(专长/属性值提升——同屏分类撞车消除)
# ② 奖励节点三选一头部(冒险牌库/渠道分流三机制事实,零「同权重/同上限/提升池」旧话)
# ③ 专长卡兜底(feat_card_text 空行兜底「专长\n???」)
# ④ 图例封印行(标空/受挖掘惩罚,零「插错/挖雷」)
# ⑤ 状态槽来源名(mine_hit→危险格——tooltip 消费半)
# ⑥ 结算来源名(settlement 表值经 ConfigDB 真装载+battle_log 行消费半)
# ⑦ 已开危险格字形(glyph「危」+plate+RED_MINE——BoardView rebuild 真实刷新路径)
# ⑧ 源 token 扫描一:board_view.gd 零 "雷" 字面量(哨兵族统一钉住)
# ⑩ 源 token 扫描二:battle_orchestrator.gd 玩家面字符串字面量零「挖雷」(注释剥离=行首 # 判)
# 扫域口径:玩家面字符串字面量(契约 2 保留面——注释/内部常量名与注记/测试消息串不入域)。
# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload,
# ConfigDB 显式实例化构造注入(统一流 master 种子 42 沿源)。
# 运行:godot --headless --path game --script res://tests/unit/presentation/player_copy_sweep_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://sim/auto_player.gd") # 自动玩家策略单一实现(夹具开域驱动)
var _db
func _init() -> void:
_db = ConfigDBScript.new()
var load_ok: bool = _db.load_tables("res://data/tables", true)
if not load_ok:
push_error("[player_copy_sweep_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])
_sweep_hero_panel()
_sweep_narrator()
_sweep_legend()
_sweep_source_names()
_sweep_board_glyph()
_sweep_token_scans()
quit(Kit.finish("player_copy_sweep_test"))
# ① C 区两段头(u1/u2——直建 hero 面板,adventure_feats_test ⑦c 族同构)
func _sweep_hero_panel() -> void:
var panel = load("res://presentation/views/hero_info_panel_view.gd").new()
var feat_head := _first_label(panel._feat_section)
var curio_head := _first_label(panel._curio_section)
Kit.check("C 区专长段头==「专长」(u1 换代——段头文案钉值)〔wa1〕",
feat_head != null and feat_head.text == "专长")
Kit.check("C 区属性值提升段头==「属性值提升」(u2 换代——同屏两段同标撞车消除)〔wa1〕",
curio_head != null and curio_head.text == "属性值提升")
# ②③ 奖励节点头部+专长卡兜底(u3/u11——静态直调)
func _sweep_narrator() -> void:
var narrator = load("res://presentation/battle_narrator.gd")
var head := String(narrator.node_curio_header())
Kit.check("奖励点头部含「冒险牌库」(u3 机制事实①同库)〔wa4〕", head.contains("冒险牌库"))
Kit.check("奖励点头部含「属性值提升与补给」(u3 机制事实②渠道分流)〔wa4〕", head.contains("属性值提升与补给"))
Kit.check("奖励点头部含「同库分流」(u3 机制事实②措辞)〔wa4〕", head.contains("同库分流"))
Kit.check("奖励点头部零「同权重」旧话(u3 清零)〔wa4〕", not head.contains("同权重"))
Kit.check("奖励点头部零「同上限」旧话(u3 清零)〔wa4〕", not head.contains("同上限"))
Kit.check("奖励点头部零「提升池」旧话(u3 清零)〔wa4〕", not head.contains("提升池"))
Kit.check("专长卡兜底==「专长\n???」(u11 换代——数据缺行真实兜底路径)〔wa3〕",
String(narrator.feat_card_text({})) == "专长\n???")
# ④ 图例封印行(u4——直建 LegendOverlay 读 ENTRIES 常量)
func _sweep_legend() -> void:
load("res://presentation/views/legend_overlay.gd").new() # 直建(_init(rap_cost) 现签名)
var seal_row: Array = []
for e in load("res://presentation/views/legend_overlay.gd").ENTRIES:
if String(e[1]) == "封印":
seal_row = e
break
Kit.check("图例封印行存在(wa5 夹具前提)〔wa5〕", not seal_row.is_empty())
Kit.check("图例封印行含「标空」(u4——与「标中」成对)〔wa5〕", String(seal_row[2] if seal_row.size() > 2 else "").contains("标空"))
Kit.check("图例封印行含「受挖掘惩罚」(u4 雷字清零换代)〔wa5〕", String(seal_row[2] if seal_row.size() > 2 else "").contains("受挖掘惩罚"))
Kit.check("图例封印行零「插错」(u4 旗族遗留词干清零)〔wa5〕", not String(seal_row[2] if seal_row.size() > 2 else "").contains("插错"))
Kit.check("图例封印行零「挖雷」(u4 雷字清零)〔wa5〕", not String(seal_row[2] if seal_row.size() > 2 else "").contains("挖雷"))
# ⑤⑥ 来源名双源(u5/u6——状态槽消费半+表值 ConfigDB 真装载+battle_log 行消费半)
func _sweep_source_names() -> void:
var row_view = load("res://presentation/views/status_slot_row.gd").new()
row_view.set_effects([{"effect_id": "alert", "source_class": "mine_hit",
"display_name": "危险格受击", "remaining_settlements": 2, "round_index": 1}])
var slot: PanelContainer = null
for child in row_view.get_children():
if child is PanelContainer and child.has_meta("eff"):
slot = child
break
Kit.check("状态槽 mine_hit 效果行在座(wa6 夹具前提)〔wa6〕", slot != null)
if slot != null:
row_view._on_slot_hover(slot)
Kit.check("状态槽来源注记==「危险格」(u5 换代——meta tooltip 消费半)〔wa6〕",
row_view._tip_label.text.contains("来源:危险格"))
row_view._hide_tip()
Kit.check("结算表 source_label_map[mine_hit]==「危险格」(u6 换代——ConfigDB 真装载)〔wa7〕",
String(_db.settlement.source_label_map.get("mine_hit", "")) == "危险格")
var log_view = load("res://presentation/views/battle_log_view.gd").new(_db)
var row_txt := String(log_view._format_row({"type": "effect", "source_class": "mine_hit",
"display_name": "挖掘反噬", "value": -5, "pool_value": -5, "round_index": 1}))
Kit.check("战斗日志 mine_hit 来源行含「(危险格)」(u6 消费半——_format_row 生产路径)〔wa7〕",
row_txt.contains("(危险格)"))
# ⑦⑧ 已开危险格字形族(u7/u8/u9——BoardView rebuild 真实刷新路径+源 token 扫描)
func _sweep_board_glyph() -> void:
var board_generator = load("res://core/board_generator.gd").new()
var b = board_generator.generate(1, 0, _mk_stream("board_gen", 4242), _db)
var ae = load("res://core/action_economy.gd").new(_db)
var ms = load("res://core/minesweeper_rules.gd").new(_db, ae)
ms.install_board(b)
ae.notify_round_start()
# 邻接雷定位(挖掘面规则=须邻接英雄——novice 驱动开域至危险格入挖掘射程;上限 60 步防冻态)
var mine_cell := Vector2i(-1, -1)
var glow_steps := 0
while glow_steps < 60:
for c in b.neighbors8(ms.hero_pos()):
if b.is_mine(c) and not ms.is_revealed(c):
mine_cell = c
break
if mine_cell.x >= 0:
break
var act: Dictionary = AutoPlayer.novice_action(ms)
if String(act.get("kind", "stop")) == "stop":
break
var op := String(act.get("kind", ""))
if op == "seal":
op = "seal_place"
ms.settle_batch([{"action": op, "cell": act.get("cell")}])
glow_steps += 1
Kit.check("棋盘夹具含挖掘射程内未开危险格(wa8 前提——实测 %d 步)〔wa8〕" % glow_steps, mine_cell.x >= 0)
if mine_cell.x >= 0:
ms.settle_batch([{"action": "dig", "cell": mine_cell}]) # 挖开危险格(真实规则路径——受击开格)
var view = load("res://presentation/views/board_view.gd").new()
view.rebuild(b, ms) # theme_projection_test.gd:200 先例式直建(缺素材程序化回退态——fresh checkout 冒烟同纪律)
var u: Node2D = view._cells[mine_cell]
Kit.check("已开危险格 glyph==「危」(u7 换代)〔wa8〕", u.glyph == "危")
Kit.check("已开危险格 plate==true(u9 牌载哨兵包含项随动)〔wa8〕", u.plate == true)
Kit.check("已开危险格 glyph_color==RED_MINE(u7 墨色不随字形换代漂移)〔wa8〕",
u.glyph_color == UiKit.RED_MINE)
# ⑧⑩ 源 token 扫描(注释剥离=行首 # 判——契约 2 保留面不入域)
func _sweep_token_scans() -> void:
var board_src := FileAccess.get_file_as_string("res://presentation/views/board_view.gd")
Kit.check("board_view.gd 源零 \"雷\" 字面量(u7/u8/u9 哨兵族统一钉住)〔wa8〕",
board_src.count("\"雷\"") == 0)
var orch_src := FileAccess.get_file_as_string("res://core/battle_orchestrator.gd")
var orch_hits := 0
for line in orch_src.split("\n"):
if line.strip_edges().begins_with("#"):
continue
if line.contains("挖雷"):
orch_hits += 1
Kit.check("battle_orchestrator.gd 玩家面字符串字面量零「挖雷」(u13——注释保留面不入域)〔wa12〕",
orch_hits == 0)
func _first_label(node: Node) -> Label:
for child in node.get_children():
if child is Label:
return child
return null
func _mk_stream(stream_name: String, scope: int) -> RandomNumberGenerator:
# stream_name = 调用点自述标签(不参与派生),保留参数零改调用方(theme_projection_test 同构)
var rg := RandomNumberGenerator.new()
var svc = load("res://core/rng_service.gd").new()
svc.set_master_seed(42)
rg.seed = svc.derive_at(scope)
return rg
@@ -0,0 +1 @@
uid://bavqeaorfbb1j
@@ -1,7 +1,8 @@
# tests/unit/presentation/theme_projection_test.gd — 呈现投影与主题单元测试宿主
# 断言面 = 十套(批 B 断言迁移自 smoke_all.gd,G11 施工组;十套按源编排序迁入):
# 断言面 = 十一套(批 B 断言迁移自 smoke_all.gd,G11 施工组;十套按源编排序迁入+开场行后缀尾接协议扩段):
# 未开格类型微光(作用集恒等/九型归组封闭/零玩法写入——board_view 微光数据面)
# 战斗日志视图行格式化(行协议 9 类呈现层同步闸——真人试玩卡断 bug 回归)
# 开场行后缀尾接协议(level_open_line 第五参布尔直控「(含面板加成)」尾接——签名零变)
# UI 全局主题(main_theme.tres 挂载/类型覆盖/滑轨两段对比/退役实现零残留/中文字体防御)
# UI 按钮五态矩阵(四工厂五态样式盒逐实例 override)
# UI 全局纹理过滤(工程 Nearest+平滑渐变件 LINEAR 白名单+光柱 alpha 结构)
@@ -42,6 +43,7 @@ func _init() -> void:
# 套执行序沿源编排序(_run_core_smoke 序 + _smoke_meta 薄壳尾序)
_smoke_type_glow()
_smoke_battle_log_view()
_smoke_level_open_line()
_smoke_global_theme()
_smoke_button_five_states()
_smoke_texture_filter()
@@ -420,6 +422,19 @@ func _smoke_battle_log_view() -> void:
view.parsed_text().length() > before)
# ---- 开场行后缀尾接协议(迭代36 wc1 静态半——level_open_line 第五参布尔直控尾接,签名零变;
# 该函数此前零既有断言,尾接行为由此钉住)----
func _smoke_level_open_line() -> void:
var NarratorScript := load("res://presentation/battle_narrator.gd")
Kit.check("开场行 boosted=true 尾接「(含面板加成)」〔wc1〕",
String(NarratorScript.level_open_line("第一层", 42, 5, 30, true)).contains("(含面板加成)"))
Kit.check("开场行 boosted=false 零后缀(白板局不带)〔wc1〕",
not String(NarratorScript.level_open_line("第一层", 42, 5, 30, false)).contains("(含面板加成)"))
Kit.check("开场行种子/两值带入不受后缀位影响(数值带入回归)〔wc1〕",
String(NarratorScript.level_open_line("第一层", 42, 5, 30, true)).contains("种子 42")
and String(NarratorScript.level_open_line("第一层", 42, 5, 30, true)).contains("攻击 5 / 生命 30"))
# ---- UI 全局主题冒烟(需求卡片 §7 验收清单 V1-V6)----
# V1 挂载覆盖 / V2 滑轨两段 / V3 预盖件运行时证词 / V4 旧形态零残留 /
# V5 回归(本冒烟全绿即回归网)/ V6 中文字体防御(挂主题后 default_font 顶掉 custom_font 也不丢字形)
@@ -731,63 +746,64 @@ func _smoke_diag_button_retired() -> void:
dbg_src.contains('UiKit.button("转储")'))
# ---- 状态面板只读投影一致性(R4 数据只读同源:状态面板展示值逐项 == MetaProgression
# 访问器读数;投影拼装与 main._status_data 同构(同访问器族同序)。只读性连接巡检与零动效
# 断言归 ui_struct_probe 呈现侧,本段只管数据面)----
# ---- 状态面板·本局信息面板投影一致性(迭代36 卡③——八键快照手拼夹具:展示值逐项 ==
# 快照钉值;投影拼装口径=组合根 _status_data 八键(数据只读同源)。只读性连接巡检与零动效
# 断言归 ui_struct_probe 呈现侧,本段只管数据面;跨关解锁概览断言族随面板换代退役——
# 替换判据=sv5 零旧段+八键投影断言(实现报告 §8.3 申报))----
func _status_panel_projection() -> void:
print("--- 状态面板·只读投影一致性(隔离档 smoke_meta_buy 复用)---")
_meta_smoke_cleanup()
var sv = _meta_new_save(_META_SMOKE_PATHS[0])
var mp = load("res://core/meta_progression.gd").new(_db, sv)
# 已购态夹具:余额非零 + 秘宝匣首件解锁——投影可辨非全零白板
sv.add_keystone(37)
sv.unlock_item("morning_order")
sv.write_to_disk()
# 投影拼装(main._status_data 同构)+ 视图喂入
var data := {"balance": mp.keystone_balance(), "workshop": mp.treasure_shop_status(),
"tree": mp.tree_camp_rows(), "fork_groups": mp.tree_fork_groups()}
print("--- 状态面板·本局信息面板投影一致性(迭代36 卡③——八键快照手拼夹具,同构口径不依赖 runner)---")
_meta_smoke_cleanup() # 套件级夹具清场(生涯页 fixture 依赖此前置——旧状态段同款保留)
# 夹具(代拍板 4——任意非零可辨值,禁读 data/tables 现值、禁与属性行断言共值致误判掩盖)
var data := {"balance": 77, "hero_hp": 13, "max_hp": 29, "atk": 7, "ac": 11, "gp": 41,
"curios": [{"name": "青苔密卷", "used": false}, {"name": "锈蚀怀表", "used": true}],
"feats": ["疼痛记忆 ×2", "双管镐 ×1"]}
var po = load("res://presentation/views/status_panel.gd").new()
po.open_status(data)
Kit.check("状态面板开层可见(open_status 直开——P4 甲零动效路径)", po.visible)
Kit.check("余额徽投影 = keystone_balance() 读数(%s)" % po._balance_badge.value_label.text,
String(po._balance_badge.value_label.text) == "钥石 %d" % mp.keystone_balance())
var ws: Array = mp.treasure_shop_status()
var ws_ok: bool = po._workshop_holder.get_child_count() == ws.size()
var uikit_s = load("res://presentation/views/ui_kit.gd")
for i in mini(ws.size(), po._workshop_holder.get_child_count()):
var row: Dictionary = ws[i]
var lbls2: Array = []
for ch in po._workshop_holder.get_child(i).get_children():
if ch is Label:
lbls2.append(ch)
var unlocked: bool = bool(row.get("unlocked", false))
var expect_prog := "已入手"
if not unlocked:
if String(row.get("mode", "")) == "keystone":
expect_prog = "钥石 %d" % int(row.get("price", 0))
elif String(row.get("mode", "")) == "condition":
var parts: Array[String] = []
for req_v2 in (row.get("reqs", []) as Array):
var req2: Dictionary = req_v2 as Dictionary
var lbl2 := String(uikit_s.TREASURE_REQ_LABELS.get(String(req2.get("key", "")), "条件"))
parts.append("%s %d/%d" % [lbl2, int(req2.get("progress", 0)), int(req2.get("threshold", 0))])
expect_prog = "、".join(parts)
var expect_st2 := "已解锁" if unlocked else "未解锁"
ws_ok = ws_ok and lbls2.size() == 3 \
and String(lbls2[0].text) == String(row.get("display_name", "")) \
and String(lbls2[1].text) == expect_prog \
and String(lbls2[2].text) == expect_st2
Kit.check("秘宝匣 12 行投影 = treasure_shop_status() 逐项(名称/钥石价与条件进度/解锁态——首件解锁夹具在列)〔秘宝批 V19 呈现数据源〕",
ws_ok)
var owned := 0
for trow in (mp.tree_camp_rows() as Array):
if int(trow.get("level", 0)) > 0:
owned += 1
var tree_txt := String(po._tree_label.text)
Kit.check("天赋树概览聚合文案与已购节点数一致(零已购=空态句)",
tree_txt == "尚未购买任何节点(天赋树在营地主柜台)" or tree_txt.begins_with("已购 %d 项:" % owned))
Kit.check("余额徽投影 = 快照 balance 读数(sv4 回归锚)〔sv4〕",
String(po._balance_badge.value_label.text) == "钥石 77")
Kit.check("属性行全值投影 = 契约4 钉值格式逐字(生命/上限/攻击/AC/GP 五现值)〔sv1〕",
String(po._attrs_label.text) == "生命 13/29 · 攻击 7 · 护甲 AC 11 · GP 41")
Kit.check("秘宝行投影 = 受理序 join+已用件「(已用)」尾缀〔sv2〕",
String(po._curios_label.text) == "青苔密卷 · 锈蚀怀表(已用)")
Kit.check("专长行投影 = 行串 join〔sv3〕",
String(po._feats_label.text) == "疼痛记忆 ×2 · 双管镐 ×1")
# 零旧段(sv5——跨关概览退役零残留:面板子树零旧文案+旧成员退役)
var stale := ""
for ch in _all_labels(po):
if String(ch.text).contains("道具工坊") or String(ch.text).contains("天赋树"):
stale += String(ch.text) + "|"
Kit.check("面板子树零「道具工坊/天赋树」文案(跨关概览退役零残留)〔sv5〕", stale == "")
Kit.check("旧段成员退役(_workshop_holder/_tree_label 零残留——契约2 数据/成员层面清零)〔sv5〕",
po.get("_workshop_holder") == null and po.get("_tree_label") == null)
# 空态句×2(curios/feats 空夹具各跑一遍——契约4 空态呈现)
var data_empty := {"balance": 3, "hero_hp": 5, "max_hp": 6, "atk": 2, "ac": 9, "gp": 1,
"curios": [], "feats": []}
var po2 = load("res://presentation/views/status_panel.gd").new()
po2.open_status(data_empty)
Kit.check("空秘宝态句=「未携带秘宝」〔sv2〕", String(po2._curios_label.text) == "未携带秘宝")
Kit.check("空专长态句=「未获专长」〔sv3〕", String(po2._feats_label.text) == "未获专长")
# 提示行文案(sv8——新定位换代;旧「永久强化进度」半句随两段退役失效)
var hint_txt := ""
for ch in _all_labels(po):
if String(ch.text).begins_with("查看本局"):
hint_txt = String(ch.text)
Kit.check("提示行=「查看本局冒险进度(只读)」(新定位换代)〔sv8〕",
hint_txt == "查看本局冒险进度(只读)")
po.visible = false
po.free()
po2.visible = false
po2.free()
# 面板子树 Label 收集(sv5 零旧段扫描辅助——递归全子树)
func _all_labels(root: Node) -> Array:
var out: Array = []
if root is Label:
out.append(root)
for child in root.get_children():
out.append_array(_all_labels(child))
return out
# ---- 生涯页只读投影一致性(汇总段六类九项逐行 == SaveManager 计数访问器/
@@ -17,6 +17,7 @@ func _init() -> void:
if f != null:
Kit.check("中文字形在集(攻/雷/旗/封)", f.has_char("攻".unicode_at(0)) and f.has_char("雷".unicode_at(0)) \
and f.has_char("旗".unicode_at(0)) and f.has_char("封".unicode_at(0)))
Kit.check("中文字形在集(危——已开危险格字形换代前置,迭代36 u12/wa9)", f.has_char("危".unicode_at(0)))
Kit.check("ASCII/数字字形在集(A/0/%)", f.has_char("A".unicode_at(0)) and f.has_char("0".unicode_at(0)) and f.has_char("%".unicode_at(0)))
var sz := 18
var w_cn: float = f.get_string_size("战斗胜利", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x