diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd index b2e80e7c..dd52ec98 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/smoke_all.gd @@ -3084,6 +3084,177 @@ func _smoke_meta() -> void: var brow: Dictionary = _db.meta_boss_keystone_row(li) boss_ok = boss_ok and not brow.is_empty() and int(brow.get("first_kill", 0)) >= int(brow.get("repeat_kill", -1)) check(boss_ok, "boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)") + _meta_pricing_and_buy() + _meta_settle_and_workshop() + _meta_injection() + + +# 冒烟隔离存档名册(段首清陈旧 + 段尾清理——契约:测试不污染 user://save.json 真实档) +const _META_SMOKE_PATHS := ["user://smoke_meta_buy.json", "user://smoke_meta_dry.json", + "user://smoke_meta_settle.json", "user://smoke_meta_workshop.json", + "user://smoke_meta_cap.json", "user://smoke_meta_white.json"] + + +func _meta_smoke_cleanup() -> void: + for p in _META_SMOKE_PATHS: + for suffix in ["", ".tmp", ".corrupt.bak"]: + if FileAccess.file_exists(p + suffix): + DirAccess.remove_absolute(p + suffix) + + +func _meta_new_save(path: String): + var sv = load("res://core/save_manager.gd").new(path) + sv.load_from_disk() + return sv + + +# ---- 元进度 ②几何定价 ③购买(余额充足持久 / 不足拒绝 / attr 出域拒绝)---- +func _meta_pricing_and_buy() -> void: + print("--- 元进度·几何定价与购买(隔离档 smoke_meta_buy/dry)---") + _meta_smoke_cleanup() + var sv = _meta_new_save(_META_SMOKE_PATHS[0]) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + # ② 几何定价:tier 0/1/2 价格 == 迭代 floor(p × ratio/100) 复算值(tier 0 = base_price)且严格递增 + for attr in MetaTable.FORGE_ATTRS: + var row: Dictionary = _db.meta_forge_row(attr) + var base := int(row.get("base_price", 0)) + var ratio := int(row.get("price_ratio_pct", 100)) + var all_ok := true + var prev := 0 + for tier in 3: + sv.set_forge_level(attr, tier) + var expect := base + for _i in tier: + expect = int(floor(float(expect) * float(ratio) / 100.0)) + all_ok = all_ok and mp.forge_price(attr) == expect and (tier == 0 or expect > prev) + prev = expect + check(all_ok, "%s 几何定价 tier 0/1/2 = 迭代 floor 复算且严格递增(base %d × ratio %d%%)" % [attr, base, ratio]) + sv.set_forge_level("atk", 0) + # ③ 购买成功:级+1、余额−价;buy 内含写盘——reload 后持久 + sv.add_keystone(1000) + var bal0: int = sv.keystone_balance() + var lvl0: int = sv.forge_level("atk") + var price0: int = mp.forge_price("atk") + check(mp.buy_forge("atk") and sv.forge_level("atk") == lvl0 + 1 and sv.keystone_balance() == bal0 - price0, + "购买成功(Lv %d→%d、余额 %d−%d)" % [lvl0, lvl0 + 1, bal0, price0]) + var sv_r = _meta_new_save(_META_SMOKE_PATHS[0]) + check(sv_r.forge_level("atk") == lvl0 + 1 and sv_r.keystone_balance() == bal0 - price0, "购买持久(reload 后级与余额不变)") + # ③ 余额不足 / attr 出域:false 零副作用 + var sv_dry = _meta_new_save(_META_SMOKE_PATHS[1]) + var mp_dry = load("res://core/meta_progression.gd").new(_db, sv_dry) + check(not mp_dry.buy_forge("atk") and sv_dry.keystone_balance() == 0 and sv_dry.forge_level("atk") == 0, + "余额不足 false 零副作用(0 余额购买拒绝)") + check(not mp_dry.buy_forge("luck") and sv_dry.keystone_balance() == 0, "attr 出域 false 零副作用(luck 不在词表)") + + +# ---- 元进度 ④注入可见 + 白板成立(验收 4/2/3——程序面;MC 逐位回归归 T7 全量验证)---- +func _meta_injection() -> void: + print("--- 元进度·注入可见与白板(隔离档 smoke_meta_white/dry)---") + var sv = _meta_new_save(_META_SMOKE_PATHS[1]) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + sv.add_keystone(mp.forge_price("atk")) + check(mp.buy_forge("atk") and int(mp.hero_override().get("atk", -1)) == int(_db.meta_forge_row("atk").get("flat_value", 0)), + "买 atk 1 级 → hero_override().atk == 行 flat(验收4)") + var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251)) + check(adr.start_adventure(920251, {"hero_override": mp.hero_override()}), "注入参数冒险启动(start_adventure params 通道)") + var r = adr.runner() + var ov: Dictionary = mp.hero_override() + check(int(r.effective_atk()) == int(_db.hero.atk) + int(ov.get("atk", 0)), + "首战 hero_atk = 表值 + 永久强化 flat(begin_battle 同表达式——下一局能用到强化)") + check(int(r.effective_max_hp()) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)) + and int(adr.ledger["hero_hp"]) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)), + "首战 hero_max_hp 与开局生命账本 = 表值 + 注入档") + # 白板:同种子下 空参数 = 零强化参数 = 纯表值(逐位一致——注入通道默认值漏改在此转红) + var adr_w = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251)) + check(adr_w.start_adventure(920251), "白板冒险启动(无 params 实参——B1 空参数路径)") + var sv_white = _meta_new_save(_META_SMOKE_PATHS[5]) + var adr_z = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251)) + check(adr_z.start_adventure(920251, {"hero_override": load("res://core/meta_progression.gd").new(_db, sv_white).hero_override()}), + "零强化参数冒险启动(新档 hero_override 全零)") + var rw = adr_w.runner() + var rz = adr_z.runner() + check(int(rw.effective_atk()) == int(_db.hero.atk) and int(rw.effective_max_hp()) == int(_db.hero.max_hp) + and int(rz.effective_atk()) == int(rw.effective_atk()) and int(rz.effective_max_hp()) == int(rw.effective_max_hp()) + and int(rz.effective_ac()) == int(rw.effective_ac()), + "白板成立:空参数 = 零强化参数 = 纯表值(atk/max_hp/ac 三轴逐位一致——验收 2/3)") + check(int(rw.effective_ac()) == int(_db.hero.armor_class) and int(r.effective_ac()) == int(_db.hero.armor_class), + "ac 轴:白板恒表值、未换强化时注入零位移") + _meta_smoke_cleanup() + + +# ---- 元进度 ⑥Boss 两档 ⑦钥石不丢 ⑧工坊解锁 ⑩摘要副本与 cap(合成摘要驱动——settle 纯账面零跑局)---- +func _meta_settle_and_workshop() -> void: + print("--- 元进度·收束入账与工坊(隔离档 smoke_meta_settle/workshop/cap)---") + var sv = _meta_new_save(_META_SMOKE_PATHS[2]) + var mp = load("res://core/meta_progression.gd").new(_db, sv) + var row1: Dictionary = _db.meta_boss_keystone_row(1) + var row2: Dictionary = _db.meta_boss_keystone_row(2) + # ⑥ 新档首通 L1:first = true、金额 = first_kill;total = big_reward + Σ drops;余额 = 累计入账 + var d1: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, + "stopped_at": 2, "keystone_total": 3, "master_seed": 11}) + var drop1: Dictionary = d1["boss_drops"][0] + check(bool(drop1.get("first", false)) and int(drop1.get("amount", 0)) == int(row1.get("first_kill", -1)) + and int(drop1.get("level", 0)) == 1, "新档首通 L1:boss_drops[0] {first:true, amount=first_kill}(验收5)") + check(int(d1.get("total", 0)) == 3 + int(row1.get("first_kill", 0)), "明细 total = big_reward + Σ drops") + check(sv.keystone_balance() == 3 + int(row1.get("first_kill", 0)), "余额 = 累计入账(大奖励 + Boss 两档)") + # ⑥ 同档再通 → 重复杀档且恒定不递减(第三轮同额) + var d2: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, + "stopped_at": 2, "keystone_total": 3, "master_seed": 12}) + var drop2: Dictionary = d2["boss_drops"][0] + check(not bool(drop2.get("first", false)) and int(drop2.get("amount", 0)) == int(row1.get("repeat_kill", -1)), + "再通 L1:{first:false, amount=repeat_kill}(首杀标记置位后转小额)") + var d3: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, + "stopped_at": 2, "keystone_total": 3, "master_seed": 13}) + check(not bool(d3["boss_drops"][0].get("first", true)) and int(d3["boss_drops"][0].get("amount", 0)) == int(row1.get("repeat_kill", -1)), + "重复杀第三轮恒定不递减(验收5——无衰减曲线)") + # 混合归因:通关两关 → L1 重复 + L2 首杀 + var d4: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 2, + "stopped_at": 0, "keystone_total": 4, "master_seed": 14}) + check(not bool(d4["boss_drops"][0].get("first", true)) and bool(d4["boss_drops"][1].get("first", false)) + and int(d4["boss_drops"][1].get("amount", 0)) == int(row2.get("first_kill", -1)), + "两关通关逐关归因:L1 重复档 + L2 首杀档(first_kill 金额)") + # ⑦ 败北零通关摘要:余额只增不减(零入账)、clear_counts 不变 + var bal_before: int = sv.keystone_balance() + var cc1_before: int = sv.clear_count(1) + var dz: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0, + "stopped_at": 1, "keystone_total": 0, "master_seed": 15}) + check(dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before, + "败北零通关摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)") + # ⑧ 工坊解锁:settle 内已随 refresh——总通关次数达阈值件数 == unlocked 数;重复 refresh 幂等 + var total_clears: int = sv.clear_count(1) + sv.clear_count(2) + sv.clear_count(3) + var expect_n := 0 + for row in _db.meta_workshop_items(): + if total_clears >= int(row.get("clear_threshold", 99)): + expect_n += 1 + var got_n := 0 + for st_row in mp.workshop_status(): + got_n += 1 if bool(st_row.get("unlocked", false)) else 0 + check(got_n == expect_n and mp.refresh_workshop_unlocks().is_empty(), + "工坊解锁数 = 总通关 %d 次达阈值件数(%d 件)且重复 refresh 幂等" % [total_clears, expect_n]) + var sv_r = _meta_new_save(_META_SMOKE_PATHS[2]) + var r_n := 0 + for st_row in load("res://core/meta_progression.gd").new(_db, sv_r).workshop_status(): + r_n += 1 if bool(st_row.get("unlocked", false)) else 0 + check(r_n == expect_n, "工坊解锁持久(reload 后一致)") + # ⑧ 干净档:通关 1 次 → 恰两件阈值 1 道具新解锁(new_unlocks 明细) + var sv_w = _meta_new_save(_META_SMOKE_PATHS[3]) + var mp_w = load("res://core/meta_progression.gd").new(_db, sv_w) + var u1: Dictionary = mp_w.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1, + "stopped_at": 2, "keystone_total": 0, "master_seed": 16}) + check(u1["new_unlocks"].size() == 2 and sv_w.item_unlocked("probe_dust") and sv_w.item_unlocked("sapper_order") + and not sv_w.item_unlocked("loot_compass"), "通关 1 次 → 恰两件阈值 1 道具解锁(new_unlocks 明细正确)") + # ⑩ recent_summaries:元素含 boss_drops 副本、最新在前、cap 10 + var rs: Array = sv.recent_summaries() + check(rs.size() >= 5 and String(rs[0].get("result", "")) != "" and rs[0].has("boss_drops") + and int(rs[0].get("master_seed", -1)) == 15, "摘要最新在前 + 元素含 boss_drops 副本(复盘可归因——验收5)") + var sv_c = _meta_new_save(_META_SMOKE_PATHS[4]) + var mp_c = load("res://core/meta_progression.gd").new(_db, sv_c) + for i in 12: + mp_c.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0, + "stopped_at": 1, "keystone_total": 0, "master_seed": i}) + check(sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11, + "摘要 cap 10 且最新在前(12 连 settle 截断后 seed 11 居首)") + _meta_smoke_cleanup() # ---- 战斗日志视图·行格式化呈现层同步闸(2026-09-20 真人试玩卡断 bug 回归:八型批伏击退役拆除 diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_click_probe.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_click_probe.gd index 06d866e9..f0646c99 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_click_probe.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_click_probe.gd @@ -37,7 +37,53 @@ func _ready() -> void: add_child(main) await get_tree().process_frame var t_ui_ready := Time.get_ticks_msec() - t0 - # 满配直达 Boss(基线在节点 2-4 就会败北——探针目标是 Boss 战的结算路径) + # ---- 营地购买合成点击路径(元进度实现批——campbtn/购买/关闭全走真实按钮信号路径; + # 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json);陈旧档段首清理)---- + for p0 in ["user://smoke_click_probe_camp.json", "user://smoke_click_probe_camp.json.tmp", + "user://smoke_click_probe_reset.json", "user://smoke_click_probe_reset.json.tmp"]: + if FileAccess.file_exists(p0): + DirAccess.remove_absolute(p0) + var iso_c = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp.json") + iso_c.load_from_disk() + main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c) + var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地") + if campbtn == null: + _problems.append("营地入口按钮缺失(开始层 campbtn)") + else: + campbtn.pressed.emit() # 真实按钮信号路径 → 组合根 _open_camp + await get_tree().process_frame + if not main.camp_overlay.visible: + _problems.append("营地层未打开(点击路径)") + elif main.camp_overlay._forge_rows.is_empty(): + _problems.append("锻造柜台行缺失(点击路径)") + else: + var buy_btn: Button = main.camp_overlay._forge_rows[0].btn + if not buy_btn.disabled: + _problems.append("零余额锻造购买按钮未禁用(禁用态失守)") + iso_c.add_keystone(9999) + main.camp_overlay.refresh_camp(main._camp_data()) + await get_tree().process_frame + if buy_btn.disabled: + _problems.append("余额充足后购买按钮仍禁用(禁用态未随刷新)") + var bal_before: int = iso_c.keystone_balance() + var buy_attr: String = String(main.camp_overlay._forge_rows[0].attr) + var price: int = main.meta.forge_price(buy_attr) + buy_btn.pressed.emit() # 合成购买点击 → _on_camp_buy → meta.buy_forge(写调用唯一归组合根) + await get_tree().process_frame + if int(iso_c.keystone_balance()) != bal_before - price: + _problems.append("营地购买未出账(%d→%d 应 −%d)" % [bal_before, iso_c.keystone_balance(), price]) + var iso_chk = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp.json") + iso_chk.load_from_disk() + if int(iso_chk.forge_level(buy_attr)) != 1: + _problems.append("营地购买未持久(reload 后级 != 1)") + main._on_camp_closed() + await get_tree().process_frame + if main.camp_overlay.visible or not main._start_overlay.visible: + _problems.append("营地关闭后未恢复开始层") + # 换回全新零档(营地购买态不泄入后续冒险段——确定性;档案残留下次段首清理兜底) + var iso_reset = load("res://core/save_manager.gd").new("user://smoke_click_probe_reset.json") + iso_reset.load_from_disk() + main.meta = load("res://core/meta_progression.gd").new(main.db, iso_reset) main.tune_panel.set_sliders(200, 100) var t1 := Time.get_ticks_msec() main._start_full_level() @@ -272,3 +318,14 @@ func _dump_state() -> void: str(diag.battle_result), str(diag.settling)]) print(" board_installed=%s facts_path='%s' refresh_used=%s flags=%s" % [ str(diag.minesweeper_board_installed), str(diag.facts_path), str(diag.refresh_used_round), str(_flags_placed)]) + + +# 开始层按钮按文案前缀寻址(campbtn 句柄未存组合根成员——树遍历只读;与 ui_probe 同款助手) +func _find_button_by_prefix(root: Node, prefix: String) -> Button: + if root is Button and String(root.text).begins_with(prefix): + return root + for child in root.get_children(): + var found := _find_button_by_prefix(child, prefix) + if found != null: + return found + return null diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_probe.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_probe.gd index 8d81b54f..26fd3603 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_probe.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_probe.gd @@ -13,15 +13,54 @@ func _ready() -> void: var main = load("res://presentation/main.tscn").instantiate() add_child(main) await get_tree().process_frame + var problems: Array = [] # ---- 放弃入口(2026-09-21 用户拍板——台账 119):默认隐藏(单关测试直入口无冒险可放弃)---- if main.route_overlay.abandon_btn == null or main.route_overlay.abandon_btn.visible: print("[ui_probe] FAIL — 放弃按钮默认应隐藏(单关测试直入口关)") get_tree().quit(1) return + # ---- 营地覆盖层(元进度实现批——主菜单入口/开合/余额文案/购买禁用态; + # 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json——SaveManager 路径注入纪律)---- + var iso = load("res://core/save_manager.gd").new("user://smoke_ui_probe_camp.json") + iso.load_from_disk() + main.meta = load("res://core/meta_progression.gd").new(main.db, iso) + var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地") + if campbtn == null: + problems.append("营地入口按钮缺失(开始层 campbtn)") + else: + campbtn.pressed.emit() # 合成信号路径(真实按钮 → 组合根 _open_camp) + await get_tree().process_frame + if not main.camp_overlay.visible: + problems.append("营地层未打开(campbtn 路径)") + elif main._start_overlay.visible: + problems.append("营地层打开时开始层未隐藏(规范 2.3 遮挡纪律)") + else: + if not String(main.camp_overlay._balance_label.text).begins_with("钥石"): + problems.append("营地余额文案缺失(%s)" % main.camp_overlay._balance_label.text) + if main.camp_overlay._forge_rows.size() != 3: + problems.append("锻造柜台行数错误(%d ≠ 3)" % main.camp_overlay._forge_rows.size()) + else: + var zero_disabled := true + for card in main.camp_overlay._forge_rows: + zero_disabled = zero_disabled and bool(card.btn.disabled) # 零余额全禁用 + if not zero_disabled: + problems.append("零余额下锻造购买按钮未全禁用(余额不足禁用态失守)") + if main.camp_overlay._workshop_rows.size() != 6: + problems.append("工坊柜台行数错误(%d ≠ 6)" % main.camp_overlay._workshop_rows.size()) + main._on_camp_closed() + await get_tree().process_frame + if main.camp_overlay.visible or not main._start_overlay.visible: + problems.append("营地关闭后未恢复开始层(开合往返失守)") + # 换回全新零档(营地段购买态不泄入后续冒险段——确定性;先删后建防陈旧档泄入) + for p in ["user://smoke_ui_probe_camp.json", "user://smoke_ui_probe_camp.json.tmp"]: + if FileAccess.file_exists(p): + DirAccess.remove_absolute(p) + var iso_zero = load("res://core/save_manager.gd").new("user://smoke_ui_probe_camp.json") + iso_zero.load_from_disk() + main.meta = load("res://core/meta_progression.gd").new(main.db, iso_zero) main._start_full_level() await get_tree().process_frame await get_tree().process_frame - var problems: Array = [] # ---- 放弃入口·冒险模式可见 + 战斗中按下被拒(真实按钮路径——入口挂选路停点,战斗中拒绝为防御分支)---- if main.route_overlay.abandon_btn == null or not main.route_overlay.abandon_btn.visible: problems.append("冒险模式放弃按钮未开放(选路停点入口在案)") @@ -154,3 +193,14 @@ func _ready() -> void: push_error("[ui_probe] FAIL — " + p) print("[ui_probe] FAIL — " + "; ".join(problems)) get_tree().quit(1) + + +# 开始层按钮按文案前缀寻址(campbtn 句柄未存组合根成员——树遍历只读;text 前缀匹配首枚按钮) +func _find_button_by_prefix(root: Node, prefix: String) -> Button: + if root is Button and String(root.text).begins_with(prefix): + return root + for child in root.get_children(): + var found := _find_button_by_prefix(child, prefix) + if found != null: + return found + return null