Files
gplan-2/game/smoke/smoke_all.gd
T
DKChan d1b2e519ce feat(调试): DS_DEBUG 环境变量门控写死=true——用户令「直接写死,不读环境变量」(2026-10-16)
- main.gd:219 原 OS.get_environment("DS_DEBUG")=="1" 读取退役,_debug_entry 恒 true
  (启动即开浮窗+两屏角标;var 注释与 6 处会话门控注释同步换代)
- 闸断言换代:smoke V2f 由「态==环境变量」改「恒开」断言;ui_click_probe 原
  「未设时浮窗不得默认开」反转为「未恒开即失守」;ui_tune_probe 删自设环境变量
  死代码(OS.set_environment 行)+三处消息/注释换代
- 其余 6 文件 10 处注释/文案清扫(ui_assembler/debug_overlay 含浮窗内用户可见
  工具提示/modal_stack/board_zone_view/level_map_screen/README)——全库 DS_DEBUG
  引用清零,无新旧并存
- 发布前删除事项:main.gd:219 一行(用户知悉;无 CI,两道恒开断言会红兜底)
2026-10-16 03:08:31 +08:00

6518 lines
403 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# headless 冒烟入口(承数据配置冒烟框架:extends SceneTree + autoload 显式实例化)。
# 运行:Godot_console.exe --headless --path <proj> --script res://smoke/smoke_all.gd
extends SceneTree
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现
const ConfigDBScript := preload("res://core/config_db.gd") # T6 收口:表宿主 preload 常量(原 _initialize 运行期 load)
var _failures := 0
var _checks := 0
func check(cond: bool, label: String, quiet: bool = false) -> void:
_checks += 1
if cond:
if not quiet:
print(" [PASS] " + label)
elif quiet:
_failures += 1
print(" [FAIL] (silent check)") # 原 check_silent 输出逐位保持
else:
_failures += 1
print(" [FAIL] " + label)
# WCAG 相对亮度/对比度(迭代 25 UI 全局主题批 V2——滑轨两段对比度断言用)
func _theme_rel_luminance(c: Color) -> float:
var lin := func(ch: float) -> float:
return ch / 12.92 if ch <= 0.03928 else pow((ch + 0.055) / 1.055, 2.4)
return 0.2126 * lin.call(c.r) + 0.7152 * lin.call(c.g) + 0.0722 * lin.call(c.b)
func _theme_contrast(a: Color, b: Color) -> float:
var la := _theme_rel_luminance(a)
var lb := _theme_rel_luminance(b)
return (maxf(la, lb) + 0.05) / (minf(la, lb) + 0.05)
func _initialize() -> void:
print("=== Dungeon Sweeper 垂直切片冒烟 ===")
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
check(load_ok, "全部表加载 + 校验通过(含跨表护栏)")
if not load_ok:
for d in db.diagnostics():
if d.severity == "error":
print(" error: [%s] %s" % [d.category, d.message])
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(42)
# 统一流黄金向量(数据配置 AC-3——2026-09-27 统一流裁决单盐重登记:单盐沿用并流前 board_gen 盐,
# scope 0-3 与旧 R3 board_gen 向量逐位连续;Python 参考实现逐位交叉验证一致;并流前分盐向量随批作废)
check(rng.derive_at(0) == 8672514112661456564, "统一流向量 m=42 scope=0(=旧 board_gen scope0 连续性锚)")
check(rng.derive_at(1) == 8834291164406037215, "统一流向量 m=42 scope=1")
check(rng.derive_at(2) == 7714373262647006282, "统一流向量 m=42 scope=2")
check(rng.derive_at(3) == 1280102845971471347, "统一流向量 m=42 scope=3")
check(rng.derive_at(4) == 82585501446482399, "统一流向量 m=42 scope=4")
check(rng.derive_at(5) == 8078924421474470922, "统一流向量 m=42 scope=5")
check(rng.derive_at(6) == 6638052740430622424, "统一流向量 m=42 scope=6")
check(rng.derive_at(7) == 8264196488417721294, "统一流向量 m=42 scope=7")
check(rng.derive_at(0) == rng.derive_at(0), "derive_at 纯函数恒等")
# scope 取号语义:首个 open_stream = 全局 scope 0;计数器单调;用途标签不参与派生(只进事件日志)
var s0 = rng.open_stream("board_gen")
var s1 = rng.open_stream("encounter")
check(rng.counter() == 2, "open_stream 全局计数器单调 +1 ×2")
check(s0.seed == rng.derive_at(0) and s1.seed == rng.derive_at(1), "首个取号 = scope 0(顺序确定)")
rng.new_adventure(4242)
var s2 = rng.open_stream("gp_drop")
check(rng.counter() == 1 and s2.seed == rng.derive_at(0), "new_adventure 重置全局计数器")
check(FileAccess.file_exists("user://logs/rng_log_4242.jsonl"), "事件日志会话落盘(2026-09-27 统一流裁决)")
if load_ok:
_run_core_smoke(db, rng)
_split_tail() # 迭代 27 T1 V2/V3——场景拆分+会话物化断言(挂树帧推进型异步尾段;摘要与退出码随尾段落)
# ---- 场景拆分/会话物化异步尾段(迭代 27 T1——autoload 挂载需过 _initialize 后首帧〔run_all.gd 头注时序〕,
# 挂 main.tscn 的断言在帧后执行;摘要打印与 quit 从 _initialize 移本尾段)----
func _split_tail() -> void:
await process_frame
await process_frame
var autoload_ok: bool = get_root().get_node_or_null("/root/ConfigDB") != null and get_root().get_node_or_null("/root/SaveManager") != null
_smoke_type_gate()
_smoke_hygiene_gates()
if autoload_ok:
await _smoke_scene_split() # 协程函数必须 await(无 await=发射后不管,首个 await 后即被 quit 截断)
await _smoke_boot_chain()
await _smoke_run_lifecycle()
await _smoke_lifecycle_matrix()
else:
check(false, "V2/V3 前置:autoload 链未就绪(ConfigDB/SaveManager)")
print("=== 冒烟完成:%d 项检查,%d 项失败 ===" % [_checks, _failures])
quit(1 if _failures > 0 else 0)
# ---- 类型闸断言(迭代 27 T5 V7——project.godot warning 配置在位〔源码断言〕+审计器脚本在位;
# 审计器归零判定走 static_scan S9 闸机,此处兜配置面)----
func _smoke_type_gate() -> void:
var pj: String = FileAccess.get_file_as_string("res://project.godot")
check(pj.contains("gdscript/warnings/untyped_declaration=1"),
"V7 类型闸 warning 配置在位(untyped_declaration=1 显形)〔迭代27 T5〕")
var scan_src: String = FileAccess.get_file_as_string("res://tests/static_scan.sh")
check(scan_src.contains("audit_type_hints.py"),
"V7 类型审计器闸机在位(static_scan S9 → tools/audit_type_hints.py)〔迭代27 T5〕")
# ---- 卫生件 grep 闸(迭代 27 T6 V8——死代码/字符串 Callable/影子账残留零命中;源码扫描面)----
func _smoke_hygiene_gates() -> void:
var callables := 0
for d in ["res://core", "res://presentation", "res://data"]:
var dir := DirAccess.open(d)
if dir == null:
continue
dir.list_dir_begin()
var f := dir.get_next()
while f != "":
if dir.current_is_dir() and not f.begins_with("."):
var sub := DirAccess.open(d + "/" + f)
if sub != null:
sub.list_dir_begin()
var g := sub.get_next()
while g != "":
if g.ends_with(".gd"):
var t: String = FileAccess.get_file_as_string(d + "/" + f + "/" + g)
callables += t.count("Callable(self, \"")
g = sub.get_next()
elif f.ends_with(".gd"):
var t2: String = FileAccess.get_file_as_string(d + "/" + f)
callables += t2.count("Callable(self, \"")
f = dir.get_next()
check(callables == 0,
"V8 字符串 Callable 产品码零命中(实测 %d——方法引用换装闸)〔迭代27 T6〕")
check(not FileAccess.file_exists("res://core/feats_engine.gd"),
"V8 FeatsEngine 整文件退役(class_name 注销=文件删除)〔迭代27 T6〕")
var bo: String = FileAccess.get_file_as_string("res://core/battle_orchestrator.gd")
check(not bo.contains("func _attune_row"),
"V8 死代码抽查·_attune_row 零残留〔迭代27 T6〕")
var sm: String = FileAccess.get_file_as_string("res://core/save_manager.gd")
check(not sm.contains("func attunement_counters"),
"V8 死代码抽查·attunement_counters 零残留〔迭代27 T6〕")
var mv: String = FileAccess.get_file_as_string("res://presentation/main.gd")
check(not mv.contains("gp_shadow_ledger") and not mv.contains("_gp_ledger"),
"V8 GP 影子账残留零命中(main 源码面——文件已删+委托缝已拆)〔迭代27 T3〕")
# V1d 层序落位单口:main.gd 的 move_child 调用全部收在 _apply_layer_contract 内(合同装配单口——
# 函数体之外零调用点;出现第二处落位=硬编码层序,须改合同表而非加调用)
var fn_start: int = mv.find("func _apply_layer_contract")
var fn_end: int = mv.find("\nfunc ", fn_start + 10)
if fn_end < 0:
fn_end = mv.length()
var outside_calls: int = (mv.substr(0, fn_start) + mv.substr(fn_end)).count("move_child(")
check(outside_calls == 0,
"V1d 层序落位单口(_apply_layer_contract 之外 move_child 调用点=%d 应=0)〔装配生命批 V1〕" % outside_calls)
# ---- 三屏场景化断言(迭代 27 T1 V2——独立实例化+关键节点在位+组合根树序/z 序保序)----
func _smoke_scene_split() -> void:
print("--- 迭代27 T1 三屏场景化(V2:屏壳独立实例化+组合根装配保序)---")
# ① 三屏壳独立实例化(F6 等价——headless 直挂;视图代码构建在屏壳 _ready)
var menu: Node = load("res://presentation/scenes/menu_screen.tscn").instantiate()
root.add_child(menu)
await process_frame
check(menu.start_overlay != null and menu.lvbtn != null and menu.resumebtn != null and menu.campbtn != null and menu.careerbtn != null and menu.settingsbtn != null and menu.quitbtn != null,
"V2 菜单屏壳独立实例化(start_overlay+操作组六钮在位)")
check(menu.start_overlay.visible, "V2 菜单层默认可见(非模态全屏态)")
check(menu.mouse_filter == Control.MOUSE_FILTER_IGNORE, "V2 屏壳根零鼠标截获(IGNORE——全屏 STOP 壳吃输入病灶回归闸)")
menu.queue_free()
var camp: Node = load("res://presentation/scenes/camp_screen.tscn").instantiate()
root.add_child(camp)
await process_frame
check(camp.camp_overlay != null, "V2 营地屏壳独立实例化(camp_overlay 在位)")
check(not camp.camp_overlay.visible, "V2 营地层默认隐藏(模态纪律)")
camp.queue_free()
var adv: Node = load("res://presentation/scenes/adventure_screen.tscn").instantiate()
root.add_child(adv)
await process_frame
check(adv.battle_root != null and adv.stage != null and adv.board_zone != null and adv.board_view != null and adv.ap_bar != null and adv.level_screen != null,
"V2 冒险屏壳独立实例化(战斗宿主+四区句柄+关卡地图屏在位)")
adv.queue_free()
await process_frame
# ② 组合根装配(合同单点化——LAYER_CONTRACT 唯一事实源:树序=登记序=z 序同源派生)
var main: Node = load("res://presentation/main.tscn").instantiate()
root.add_child(main)
await process_frame
# V1a 启动态顶层断言:boot 后自树顶向下第一个可见内容层=start_overlay(headless 可判)
#(内容层遍历——屏壳是容器不当候选:camp 壳 boot 态 vis=true 但成员恒隐,裸树遍历会误命中)
var top: Node = _topmost_visible_layer(main)
check(top == main._start_overlay,
"V1a 启动态顶层可见层=start_overlay(实测顶层=%s——菜单被冒险屏盖住即红)〔装配生命批 V1〕"
% (top.name if top != null else "null"))
# V1b 语义关系:start_overlay 在冒险屏内容之上(菜单屏壳在冒险屏壳之上——装配序修复判据)
check(main._menu_screen.get_index() > main._adventure_screen.get_index(),
"V1b 菜单屏壳在冒险屏壳之上(start_overlay 盖住战斗内容)〔装配生命批 V1〕")
# V1c 合同树序链逐对保序(直接子层全链=屏壳/模态/全屏态/非视觉节点;宿住层树序随宿主不参与)
var chain_detail := ""
var chain_prev: Node = null
var chain_ok := true
for entry: Dictionary in main.LAYER_CONTRACT:
if entry.has("host"):
continue
var node: Node = main._layer_node(String(entry["shell"]) if entry.has("shell") else String(entry["key"]))
if node == null:
chain_ok = false
chain_detail += " 缺失:%s" % String(entry["key"])
continue
if chain_prev != null and node.get_index() <= chain_prev.get_index():
chain_ok = false
chain_detail += " %s(%d)≤%s(%d)" % [chain_prev.name, chain_prev.get_index(), node.name, node.get_index()]
chain_prev = node
check(chain_ok, "V1c 合同树序链逐对保序(运行期树序==LAYER_CONTRACT 序;违例:%s)〔装配生命批 V1〕" % chain_detail)
# ③ 探针挂载假设句柄在座(main.X 一级路径——探针迁移面收口证明)
check(main._start_overlay == main._menu_screen.start_overlay and main.camp_overlay == main._camp_screen.camp_overlay and main.level_screen == main._adventure_screen.level_screen and main._battle_root == main._adventure_screen.battle_root and main.board_view == main._adventure_screen.board_view,
"V2 组合根句柄指向屏壳成员(探针挂载假设 main.X 零迁移)")
check(main.run_scope != null and not main.run_scope.is_adventure,
"V2 闲时会话自 _ready 即在(永非 null;迭代 27 T3——gp 影子账退役无账本成员)")
main.queue_free()
await process_frame
# 顶层可见内容层(V1a 用——候选=合同模态/全屏态+两屏壳的 boot 内容成员;屏壳本身是容器不当候选:
# camp 壳 boot 态 vis=true 但成员恒隐,裸树遍历自顶向下会先命中透明壳〔装配生命批诊断订正①〕)
func _topmost_visible_layer(main: Node) -> Node:
var candidates: Array[Node] = [main._start_overlay, main._battle_root, main._inter_overlay,
main.legend_overlay, main.node_overlay, main.level_screen, main.curio_overlay,
main._end_overlay, main.shop_overlay, main.camp_overlay, main.settings_overlay,
main.status_panel, main.career_overlay, main.confirm_dialog, main.debug_overlay]
var best: Node = null
var best_x := -1
var best_y := -1
for c: Node in candidates:
if c == null or not (c is CanvasItem) or not (c as CanvasItem).visible:
continue
var rx: int = c.get_parent().get_index()
var ry: int = c.get_index()
if best == null or rx > best_x or (rx == best_x and ry > best_y):
best = c
best_x = rx
best_y = ry
return best
# ---- boot 链路时序后置条件(迭代 28 T2——装配修复后启动态六断言;只立行为可判断言,不造假时序)----
func _smoke_boot_chain() -> void:
print("--- 迭代28 T2 boot 链路时序后置条件(V2:六断言)---")
var main: Node = load("res://presentation/main.tscn").instantiate()
root.add_child(main)
await process_frame
await process_frame
# ① ConfigDB 就绪且表已载
var db = main.get_node("/root/ConfigDB")
check(db.is_ready and db.hero != null,
"V2a ConfigDB 就绪且表已载(is_ready+hero 表在座)〔装配生命批 V2〕")
# ② 设置已应用(引擎态=settings_store 现值——真实设置文件照读,一致性判据与文件内容无关)
var ss = main.settings_store
var master := AudioServer.get_bus_index("Master")
check(AudioServer.is_bus_mute(master) == (ss.volume_linear <= 0.001)
and is_equal_approx(AudioServer.get_bus_volume_db(master), linear_to_db(maxf(ss.volume_linear, 0.001))),
"V2b 设置已应用·音量(Master mute/volume_db=settings_store 现值 linear=%f)〔装配生命批 V2〕" % ss.volume_linear)
var ui_kit = load("res://presentation/views/ui_kit.gd")
check(ui_kit.motion_scale == (0.5 if ss.reduced_motion else 1.0),
"V2b 设置已应用·减动效(UiKit.motion_scale=%s=settings_store.reduced_motion=%s)〔装配生命批 V2〕" % [
str(ui_kit.motion_scale), str(ss.reduced_motion)])
# ③ 启动态菜单可见
check(main._start_overlay.visible,
"V2c 启动态 _start_overlay.visible=true(boot 后玩家见菜单)〔装配生命批 V2〕")
# ④ 续玩钮可见态==SaveManager 断点在档态
var sm = main.get_node("/root/SaveManager")
# ④-1 boot 路径:boot 时按钮态==真实档断点态(关系判据——与真实档当时内容无关)
var real_bp: bool = sm.has_breakpoint()
check(main._resume_btn.visible == real_bp,
"V2d 续玩钮 boot 态==断点在档态(visible=%s has_breakpoint=%s)〔装配生命批 V2〕" % [
str(main._resume_btn.visible), str(real_bp)])
# ④-2 隔离新档双向(attach 原地重置——绝不碰真实档 user://save.json;刷新单口与 boot 路径同源)
for p in ["user://smoke_boot_chain.json", "user://smoke_boot_chain.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
sm.attach_path("user://smoke_boot_chain.json")
main._refresh_resume_button()
check(not sm.has_breakpoint() and not main._resume_btn.visible,
"V2d 续玩钮·无断点态隐藏(visible=%s has_breakpoint=%s)〔装配生命批 V2〕" % [
str(main._resume_btn.visible), str(sm.has_breakpoint())])
sm.set_breakpoint({"level_index": 2, "master_seed": 42})
main._refresh_resume_button()
check(sm.has_breakpoint() and main._resume_btn.visible,
"V2d 续玩钮·有断点态可见(visible=%s has_breakpoint=%s)〔装配生命批 V2〕" % [
str(main._resume_btn.visible), str(sm.has_breakpoint())])
sm.clear_breakpoint()
main._refresh_resume_button()
check(not main._resume_btn.visible,
"V2d 续玩钮·清断点后回隐藏(visible=%s)〔装配生命批 V2〕" % str(main._resume_btn.visible))
# ⑤ 闲时会话(_ready 即建、永非 null、在树)
check(main.run_scope != null and not main.run_scope.is_adventure and main.run_scope.is_inside_tree(),
"V2e run_scope 非空且为闲时会话(is_adventure=false+is_inside_tree)〔装配生命批 V2〕")
# ⑥ 调试浮窗恒开(2026-10-16 用户令 DS_DEBUG 写死=true——原环境变量门控退役,启动即开为常态)
check(main.debug_overlay.visible,
"V2f 调试浮窗恒开(visible=%s)〔2026-10-16 写死令〕" % str(main.debug_overlay.visible))
main.queue_free()
await process_frame
for p in ["user://smoke_boot_chain.json", "user://smoke_boot_chain.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
# ---- 生命周期存活性矩阵(迭代 28 T3——九阶段×五引用面;驱动复用 V3 debug seed 开局路径+
# _auto_play_round 活战斗模式+ui_struct resume 夹具模式,不另起点击链)----
# 阶段映射说明:计划书十阶段中「开局停点」与「地图选路」在本现行流程同为关卡画面停点态
#(level_started→choices_presented 同步到达,无可观测中间态)——合并为 V3b 一处断言。
func _smoke_lifecycle_matrix() -> void:
print("--- 迭代28 T3 生命周期存活性矩阵(V3:九阶段×五引用面)---")
var main: Node = load("res://presentation/main.tscn").instantiate()
root.add_child(main)
await process_frame
await process_frame
var sm = main.get_node("/root/SaveManager")
for p in ["user://smoke_lifecycle.json", "user://smoke_lifecycle.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
sm.attach_path("user://smoke_lifecycle.json")
main._refresh_resume_button() # 隔离后重同步(boot 时按钮态反映的是真实档——T2 已断 boot 关系,此处按隔离档定基)
main.debug_overlay.seed_edit.text = "42"
# 阶段①闲时
await _stage_faces(main, "V3a 闲时", "null", "null", "null",
{"mf_start": true, "mf_inter": false, "mf_rows_empty": true, "start_vis": true, "resume_vis": false})
# 阶段②开局停点(地图选路停点态)
main._start_adventure_from_menu()
for i in range(6):
await process_frame
await _stage_faces(main, "V3b 开局停点", "alive", "alive", "null",
{"mf_start": false, "level_vis": true})
# 阶段③地图选路→战斗中(真实选路提交 press_candidate)
main.level_screen.press_candidate(0)
for i in range(10):
await process_frame
await _stage_faces(main, "V3c 战斗中", "alive", "alive", "alive", {"mf_start": false})
# 阶段④结算后/终局面(AutoPlayer 活战斗驱动到终局——_auto_play_round 同款 phase/round 守卫)
var finished := {"done": false}
main.run_scope.adventure.adventure_finished.connect(func(_s: Dictionary) -> void:
finished.done = true, CONNECT_ONE_SHOT)
var guard := 0
while main.run_scope.settlement != null and main.run_scope.settlement.is_battle_live() \
and not finished.done and guard < 600:
var st = main.run_scope.settlement
var ms = st.minesweeper
var ae = st.action_economy
var start_round: int = st.round_index
var round_guard := 400
while ae.phase == "Active" and st.round_index == start_round and round_guard > 0 \
and main.run_scope.settlement != null:
round_guard -= 1
var plan: Dictionary = AutoPlayer.choose_dig(ms)
if plan.is_empty():
break
if plan.hero_cell != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": plan.hero_cell}])
if ae.phase != "Active" or st.round_index != start_round:
break
ms.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if main.run_scope.settlement == null or not main.run_scope.settlement.is_battle_live():
break
if ae.phase == "Active" and st.round_index == start_round:
ms.settle_batch([{"action": "stop"}])
guard += 1
check(main.run_scope.settlement == null or not main.run_scope.settlement.is_battle_live(),
"V3d 活战斗驱动完毕(战斗已收场——%d 轮,finished=%s)〔装配生命批 V3〕" % [guard, str(finished.done)])
main._flush_row_queue()
for i in range(12):
if main._end_overlay.is_open():
break
await process_frame
# 分支甲:战斗胜利停点(结算后)→确认进选路→放弃→终局面
if not finished.done:
await _stage_faces(main, "V3d 结算后(胜利停点)", "alive", "alive", "both", {"mf_start": false, "end_open": true})
main._on_battle_end_continue()
for i in range(8):
await process_frame
await _stage_faces(main, "V3d2 选路页(停点后)", "alive", "alive", "both", {"mf_start": false, "level_vis": true})
check(main.run_scope.adventure.abandon(), "V3d2 放弃受理(选路停点天然非战斗期)〔装配生命批 V3〕")
for i in range(12):
if finished.done:
break
await process_frame
main._flush_row_queue()
for i in range(8):
if main._end_overlay.is_open():
break
await process_frame
# 阶段⑤终局面(败北/放弃皆达)
check(finished.done and main._end_overlay.is_open(),
"V3e 终局面到达(adventure_finished+终局面板开)〔装配生命批 V3〕")
await _stage_faces(main, "V3e 终局面", "alive", "alive", "null", {"mf_start": false, "end_open": true})
# 阶段⑥回菜单收束(旧局消亡+新会话即驻+无真空窗)
var old_scope = main.run_scope
main._on_battle_end_continue()
check(main.run_scope != old_scope and main.run_scope.is_inside_tree(),
"V3f 收束换会话无真空窗(新闲时会话即刻在树)〔装配生命批 V3〕")
await process_frame
check(not is_instance_valid(old_scope),
"V3f 收束后旧局经 queue_free 消亡(is_instance_valid=false)〔装配生命批 V3〕")
await _stage_faces(main, "V3f 回菜单收束", "null", "null", "null",
{"mf_start": true, "start_vis": true, "end_open": false})
# 阶段⑦各模态开合后(菜单域四模态真实路径开合——会话面零扰动)
for modal_name in ["settings", "career", "camp", "debug"]:
if modal_name == "settings":
main._open_settings()
elif modal_name == "career":
main._open_career()
elif modal_name == "camp":
var campbtn: Button = main._menu_screen.campbtn
if campbtn == null:
check(false, "V3g 营地入口缺失(驱动中断)〔装配生命批 V3〕")
continue
campbtn.pressed.emit()
else:
main._show_debug_overlay()
for i in range(4):
await process_frame
var open_extra: Dictionary = {"mf_start": false}
if modal_name == "debug":
open_extra = {"mf_start": true} # 调试浮窗=浮于菜单之上的 Dialog(不隐藏开始层——迭代 18 卡⑦形态)
await _stage_faces(main, "V3g 模态开·%s" % modal_name, "null", "null", "null", open_extra)
if modal_name == "settings":
main._on_settings_closed()
elif modal_name == "career":
main._on_career_closed()
elif modal_name == "camp":
main._on_camp_closed()
else:
main._close_debug_overlay()
for i in range(4):
await process_frame
await _stage_faces(main, "V3g 模态合·%s" % modal_name, "null", "null", "null",
{"mf_start": true, "start_vis": true})
# 阶段⑧两连局(第二局全新会话——旧局对象不复用、脏态零残留)
var first_adv = main.run_scope.adventure
main.debug_overlay.seed_edit.text = "43"
main._start_adventure_from_menu()
for i in range(6):
await process_frame
check(main.run_scope.adventure != null and main.run_scope.adventure != first_adv,
"V3h 两连局第二会话全新对象(与第一局不复用)〔装配生命批 V3〕")
await _stage_faces(main, "V3h 两连局·开局停点", "alive", "alive", "null", {"mf_start": false, "level_vis": true})
check(main.run_scope.adventure.abandon(), "V3h 放弃受理(第二局)〔装配生命批 V3〕")
for i in range(12):
if main._end_overlay.is_open():
break
await process_frame
main._flush_row_queue()
for i in range(8):
if main._end_overlay.is_open():
break
await process_frame
await _stage_faces(main, "V3h 两连局·终局面", "alive", "alive", "null", {"mf_start": false, "end_open": true})
main._on_battle_end_continue()
await process_frame
await _stage_faces(main, "V3h 两连局·收束", "null", "null", "null", {"mf_start": true, "start_vis": true})
# 阶段⑨续玩断点(fresh 隔离档+最小合法断点夹具——ui_struct resume 夹具同构;换会话无真空窗)
for p in ["user://smoke_lifecycle_resume.json", "user://smoke_lifecycle_resume.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
sm.attach_path("user://smoke_lifecycle_resume.json")
sm.set_breakpoint({
"master_seed": 42, "rng_counter": 0, "hero_hp": 20, "gp": 24, "keystone": 0,
"level_index": 1, "bonus": {}, "shop_purchased": {}, "relics": [],
"keystone_drops": [], "bonus_taken": {},
"bonus_pending": {"candidates": [{"kind": "heal", "tier": "common"}], "payload": {"gp_dropped": 0}},
})
main._refresh_resume_button()
check(main._resume_btn.visible, "V3i 续玩钮·断点在档可见〔装配生命批 V3〕")
var idle_scope = main.run_scope
main._resume_from_menu()
check(main.run_scope != idle_scope and main.run_scope.is_inside_tree(),
"V3i 续玩换会话无真空窗(新冒险会话即刻在树)〔装配生命批 V3〕")
for i in range(10):
await process_frame
await _stage_faces(main, "V3i 续玩断点", "alive", "both", "both", {"mf_start": false})
main.queue_free()
await process_frame
for p in ["user://smoke_lifecycle.json", "user://smoke_lifecycle.json.tmp",
"user://smoke_lifecycle_resume.json", "user://smoke_lifecycle_resume.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
# 引用面快照(五承载物的会话相关面+恒活面——逐个调用现值回调,与 run_scope 成员并采)
func _face_snapshot(main: Node) -> Dictionary:
var tu = main._treasure_ui
var bo = main._bonus
var cp = main._camp_pres
var mf = main._modal_flow
var rp = main._row_pacer
return {
"treasure_settlement": tu._settlement_get.call(),
"treasure_runner": tu._runner_get.call(),
"bonus_meta": bo._meta_get.call(),
"bonus_save": bo._save_get.call(),
"bonus_adventure": bo._adventure_get.call(),
"bonus_runner": bo._runner_get.call(),
"bonus_mode": bo._adventure_mode_get.call(),
"camp_meta": cp._meta_get.call(),
"camp_save": cp._save_get.call(),
"camp_runner": cp._runner_get.call(),
"mf_start": mf.start_visible_get.call(),
"mf_inter": mf.inter_visible_get.call(),
"mf_settlement": mf.settlement_get.call(),
"mf_rows_empty": mf.rows_empty_get.call(),
"rp_log": rp.log_view, "rp_stage": rp.stage, "rp_board_zone": rp.board_zone,
"rp_fx": rp.settlement_fx, "rp_board": rp.board_view,
"rp_refresh": rp.refresh_request, "rp_pump": rp.pump_ended,
"rs_adventure": main.run_scope.adventure, "rs_runner": main.run_scope.runner,
"rs_settlement": main.run_scope.settlement, "rs_is_adventure": main.run_scope.is_adventure,
}
# 阶段面断言:adv/run/set=alive|null|both(会话三面存活态);恒活面每阶段必查;
# 现值回调与 run_scope 成员必须同源(回调链无悬挂铁证);extra=阶段附加判据。
func _stage_faces(main: Node, tag: String, adv: String, run: String, set: String, extra: Dictionary = {}) -> void:
var snap := _face_snapshot(main)
var bad := ""
for k in ["bonus_meta", "bonus_save", "camp_meta", "camp_save", "rp_log", "rp_stage", "rp_board_zone", "rp_fx", "rp_board"]:
if not is_instance_valid(snap[k]):
bad += " %s=失效" % k
for k in ["rp_refresh", "rp_pump"]:
var cb: Variant = snap[k]
if not (cb is Callable) or not (cb as Callable).is_valid():
bad += " %s=非有效Callable" % k
if not main.run_scope.is_inside_tree():
bad += " run_scope=不在树"
bad += _face_liveness("adventure", snap["rs_adventure"], adv)
bad += _face_liveness("runner", snap["rs_runner"], run)
bad += _face_liveness("settlement", snap["rs_settlement"], set)
if snap["treasure_settlement"] != snap["rs_settlement"]:
bad += " treasure._settlement_get≠run_scope.settlement(回调悬挂)"
if snap["treasure_runner"] != snap["rs_runner"]:
bad += " treasure._runner_get≠run_scope.runner(回调悬挂)"
if snap["bonus_adventure"] != snap["rs_adventure"]:
bad += " bonus._adventure_get≠run_scope.adventure(回调悬挂)"
if snap["bonus_runner"] != snap["rs_runner"]:
bad += " bonus._runner_get≠run_scope.runner(回调悬挂)"
if snap["camp_runner"] != snap["rs_runner"]:
bad += " camp._runner_get≠run_scope.runner(回调悬挂)"
if snap["mf_settlement"] != snap["rs_settlement"]:
bad += " modal_flow.settlement_get≠run_scope.settlement(回调悬挂)"
if bool(snap["bonus_mode"]) != bool(snap["rs_is_adventure"]):
bad += " bonus._adventure_mode_get≠run_scope.is_adventure(回调悬挂)"
for k in extra:
match String(k):
"mf_start":
if bool(snap["mf_start"]) != bool(extra[k]):
bad += " modal_flow.start_visible_get 应=%s" % str(extra[k])
"mf_inter":
if bool(snap["mf_inter"]) != bool(extra[k]):
bad += " modal_flow.inter_visible_get 应=%s" % str(extra[k])
"mf_rows_empty":
if bool(snap["mf_rows_empty"]) != bool(extra[k]):
bad += " modal_flow.rows_empty_get 应=%s" % str(extra[k])
"level_vis":
if main.level_screen.visible != bool(extra[k]):
bad += " level_screen.visible 应=%s" % str(extra[k])
"end_open":
if main._end_overlay.is_open() != bool(extra[k]):
bad += " _end_overlay.is_open 应=%s" % str(extra[k])
"resume_vis":
if main._resume_btn.visible != bool(extra[k]):
bad += " _resume_btn.visible 应=%s" % str(extra[k])
"start_vis":
if main._start_overlay.visible != bool(extra[k]):
bad += " _start_overlay.visible 应=%s" % str(extra[k])
check(bad.is_empty(), "%s(引用面违例:%s)〔装配生命批 V3〕" % [tag, bad])
# 单面存活态判据:want=null=必须缺位/alive=必须有效非空/both=两可但非空必有效
func _face_liveness(name: String, v, want: String) -> String:
if want == "null":
return "" if v == null else " %s 应缺位(实测非空)" % name
if v != null and not is_instance_valid(v):
return " %s 悬挂(失效对象)" % name
return ""
# ---- 会话物化断言(迭代 27 T1 V3——两连局复位/无脏状态残留;原手工复位清单消亡的行为级证明)----
func _smoke_run_lifecycle() -> void:
print("--- 迭代27 T1 会话物化(V3:两连局复位+脏状态不残留)---")
var main: Node = load("res://presentation/main.tscn").instantiate()
root.add_child(main)
await process_frame
# 存档隔离(autoload attach 原地重置——绝不碰真实档 user://save.json)
var sm = main.get_node("/root/SaveManager")
for p in ["user://smoke_run_lifecycle.json", "user://smoke_run_lifecycle.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
sm.attach_path("user://smoke_run_lifecycle.json")
sm.mark_first_adventure_done()
# ---- 第一局:开局→注脏→放弃→终局出口→会话收束 ----
main.debug_overlay.seed_edit.text = "42"
main._start_adventure_from_menu()
await process_frame
check(main.run_scope != null and main.run_scope.is_adventure and main.run_scope.adventure != null,
"V3 开局建会话(is_adventure+adventure 在座)")
check(main.run_scope.hints_shown.is_empty(),
"V3 新会话局态零值")
# 注入脏状态(旧手工清单时代的典型泄漏源——新会话必须整体吞掉;迭代 27 T3——刷新计数已单源化入编排器,不再属会话脏面)
main.run_scope.hints_shown["chord"] = true
main.run_scope.boss_active = true
main.run_scope.last_gain_amount = 77
var finished := {"done": false}
main.run_scope.adventure.adventure_finished.connect(func(_s: Dictionary) -> void:
finished.done = true, CONNECT_ONE_SHOT)
check(main.run_scope.adventure.abandon(), "V3 放弃受理(入口停点天然非战斗期)")
for i in range(8):
if finished.done:
break
await process_frame
check(finished.done, "V3 冒险终局到达(abandon→adventure_finished 同步链)")
check(main._end_overlay.is_open(), "V3 终局面板弹起")
main._on_battle_end_continue() # 终局出口(回菜单+会话收束——手工置空清单已消亡)
await process_frame
check(not main.run_scope.is_adventure and main.run_scope.adventure == null and main.run_scope.runner == null,
"V3 会话收束(新闲时会话零冒险/关卡对象)")
check(main.run_scope.hints_shown.is_empty() and not main.run_scope.boss_active and main.run_scope.last_gain_amount == 0,
"V3 局态旗/账本全复位(queue_free 物化——注入脏状态零残留)")
check(main._start_overlay.visible, "V3 回开始菜单(终局出口路径)")
# ---- 第二局:放弃终局无断点残留→直进开局→全新会话 ----
check(not sm.has_breakpoint(), "V3 放弃终局清断点(无残留断点——续玩按钮态联动)")
main.debug_overlay.seed_edit.text = "43"
main._start_adventure_from_menu()
await process_frame
check(main.run_scope.is_adventure and main.run_scope.adventure != null and main.run_scope.hints_shown.is_empty(),
"V3 第二连局全新会话(两连局零泄漏)")
check(not main._start_overlay.visible, "V3 直进入局(菜单隐)")
main.queue_free()
await process_frame
for p in ["user://smoke_run_lifecycle.json", "user://smoke_run_lifecycle.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
# ---- Core 集成冒烟(表加载成功才执行)----
var _db
var _rng
func _run_core_smoke(db, rng) -> void:
_db = db
_rng = rng
_smoke_ac_swap_guard()
_smoke_enemy_attack_type()
_smoke_armor_roll()
_smoke_pool_wiring()
_smoke_board_generator()
_smoke_layout_cell_formula()
_smoke_variation_framework()
_smoke_variant_placement(1)
_smoke_variant_placement(2)
_smoke_global_variant()
_smoke_full_battle(1, 0, "L1 · 地精")
_smoke_full_battle(2, 0, "L2 · 熊地精")
_smoke_level()
_smoke_flag_ap_exhausted()
_smoke_reward_identity_unit()
_smoke_power_interval()
_smoke_active_refresh()
_smoke_difficulty_table()
_smoke_damage_types()
_smoke_playtest_logger()
_smoke_battle_log_view()
_smoke_global_theme()
_smoke_button_five_states()
_smoke_texture_filter()
_smoke_contrast_inks()
_smoke_element_details()
_smoke_diag_button_retired()
_smoke_encounter_roll()
_smoke_attack_chain_fresh_pool()
_smoke_clear_reward_chain()
_smoke_refresh_provenance_guard()
_smoke_polarity_routing()
_smoke_flag_irreversible_quadrants()
_smoke_negative_dictionary()
_smoke_resource_surface()
_smoke_settlement_segments()
_smoke_skill_pool()
_smoke_level_graph()
_smoke_gp_wallet()
_smoke_gp_escrow()
_smoke_shop_room()
_smoke_shop_graph()
_smoke_shop_pool()
_smoke_nine_types_gp_stream()
_smoke_adventure_table()
_smoke_adventure_api()
_smoke_adventure()
_smoke_feats()
_smoke_save()
_smoke_meta()
_smoke_tree()
_smoke_tree_rules()
_smoke_node_map_templates()
# ---- 关卡节点图生成·模板池冒烟(M-3 迭代 5 T5——v0.4 §P0.6 断言面)----
func _smoke_node_map_templates() -> void:
# ① 扩签:node_map 入用途标签封闭词表(派生零改动——标签只进事件日志)
var svc = load("res://core/rng_service.gd").new()
check((svc.PURPOSE_TAGS as Array).has("node_map"), "node_map 标签入 rng_service 封闭词表(扩签通道=改代码+冒烟守卫)")
# ② 池参数:三关各 5 模板、模板 0 存在(回滚档成员)
for li in range(1, 4):
var row := {}
for r in _db.level.rows:
if int(r.level_index) == li:
row = r
var tpls: Array = row.get("templates", [])
check(tpls.size() == 5, "L%d 模板池 = 5 张(拍板点④——首版池规模)" % li)
check(not tpls.is_empty() and String(tpls[0].get("display_name", "")).ends_with("(现行)"),
"L%d 模板 0 = 现行固定图(回滚档成员)" % li)
# ③ 强制取 0 = 模板 0 节点表逐位相等 + 零 scope 消费
var rng0 = load("res://core/rng_service.gd").new()
rng0.new_adventure(910001)
var runner0 = load("res://core/level_runner.gd").new(_db, rng0)
var c_before: int = rng0.counter()
check(runner0.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, # 迭代 27 T3——start_level_external 签名收口(bonus 参数随 RunLedger 单类下传退役)
Callable(self, "_smoke_rest_stub"), {}, true, 0), "强制 template_pick=0 起关受理")
check(rng0.counter() == c_before, "强制取模板零 RNG 消耗(零抽签——首次脚本语义)")
var tpl0: Dictionary = {}
for r in _db.level.rows:
if int(r.level_index) == 2:
tpl0 = r.get("templates", [])[0]
check(runner0.level_row.get("nodes", []) == tpl0.get("nodes", []),
"强制 0 的生效节点表 = 模板 0 逐位相等(回滚档成立)")
# ④ 抽签确定性:同种子两实例同模板(同种子同操作序列 → 逐位同图)
var names := []
for i in range(2):
var rg = load("res://core/rng_service.gd").new()
rg.new_adventure(910002)
var rn = load("res://core/level_runner.gd").new(_db, rg)
rn.start_level_external(2, {"hero_hp": 20, "gp": 0}, {},
Callable(self, "_smoke_rest_stub"), {}, true, -1)
names.append(rn.active_template_name)
check(names[0] == names[1] and String(names[0]) != "",
"抽签确定性:同种子逐位同图(实测 %s)" % String(names[0]))
# ⑤ N=1 池与现行固定图等价:单模板行过图校验(抽签取模 1 恒 0 = 回滚档)
var lt = load("res://data/schemas/level_table.gd").new()
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30,
"templates": [tpl0]}])
check(lt.validate().is_empty(), "N=1 池表通过图校验(回滚档形态合法)")
# ⑥ 首次脚本分支:first_script=true 全关模板 0;false = 抽签(scope 恰一差异)
var rngF = load("res://core/rng_service.gd").new()
rngF.new_adventure(910003)
var adrF = load("res://core/adventure_runner.gd").new(_db, rngF)
adrF.start_adventure(910003, {"first_script": true})
check(String(adrF.runner().active_template_name).ends_with("(现行)"),
"首次脚本模式:L1 取模板 0(全关零抽签——存档布尔位 false 语义)")
var cF: int = rngF.counter()
var rngT = load("res://core/rng_service.gd").new()
rngT.new_adventure(910003)
var adrT = load("res://core/adventure_runner.gd").new(_db, rngT)
adrT.start_adventure(910003, {"first_script": false})
check(rngT.counter() == cF + 1, "抽签模式恰多消费 1 scope(node_map 逐关恰一——L1 起关抽签)")
check(String(adrT.runner().active_template_name) != "",
"抽签模式 active_template_name 有值(MC 分层出数取数面在位)")
# ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等)
DirAccess.remove_absolute("user://smoke_node_map_save.json") # 测试隔离——上轮 settle 写盘的残留档先删
var sv = load("res://core/save_manager.gd").new("user://smoke_node_map_save.json")
sv.load_from_disk()
check(sv.is_first_adventure_done() == false, "新档首次冒险布尔位 = false(默认域)")
var mp = load("res://core/meta_progression.gd").new(_db, sv)
mp.settle_adventure({"levels_cleared": 1, "keystone_total": 10, "cleared": false, "result": "failed"})
check(sv.is_first_adventure_done() == true, "冒险收束点置位(三结局同写——写入只在收束挂点)")
mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"})
check(sv.is_first_adventure_done() == true, "置位幂等(重复收束不翻转)")
# 冒烟恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位)
func _smoke_rest_stub(pct: int, hp: int, max_hp: int) -> int:
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
# ---- 冒险结构实现批冒烟(2026-09-21——AdventureTable 建表 + AdventureRunner + 冒险验收断言)----
# 令牌候选匹配(TP0——kind+tier 复合身份;冒烟断言面共用)
func _smoke_find_token(candidates: Array, kind: String, tier: String) -> Dictionary:
for t in candidates:
var tok: Dictionary = t as Dictionary
if String(tok.get("kind", "")) == kind and String(tok.get("tier", "")) == tier:
return tok
return {}
# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池换代:配额结构/件值对/池总量域检)
func _smoke_adventure_table() -> void:
print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---")
check(_db.adventure != null, "db.adventure 已加载(adventure.tres 入 manifest)")
if _db.adventure == null:
return
check(_db.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)")
check(int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100,
"clear_heal_pct ∈ [0,100](%d——通关部分回血比例)" % int(_db.adventure.clear_heal_pct))
for tier in _db.adventure.TIERS:
check(int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier]),
"档位配额结构锚(四级 70/25/3/0 直映射承袭——迭代 20 T2):%s = %d 张" % [tier, int(_db.adventure.TIER_QUOTAS[tier])])
check(int(_db.adventure.deck_total()) == 104, "令牌池总量 = 104 张(四级 普通 70/优秀 25/稀有 9/传说 0——T2 直映射 98+T4 凭证投放 6)〔专长批 V5〕")
check(int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "excellent")) == 50
and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "excellent")) == 2
and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "excellent")) == 10
and int(_db.adventure.curio_value("armor", "common")) == 1 and int(_db.adventure.curio_value("armor", "excellent")) == 2,
"数值对(四级换代承袭):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2")
check(int(_db.adventure.curio_value("heal", "excellent")) == 6 and int(_db.adventure.curio_value("ap", "rare")) == 1,
"恢复件 +6 当即回血(优秀档承袭)· AP +1(稀有档承袭)〔专长批 V14 换代〕")
check(int(_db.adventure.curio_cap("gp")) == 99 and int(_db.adventure.curio_cap("attack")) == 99,
"cap 占位在域(案②提前退役——99=实质无上限,实数归调参批)〔专长批 V8 形态面〕")
check(Attunement.slots(_db, null) == 0,
"同调位读数源=树同调位节点点亮数(迭代 21 T4 槽位收编——null 档求值 0;谓词表已退役)〔天赋树批 V14〕")
# 迭代 22 T1——新目录 12 件全量换代(V1/V2 断言落点;旧提灯段随 fog_lantern 行退役摘除,
# 装配→战斗贯通与空装配基线本体保留换主语 morning_order)
var trows: Array = _db.treasure_attunement_rows()
var tid_set := {}
for r in trows:
tid_set[String(r.get("item_id", ""))] = true
check(trows.size() == 12 and tid_set.size() == 12,
"新目录 12 件全量落表(旧 12 行删除;装配词表=活行 12 件零重复)〔秘宝批 V1〕")
var new_names := {"morning_order": "晨操令", "wide_scout_band": "阔视野带", "trial_pick": "试炼镐",
"refresh_practice": "刷新练习", "reserve_jar": "储备罐", "nest_forecast": "巢穴预告",
"incense_stick": "线香", "twin_loader": "双管装填", "ranged_pick": "远程镐",
"refresh_license": "刷新执照", "sky_eye": "天眼", "beacon_network": "信标网络"}
var names_ok := tid_set.size() == new_names.size()
for r in trows:
var iid2 := String(r.get("item_id", ""))
if not new_names.has(iid2) or String(r.get("display_name", "")) != String(new_names[iid2]):
names_ok = false
check(names_ok, "命名锁定:12 件 item_id 与 display_name 逐件=正式名(C2 代号即名)〔秘宝批 V2 初筛〕")
var keystone_ids := ["morning_order", "wide_scout_band", "trial_pick", "refresh_practice",
"reserve_jar", "nest_forecast", "incense_stick", "twin_loader"]
var cond_reqs := {"ranged_pick": {"adventure_cleared": 1}, "refresh_license": {"boss_kill_count": 3},
"sky_eye": {"adventure_cleared": 3, "boss_kill_count": 4}, "beacon_network": {"vision_used": 10}}
var tier_map := {"common": 0, "excellent": 0, "rare": 0}
var alloc_ok := trows.size() == 12
for r in trows:
var iid3 := String(r.get("item_id", ""))
var ureqs: Array = r.get("unlock_requires", []) as Array
if keystone_ids.has(iid3):
if int(r.get("unlock_keystone_cost", 0)) < 1 or not ureqs.is_empty():
alloc_ok = false
elif cond_reqs.has(iid3):
var want: Dictionary = cond_reqs[iid3]
if int(r.get("unlock_keystone_cost", 0)) != 0 or ureqs.size() != want.size():
alloc_ok = false
for req_v in ureqs:
var req: Dictionary = req_v as Dictionary
if int(want.get(String(req.get("key", "")), -1)) != int(req.get("threshold", -1)):
alloc_ok = false
else:
alloc_ok = false
var t: String = String(r.get("tier", ""))
if not tier_map.has(t):
alloc_ok = false
else:
tier_map[t] += 1
check(alloc_ok and tier_map["common"] == 7 and tier_map["excellent"] == 3 and tier_map["rare"] == 2,
"逐行解锁方式与契约 8 分配一致(钥石 8 件带价格、条件 4 件带谓词行互斥)+三档 7/3/2〔秘宝批 V1〕")
var orch_l = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(191))
check(orch_l.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "attunement": ["morning_order"]})
and orch_l.attune_has("morning_order"),
"装配→战斗贯通:enc.attunement=[morning_order] 起战 attune_has=true(注入面+只读出口)〔秘宝批换主语〕")
var orch_e = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(192))
check(orch_e.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
and not orch_e.attune_has("morning_order"),
"空装配基线:不带 attunement 起战 attune_has=false(off 态零扰动)〔秘宝批换主语〕")
# V9 静态词表断言(迭代 22 T6——棋盘事实不可变契约 3:新 12 行 triggers do.kind 与 effect_kinds
# 全集零棋盘改写类词〔rewrite_board 已除名/remove_mines/cell_removal=改写族——remove_mines 现役
# 语义=爆破工兵包旧通道随行退役,新表零引用〕;效果只允许信息呈现与开格动作)
var rewrite_words := ["rewrite_board", "remove_mines", "cell_removal"]
var v9_ok := true
for r9 in trows:
for k9 in (r9.get("effect_kinds", []) as Array):
if rewrite_words.has(String(k9)):
v9_ok = false
for tr9 in (r9.get("triggers", []) as Array):
if rewrite_words.has(String((tr9 as Dictionary).get("do", {}).get("kind", ""))):
v9_ok = false
check(v9_ok, "新 12 行效果词零棋盘改写类(危险格/安全格分布同池冻结——真视/开格动作外无写面)〔秘宝批 V9〕")
# 覆盖检查(迭代 22 T6——效果三方向覆盖:信息〔巢穴预告/线香/天眼/阔视野带〕/行动资源〔晨操令/
# 双管装填/试炼镐/储备罐〕/操作能力〔远程镐/信标网络〕+抉择刷新〔刷新练习/刷新执照〕——dimension_tags 静态投影)
var tag_set := {}
for r10 in trows:
for tg in (r10.get("dimension_tags", []) as Array):
tag_set[String(tg)] = true
check(tag_set.has("info") and tag_set.has("action") and tag_set.has("plan"),
"效果三方向覆盖在册(信息/行动资源/操作能力=plan·抉择刷新——12 件目录结构对齐设计权威)〔秘宝批 V10 覆盖半〕")
# V18 装配面空目录合法态(迭代 22 T6——白板档全未解锁:柜台投影 12 行全未解锁+mode 分派正确;
# 池空兜底/商店空货架两部分归奇物/商店侧另批〔卡片 §4〕)
var mp_v18 = load("res://core/meta_progression.gd").new(_db, _meta_new_save(_META_SMOKE_PATHS[12]))
var v18_ok := true
var locked_n := 0
for st18 in mp_v18.treasure_shop_status():
if bool(st18.get("unlocked", false)):
v18_ok = false
else:
locked_n += 1
if (String(st18.get("mode", "")) != "keystone" and String(st18.get("mode", "")) != "condition"):
v18_ok = false
check(v18_ok and locked_n == 12,
"装配面空目录合法态:白板档 12 件全未解锁且 mode 分派正确(空目录=合法可玩态)〔秘宝批 V18〕")
# ② 冒险结构 API 闸(状态机受理面——重复开始拒绝 / 入口停点 / 放弃两态)
func _smoke_adventure_api() -> void:
print("--- 冒险结构 API 闸(重复开始拒绝 · 入口停点 · 放弃两态受理)---")
var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(42))
adr.set_hero_override(900, 400) # 首战速胜档(放弃的非战斗受理要走到选路态)
# 停点前移批 2026-09-23:入口停点观测(信号序 + 入口候选快照——容器写回规避 lambda 按值捕获)
var sig_seq: Array = []
var obs := {"entry_cands": []}
adr.level_started.connect(_api_mark_started.bind(sig_seq))
adr.choices_presented.connect(_api_mark_choices.bind(sig_seq, obs))
adr.node_entered.connect(_api_mark_node.bind(sig_seq))
check(adr.start_adventure(42, {"first_script": true}), "冒险启动受理(主种子注入恰一次——state=running;first_script=固定模板 0——精确路径断言需要现行图,零抽签)")
check(not adr.start_adventure(43), "进行中重复 start_adventure 拒绝(重开须待结算完毕)")
check(sig_seq.size() == 2 and String(sig_seq[0]) == "level_started" and String(sig_seq[1]) == "choices_presented",
"停点前移次序契约:level_started 先于入口候选呈现(反序选路口会被呈现层 runner 空防御丢弃)")
check(adr.runner().awaiting_choice() and (obs["entry_cands"] as Array).size() == 1 \
and adr.runner().current_battle() == null,
"入口停点:选路态 + 入口单候选 + 首战未开(呈现零 RNG 消耗——pick 前零战斗域)")
check(adr.runner().pick_next_node(int(adr.runner().next_candidate_ids()[0])),
"pick 入口候选受理(同一 _enter_node——与旧自动进入逐位等价)")
check(sig_seq.size() == 3 and String(sig_seq[2]) == "node_entered" and adr.runner().current_battle() != null,
"pick 后 node_entered 到达 + 首战在飞(接口①开始关卡——停点闭合直达战斗节点)")
check(not adr.abandon(), "战斗中 abandon 拒绝(战斗域单事务在飞)")
# 驱动首战到胜利 → 选路态(非战斗期)→ abandon 受理
var autoplay = load("res://smoke/level_autoplay.gd")
var battle = adr.runner().current_battle()
var st := _battle_st("normal")
var guard := 30
while adr.runner().current_battle() == battle and guard > 0:
guard -= 1
autoplay._autoplay_battle(adr.runner(), battle, st, null)
check(adr.runner().current_battle() == null and adr.runner().awaiting_choice(),
"首战胜后选路态(非战斗期——current_battle 置空)")
check(adr.abandon(), "非战斗期 abandon 受理(选路态——同失败型结算)")
var sum: Dictionary = adr.summary
check(String(sum.get("result", "")) == "abandoned" and not bool(sum.get("cleared", true)),
"放弃结算:result=abandoned · cleared=false(已获面不追回——钥石累计 %d)" % int(sum.get("keystone_total", -1)))
check(not adr.abandon(), "结算后重复 abandon 拒绝(结算受理只算一次——state=idle)")
# 停点前移批 2026-09-23 观测桩(信号序采集——bind 容器写回,签名按各信号实参定制)
func _api_mark_started(_li: int, _dn: String, seq: Array) -> void:
seq.append("level_started")
func _api_mark_choices(cands: Array, seq: Array, obs: Dictionary) -> void:
seq.append("choices_presented")
obs["entry_cands"] = cands.duplicate()
func _api_mark_node(_node: Dictionary, _info: Dictionary, seq: Array) -> void:
seq.append("node_entered")
# ③ 冒险验收断言(映射 冒险结构.md 验收标准——表覆盖/满配通关/种子复现/大奖励/败北线/
# 重复拒绝/GP 跨关延续/加算区同账/恢复数学/放弃)
func _smoke_adventure() -> void:
print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---")
var autoplay = load("res://smoke/level_autoplay.gd")
# ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行集——覆盖义务断言;
# 2026-10-07 行集多行批:「精确行」改「精确行集」口径——enemy 侧同坐标多行合法,坐标级键聚合天然兼容)
check(int(_db.level.rows.size()) == 3, "序列关数 = 行集关数(三关正式序列生效)")
var bg_keys := {}
for row in _db.board_gen.rows:
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var en_keys := {}
for row in _db.enemy.rows:
en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var miss := 0
for row in _db.level.rows:
for n in _level_active_nodes(int(row.level_index)):
var nt := String(n.get("type", ""))
if nt != "battle" and nt != "boss":
continue
var key := "%d_%d" % [int(row.level_index), int(n.get("node_depth", -1))]
if not bg_keys.has(key) or not en_keys.has(key):
miss += 1
check(miss == 0, "行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行集(缺失 %d)" % miss)
# ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复)
var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
var sum: Dictionary = st["summary"]
check(String(st["result"]) == "completed" and bool(sum.get("cleared", false)),
"满配通关(42 / atk+900 / hp+400——cleared=true;v2 怪阶梯后旧 200/100 档不再满配)")
check(int(sum.get("levels_cleared", -1)) == 3, "通关几关 = 3(levels_cleared)")
var ks_exp := 0
for li in range(1, int(_db.level.rows.size()) + 1):
ks_exp += int(_db.meta_boss_keystone_row(li).get("first_kill", 0))
check(int(sum.get("keystone_total", -1)) == ks_exp,
"钥石合计 = ΣBoss 首杀行值(%d——逐杀掉落新口径;断点未注入恒首杀)" % int(sum.get("keystone_total", -1)))
var drops: Array = sum.get("keystone_drops", []) as Array
check(drops.size() == 3, "逐杀掉落明细恰 3 笔(L1/L2/L3——additive 摘要键 keystone_drops)")
check(int(sum.get("master_seed", -1)) == 42, "摘要携带主种子(42)")
var picks: Array = []
var resolves: Array = []
var node_picks := 0
for ev in (st["reward_events"] as Array):
if (ev as Dictionary).has("curio_pick"):
picks.append((ev as Dictionary)["curio_pick"])
elif (ev as Dictionary).has("node_curio_pick"):
node_picks += 1 # 奖励节点三选一(追加批——每关奖励节点各 1 次)
elif (ev as Dictionary).has("curio_resolved"):
resolves.append((ev as Dictionary)["curio_resolved"])
check(picks.size() == 2 and resolves.size() == 2 + node_picks,
"通关三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)")
check(node_picks >= 1, "奖励节点三选一 ≥1 次(L1 链必经 1 个;L2/L3 图可选路经若干——实际经停 %d)" % node_picks)
var node_resolves := 0
for r0 in resolves:
if String((r0 as Dictionary).get("context", "")) == "reward_node":
node_resolves += 1
check(node_resolves == node_picks, "奖励节点兑付回执与经停逐笔相等(context=reward_node——同账计数 %d/%d)" % [node_resolves, node_picks])
var level_resolves: Array = []
for r0 in resolves:
if String((r0 as Dictionary).get("context", "")) != "reward_node":
level_resolves.append(r0)
var heal_ok := picks.size() == 2
var cand_ok := picks.size() == 2
var ks_gain_ok := picks.size() == 2
var resolve_ok := level_resolves.size() == 2
var max_flat := int(_db.adventure.curio_value_max_hp_common)
var heal_pct := int(_db.adventure.clear_heal_pct)
var hp_acc := 0 # max_hp 件累加(回血封顶断言的生效上限推导——TP0 起按兑付令牌实值,大小档分别计)
var ks_prefix := 0
for i in picks.size():
var p: Dictionary = picks[i]
var cands: Array = p.get("candidates", []) as Array
if cands.size() < 2 or cands.size() > 3:
cand_ok = false
var seen := {}
for c in cands:
# 身份键 = kind|tier|feat_id(迭代14① T4 断言基线复核——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))
# 模拟档 v3.4 商店贪心购血合法抬升(L2/L3 店在 Boss 前)——回血口径窗口 = 加算区件 + 购血余量 0..15
# T17 账本口径修复随批:置满/回血封顶改生效上限口径(flat 后乘调试浮窗 pct)——窗口同步 ×5(本段 pct=400)
var heal_ok_i := false
for extra in 46:
var m := int(round(float(int(_db.hero.max_hp) + hp_acc + extra) * 5.0))
if heal == mini(int(floor(float(m) * float(heal_pct) / 100.0)), m - hp_before):
heal_ok_i = true
break
if not heal_ok_i or hp_after != hp_before + heal or hp_after > int(round(float(int(_db.hero.max_hp) + hp_acc + 45) * 5.0)):
heal_ok = false
ks_prefix += int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0))
if int(p.get("keystone_gained", -1)) != int(_db.meta_boss_keystone_row(int(p.get("level_index", -1))).get("first_kill", 0)) or int(p.get("keystone_total", -1)) != ks_prefix:
ks_gain_ok = false
if resolve_ok:
var r: Dictionary = level_resolves[i]
var rk := String(r.get("picked", ""))
var rt := String(r.get("tier", ""))
if _smoke_find_token(p.get("candidates", []) as Array, rk, rt).is_empty() or int(r.get("value", -1)) != int(_db.adventure.curio_value(rk, rt)):
resolve_ok = false
if rk == "max_hp":
hp_acc += int(r.get("value", max_flat))
check(cand_ok, "三选一候选 2–3 枚且零重复(令牌洗牌不放回——kind+tier 复合身份)")
check(heal_ok, "通关部分回血封顶(floor(上限×比例) 封顶缺口——血回满退役;购血容差窗口)")
check(ks_gain_ok, "钥石逐杀入账(首杀档位 + 累计逐位)")
check(resolve_ok, "点选恰兑付选中一件(值=令牌档位表值;未选项零生效)")
var seq: Array = st["encounter_max_hp_seq"]
var k := 5.0 # hp+400 ⇒ 调试浮窗百分比后乘 ×5(加算先加、百分比后乘——生效次序口径)
var exp_base := maxi(1, int(round(float(int(_db.hero.max_hp)) * k)))
# 模拟档 v3.4 商店贪心购血(数值修复批)合法抬升——上限窗口 = 基础 + 加算区 3 件 + 购血余量;
# 大生命 +10(迭代 20 T2 四级换代:优秀档)——上界按大件最坏构成 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
check(seq_ok,
"各战上限入参 ∈ [基础, 基础+3×件值+购血余量](覆盖值通道——双渠道共享上限 3;模拟档贪心购血合法叠加〔数值修复批 v3.4〕;首战=基础 %d)" % exp_base)
# ③ 种子域可复现(同种子两局:summary + 上限序列 + 行进序列 + 三选一停点载荷逐位一致)
var st2: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
check(st2["summary"] == sum and st2["encounter_max_hp_seq"] == seq
and st2["visited_ids"] == st["visited_ids"] and st2["reward_events"] == st["reward_events"],
"同种子两局逐位可复现(summary/上限序列/行进序列/三选一载荷——reward_pick 封闭流)")
# ⑤ 败北线(零配 L1 败北 + 半配 L2 败北——种子扫描,仿商店房 range(42,92) 先例)
var lose := {}
for s in range(42, 92):
var stl: Dictionary = autoplay.run_adventure(_db, s, 0, 0)
if String(stl["result"]) == "failed" and int(stl["summary"].get("stopped_at", -1)) == 1:
lose = {"s": s, "st": stl}
break
check(not lose.is_empty(), "零配 L1 败北种子可达(seed=%s)" % str(lose.get("s", -1)))
if not lose.is_empty():
var sl: Dictionary = lose["st"]["summary"]
check(not bool(sl["cleared"]) and int(sl["levels_cleared"]) == 0 \
and int(sl["keystone_total"]) == 0 and int(sl["stopped_at"]) == 1,
"L1 败北摘要:未打穿 · 通关 0 关 · 钥石 0 · 止步第 1 层")
var dl: Dictionary = sl.get("defeat", {})
check(int(dl.get("level_index", -1)) == 1 and int(dl.get("node_id", -1)) >= 0 \
and String(dl.get("node_type", "")) in ["battle", "boss"],
"L1 败北败退明细:层号/节点编号/节点类型在案(数值验证取数面 2026-09-22)")
var half := {}
for s in range(42, 142):
var sth: Dictionary = autoplay.run_adventure(_db, s, 50, 0)
if String(sth["result"]) == "failed" and int(sth["summary"].get("stopped_at", -1)) == 2:
half = {"s": s, "st": sth}
break
check(not half.is_empty(), "半配(atk+50)L2 败北种子可达(seed=%s)" % str(half.get("s", -1)))
if not half.is_empty():
var sh: Dictionary = half["st"]["summary"]
check(not bool(sh["cleared"]) and int(sh["levels_cleared"]) == 1 \
and int(sh["keystone_total"]) == int(_db.meta_boss_keystone_row(1).get("first_kill", 0)) \
and int(sh["stopped_at"]) == 2,
"L2 败北摘要:L1 已过(钥石=L1 首杀掉落)· 止步第 2 层")
var dh: Dictionary = sh.get("defeat", {})
check(int(dh.get("level_index", -1)) == 2 and int(dh.get("node_id", -1)) >= 0 \
and String(dh.get("node_type", "")) in ["battle", "boss"],
"L2 败北败退明细:层号=2 · 节点在案(数值验证取数面 2026-09-22)")
# ⑥ 重复宣布拒绝(settle 后再调 _on_level_finished——摘要零变化)
var adr6 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(7))
adr6.set_hero_override(900, 400)
check(adr6.start_adventure(7, {"first_script": true}), "重复拒绝场景:冒险启动(seed 7——固定模板 0 同上)")
adr6.runner().pick_next_node(int(adr6.runner().next_candidate_ids()[0])) # 停点前移批 2026-09-23:入口停点 pick 进首战
var b6 = adr6.runner().current_battle()
var st6 := _adv_st()
var g6 := 30
while adr6.runner().current_battle() == b6 and g6 > 0:
g6 -= 1
autoplay._autoplay_battle(adr6.runner(), b6, st6, null)
check(adr6.abandon(), "重复拒绝场景就位:选路态放弃 → 已结算")
var sum6_before: Dictionary = adr6.summary.duplicate(true)
adr6._on_level_finished({"result": "victory", "hp": 10, "node_index": 1, "node_id": 1, "node_type": "battle"})
check(adr6.summary == sum6_before, "重复关卡结束宣布拒绝(账本与摘要零变化——契约 7)")
# ⑦ GP 跨关延续(L1 收束无 clear · 每战入账恰一次 · L2 商店房余额 > 0 · 冒险收束 clear 恰一次为末事件)
var wallet: Array = st["wallet_events"]
var clears := 0
var gains := 0
for ev in wallet:
if String(ev["event"]) == "clear":
clears += 1
elif String(ev["event"]) == "gain" and String(ev.get("source", "")) != "curio_pick":
gains += 1 # 战斗完成入账计数(三选一 GP 袋件入账另有 source 标记——不计战斗口径)
check(gains == int(st["battles"]), "每战完成入账恰一次(gain %d = battles %d——GP 袋件入账单列)" % [gains, int(st["battles"])])
check(clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear",
"钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)")
var shop_seq: Array = st["shop_gp_seq"]
check(shop_seq.size() == 2 and int(shop_seq[0]) > 0,
"L2 商店房到达余额 > 0(%d——L1 积累跨关延续;两关商店房必经各 1)" % (shop_seq[0] if shop_seq.size() > 0 else -1))
# ⑧ 加算区同账(手工场景:L2 商店购买 → 三选一直调——同一本账累加 + 部分回血封顶)
var same := {}
for s in range(42, 92):
var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s))
a8.set_hero_override(900, 400)
if not a8.start_adventure(s, {"first_script": true}): # 固定模板 0——种子扫描语义按现行图
continue
if _adv_drive_to_shop(a8, _adv_st(), autoplay):
same = {"a": a8, "s": s}
break
check(not same.is_empty(), "同账场景种子可达(seed=%s——L2 商店房就位)" % str(same.get("s", -1)))
if not same.is_empty():
var a8 = same["a"]
var r8 = a8.runner()
var gp0: int = int(r8.gp)
var hp_price := -1
for p in _db.shop.products:
if String(p.get("product_id", "")) == "hp_up":
hp_price = int(p.get("base_price", -1))
var l2_price_bonus := 0
for pb in _db.shop.price_level_bonus:
if int(pb.get("level_index", -1)) == 2:
l2_price_bonus = int(pb.get("bonus", 0))
check(gp0 >= hp_price + l2_price_bonus,
"进店余额 ≥ hp_up L2 首价(%d = 基价 %d + 关卡加成 %d——配置推导)" % [hp_price + l2_price_bonus, hp_price, l2_price_bonus])
check(r8.buy_product("hp_up"), "购买 hp_up 受理(出账 + 加算区)")
var acc_shop := int(a8.run_ledger.bonus.get("max_hp", -1)) # L1 三选一后 + 商店 3(首件随种子)
check(acc_shop >= 3, "统一冒险加算区含商店值(max_hp=%d ≥ 商店 3——L1 三选一件值另计)" % acc_shop)
a8._on_level_finished({"result": "victory", "hp": int(r8.hero_hp), "node_index": r8.node_index,
"node_id": r8.current_node_id(), "node_type": "shop"}) # 模拟 L2 通关(victory 载荷直调——非末关)
check(not a8._curio_pending.is_empty(), "L2 通关三选一停点在案(直调兑付前——零自动推进)")
check(not a8.continue_to_next_level(), "未兑付推进拒绝(选择停点零自动推进契约)")
var l2_cands: Array = a8._curio_pending["candidates"]
var l2_pick: Dictionary = l2_cands[0]
check(a8.pick_curio(String(l2_pick.get("kind", "")), String(l2_pick.get("tier", "")), 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)
check(int(a8.run_ledger.bonus.get("max_hp", -1)) == expect_acc,
"三选一属性件与商店购买同账累加(max_hp=%d = %d + 三选一%s)" % [int(a8.run_ledger.bonus.get("max_hp", -1)), acc_shop, picked2])
var max_eff8: int = a8.effective_max_hp() # T17 口径修复随批:账本生效上限含调试浮窗 pct(a8 已 set 400)
check(int(a8.ledger["hero_hp"]) <= max_eff8,
"通关部分回血封顶(hp %d ≤ 生效上限 %d——血回满语义退役)" % [int(a8.ledger["hero_hp"]), max_eff8])
check(a8.continue_to_next_level(), "兑付后推进受理(下一关开始)")
# ⑨ 恢复点数学(接口③封顶公式三读数 + 同式两落点一致)
var a9 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(99))
check(int(a9.apply_rest(30, 70, 100)) == 30 and int(a9.apply_rest(30, 95, 100)) == 5 \
and int(a9.apply_rest(30, 100, 100)) == 0,
"恢复点数学:floor(100×30%)=30 · 封顶缺口 5 · 满血回 0 合法")
var lr9 = load("res://core/level_runner.gd").new(_db, _fresh_rng(99))
check(int(lr9.rest_heal_amount(30, 70, 100)) == 30 and int(lr9.rest_heal_amount(30, 95, 100)) == 5,
"同一公式两个落点逐位一致(LevelRunner.rest_heal_amount 内置默认)")
# ⑩ 放弃(战斗中拒绝 · 非战斗期受理同失败型结算)
var a10 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(15))
a10.set_hero_override(900, 400)
check(a10.start_adventure(15, {"first_script": true}), "放弃场景:冒险启动(seed 15——固定模板 0 同上)")
check(a10.runner().pick_next_node(int(a10.runner().next_candidate_ids()[0])),
"放弃场景:入口停点 pick 进首战(停点前移批 2026-09-23 驱动口径)")
check(not a10.abandon(), "战斗中 abandon 拒绝(战斗域单事务在飞)")
var b10 = a10.runner().current_battle()
var st10 := _adv_st()
var g10 := 30
while a10.runner().current_battle() == b10 and g10 > 0:
g10 -= 1
autoplay._autoplay_battle(a10.runner(), b10, st10, null)
check(a10.runner().current_battle() == null and a10.abandon(), "非战斗期(选路态)abandon 受理")
# ⑪ 池耗即枯竭 + 池收缩 + 满血回 0(TP0——white-box:耗尽 attack/ap 令牌后候选零出现;
# 种类次数上限退役,「达上限剔除」语义由牌库耗尽承载)
var a11 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(11))
check(a11.start_adventure(11, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}),
"剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)")
for di in range(a11._curio_deck.size() - 1, -1, -1):
var dk := String((a11._curio_deck[di] as Dictionary).get("kind", ""))
if dk == "attack" or dk == "ap":
a11._curio_deck.remove_at(di)
var draw: Array = a11._draw_curio_candidates()
var draw_kinds := {}
for t in draw:
draw_kinds[String((t as Dictionary).get("kind", ""))] = true
check(not draw_kinds.has("attack") and not draw_kinds.has("ap"),
"池耗即枯竭(attack/ap 令牌耗尽不出候选——%s)" % str(draw))
check((draw as Array).size() >= 2, "池耗后候选恒 ≥2(稀有/罕见档余量——防「三选项全不可选」死角)")
# 池收缩(迭代 11 同调批 T7——aux 卡退役出三选一牌库:重复抽样零 aux 候选;同调位替换两路
# white-box 测试随停点渠道退役拆除——机械惰性承载,零投放零行为)
var treasure_seen := false
for i in 40:
for t in a11._draw_curio_candidates():
if String((t as Dictionary).get("kind", "")) == "treasure_item":
treasure_seen = true
break
check(not treasure_seen, "池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)")
# 满血回 0 合法(通关回血封顶口径)
var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12))
a12.start_adventure(12, {"first_script": true})
var cap := {"payload": {}}
a12.curio_choice_presented.connect(func(p): cap["payload"] = p)
var max_full := int(_db.hero.max_hp) + int(a12._base_override.get("max_hp", 0))
a12.ledger["hero_hp"] = max_full
a12._on_level_finished({"result": "victory", "hp": max_full, "node_index": 1, "node_id": 1, "node_type": "shop"})
check(int(cap["payload"].get("heal_gained", -1)) == 0,
"满血通关回血 0 合法(封顶口径——heal_gained=0 非负溢出)")
# ⑬ 奖励节点共享池(迭代 10 追加批——同库同池同摘牌;TP0:attack 令牌耗尽后节点域同样零出现)
var a13 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(13))
check(a13.start_adventure(13, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}),
"节点共享池场景:冒险启动(seed 13)")
for di13 in range(a13._curio_deck.size() - 1, -1, -1):
if String((a13._curio_deck[di13] as Dictionary).get("kind", "")) == "attack":
a13._curio_deck.remove_at(di13)
var ndraw: Dictionary = a13.node_curio_candidates()
var ncands13: Array = ndraw.get("candidates", []) as Array
var has_attack13 := false
for t in ncands13:
if String((t as Dictionary).get("kind", "")) == "attack":
has_attack13 = true
break
check(not has_attack13,
"奖励节点共享池:令牌耗尽同样不出候选(同库同池——%s)" % str(ncands13))
a13.present_node_curio(ncands13)
a13.present_node_curio([{"kind": "gp", "tier": "common"}])
var gp13 := int(a13.ledger.get("gp", 0))
check(a13.apply_node_curio("gp", "common") and int(a13.ledger.get("gp", 0)) == gp13 + int(_db.adventure.curio_value("gp", "common")),
"奖励节点 GP 件入账(钱包「奖励入账」收口——+%d)" % int(_db.adventure.curio_value("gp", "common")))
check(a13._node_curio_pending.is_empty(), "节点停点态兑付后清零(恰一次语义同构)")
var s10: Dictionary = a10.summary
check(String(s10.get("result", "")) == "abandoned" and not bool(s10.get("cleared", true)),
"放弃摘要:abandoned · 未打穿(已获大奖励不追回——同失败型结算)")
# ---- 专长重设计实装冒烟(迭代 20 T3——feats 四级换代 · 案D 池接线 · V3 订阅面逐件三列)----
func _smoke_feats() -> void:
print("--- 专长重设计实装(feat_table 四级 · 专长带池接线 · 订阅面逐件)---")
# ① 现行表牌库构成:重设计 29 件全量入池——98 数值令牌+29 relic 令牌(四级 rarity 直映射)
check(_db.feats != null and (_db.feats.rows as Array).size() == 29, "feat_table 已加载(重设计 29 件=基础 27+迁入 2——卡文 §10.4 推荐案口径)")
check(_db.feats.validate().is_empty(), "feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)")
var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71))
check(a_r0.start_adventure(71, {"first_script": true}), "专长带场景:冒险启动(seed 71)")
var 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
check(feat_tokens == 29 and int(tier_counts.get("common", 0)) == 12 and int(tier_counts.get("excellent", 0)) == 9 and int(tier_counts.get("rare", 0)) == 8 and a_r0._curio_deck.size() == 133,
"专长带入池:29 件四级直映射(普通 12/优秀 9/稀有 8/传说 0)——牌库 104+29=133〔专长批 V2〕")
# ② 旧 27 件退役钉面(V2——arcane_ember 系旧件表首行;全仓 grep 零残留归批末静态核查)
check(_db.feats.row_by_id("arcane_ember").is_empty() and _db.feats.row_by_id("magma_body").is_empty(),
"旧 27 件已退役:arcane_ember/magma_body 表内零残留〔专长批 V2〕")
check(_db.treasure_items.row_by_id("venom_edge").is_empty() and _db.treasure_items.row_by_id("flag_burst").is_empty(),
"迁出收尾:treasure_items 无 venom_edge/flag_burst 行(同调目录自然收窄)〔专长批 V13〕")
# ③ 契约面静态钉扎(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
check(kinds_ok, "层级纪律:牌库 kind 全集=CURIO_KINDS+relic(秘宝 item_id 零入池)〔专长批 V18〕")
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
check(rewrite_rows == 0, "棋盘事实不可变:新 29 行零棋盘改写类订阅(rewrite_board 已除名消费面)〔专长批 V19〕")
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
check(bad_words == 0, "对外命名纪律:29 行 display_name/desc 零「雷/插旗」字面(封印/陷阱风味系)〔专长批 V21 初筛〕")
check(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,
"数值纪律:陷阱爆发倍率>1 · 血蜜回复<典型陷阱伤 · 石肤上限 3 在册(结构锚形态内 provisional)〔专长批 V22 机检半〕")
# ④ 临时表注入(新 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": "flag_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": "flag_hit", "do": {"kind": "ac_bonus", "value": 1, "mode": "round"}}]},
]
check(rt.validate().is_empty(), "新 schema 构造表 validate 零违例(四级 rarity/cap 字段/词表全过——流派标签已退役零 archetype)")
var live_rows: FeatTable = _db.feats
_db.feats = rt
var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72))
check(a_r.start_adventure(72, {"first_script": true}), "专长带场景:冒险启动(seed 72——注入 5 行临时表)")
var deck_relic := 0
for t in a_r._curio_deck:
if String((t as Dictionary).get("kind", "")) == "relic":
deck_relic += 1
check(deck_relic == 5 and a_r._curio_deck.size() == 109,
"专长带入池:5 行 relic 每件 1 张(四级 rarity 直映射)——牌库 104+5=109")
# T4 渠道白名单断言(迭代 15 卡②——渠道轴定位:通关渠道只出专长/节点渠道零专长)
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
check(feat_seen.size() >= 2, "通关渠道只出专长(60 次重抽 ≥2 种不同 relic——身份键 feat_id 同档可多件)")
check(not node_relic_seen, "节点渠道零专长(60 次重抽零 relic——专长在非 Boss 渠道只以门票形态出现)")
# 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"}]}
check(a_t.apply_node_curio("feat_ticket", "excellent"), "A5 门票兑付受理(节点渠道——拿到即开)")
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
check(tkt_all_relic, "A5 拿到即开——停点候选已替换为专长三选一(全 relic)")
check(a_t.pool_stock("attack") == int(stock_before["attack"]) and a_t.pool_stock("gp") == int(stock_before["gp"]),
"A5 零摘牌——门票兑付不动令牌库(不进持有集)")
check(not (a_t.feats_held() as Array).has("feat_ticket"), "A5 无门票残留持有集")
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", "")))
check((a_t.feats_held() as Array).size() == feats_n + 1, "A5 专长面兑付受理(门票开出候选可正常入持有集)")
# 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")
check(a_t._feat_ticket_consider(rg_t), "A6 保底触发(pity=5 → 第 6 次展示必出——概率无关)")
check(a_t._feat_ticket_pity == 0, "A6 触发后清零")
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)
check(not a_t._feat_ticket_consider(rg_t), "A6 限购满不再触发(保底冻结)")
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
check(offered, "A6 概率面存在(chance=20% 200 展示内必出现——零触发=概率面失守)")
a_r._curio_pending = {"candidates": [{"kind": "relic", "tier": "rare", "feat_id": "x3"}, {"kind": "gp", "tier": "common"}], "payload": {}}
check(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,
"relic 兑付:入持有集 + 牌库摘牌(选中踢池——池库存 %d→%d)" % [deck_relic, deck_relic - 1])
# 断点往返(持有集+余库 feat_id 随行)
var sm_r = load("res://core/save_manager.gd").new("user://_smoke_feats_bp.json")
sm_r.load_from_disk()
a_r.set_save_manager(sm_r)
a_r._on_level_finished({"result": "victory", "hp": int(a_r.ledger.get("hero_hp", 26)), "node_index": 1, "node_id": 1, "node_type": "shop"})
var bp_r: Dictionary = sm_r.breakpoint_snapshot()
check((bp_r.get("relics", []) as Array).has("x3"), "断点附随件:relics 持有集入快照(迭代 12 T1 扩列)")
check(int((bp_r.get("adventure_counters", {}) as Dictionary).size()) >= 0, "断点附随件:adventure 计数器账本键在案(T6/T7 扩列)")
var bp_deck_x3 := 0
for t in (bp_r.get("bonus_deck", []) as Array):
if String((t as Dictionary).get("feat_id", "")) == "x3":
bp_deck_x3 += 1
check(bp_deck_x3 == 0, "断点余库:已兑付 relic 令牌逐枚还原(x3 零残留——续玩不重复获得)")
sm_r.clear_breakpoint()
_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)
fx.counter_add(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("dig_paid", {"ap_cost": 1})
var refund := false
for h in hits_dp:
if String(h.get("kind", "")) == "grant_ap":
refund = true
fx.counter_add("twin_digs", 1)
if i == 1 or i == 3:
check(refund, "双管镐:第 %d 次付费挖格返 1 AP(每第 2 次——mod_eq 预评估口径)" % (i + 1))
else:
check(not refund, "双管镐反例:第 %d 次不返(未满 2 次)" % (i + 1))
fx.rebuild(["geologist"], [], [])
var geo_hits: Array = fx.dispatch("dig_reveal", {"new_resource_type": true})
apply_fx.call(geo_hits)
check(orch._hits_of(geo_hits, "counter_add").size() == 1, "地质学家:首开新类型集 1 点〔专长批 V3〕")
fx.counter_add("geo_types", 2) # 手动基数补齐(row1 已落账 1+2=3——满 4 种的预评估现值)
var geo_hits2: Array = fx.dispatch("dig_reveal", {"new_resource_type": true})
apply_fx.call(geo_hits2)
check(orch._hits_of(geo_hits2, "reveal_tiles").size() == 1 and fx.counter("geo_types") == 0,
"地质学家:满 4 种(预评估现值 3+本格)→揭示 1 格·计数清零〔专长批 V3〕")
var geo_hits3: Array = fx.dispatch("dig_reveal", {"new_resource_type": false})
check(orch._hits_of(geo_hits3, "counter_add").is_empty(), "地质学家反例:同类型重复首开不叠点〔专长批 V3〕")
fx.rebuild(["hard_hat"], [], [])
apply_fx.call(fx.dispatch("dig_reveal", {}))
apply_fx.call(fx.dispatch("dig_reveal", {}))
check(fx.counter("helm_layers") == 2, "安全帽:开安全格攒层 ×2〔专长批 V3〕")
var hh_hits: Array = fx.dispatch("mine_hit", {"damage": 6})
var hh_reduce := 0
for h in hh_hits:
if String(h.get("kind", "")) == "damage_reduce":
hh_reduce = fx.counter(String(h.get("value_counter", "")))
check(hh_reduce == 2, "安全帽:踩雷耗层减伤=层值 2(一次清零随施加面 set 行)〔专长批 V3〕")
fx.rebuild(["skip_mining"], [], [])
var sk_zero: Array = fx.dispatch("dig_reveal", {"is_blank_zero": true})
apply_fx.call(sk_zero)
check(orch._hits_of(sk_zero, "counter_add").size() == 1, "跳格采掘:空白 0 格攒 1 点〔专长批 V3〕")
fx.counter_add("skip_pts", 1) # 手动基数补齐(已落账 1+1=2——满 3 点的预评估现值)
var sk_full: Array = fx.dispatch("dig_reveal", {"is_blank_zero": true})
apply_fx.call(sk_full)
check(orch._hits_of(sk_full, "reveal_tiles").size() == 1,
"跳格采掘:满 3 点→揭示 1 格〔专长批 V3〕")
check(orch._hits_of(fx.dispatch("dig_reveal", {"is_blank_zero": false}), "counter_add").is_empty(),
"跳格采掘反例:普通开格不计点〔专长批 V3〕")
fx.rebuild(["blast_accounting"], [], [])
for i in 5:
var ba_hits: Array = fx.dispatch("dig_paid", {"ap_cost": 1})
for h in ba_hits:
if String(h.get("kind", "")) == "counter_add":
fx.counter_add(String(h.get("counter_id", "")), 1)
check(orch._hits_of(fx.dispatch("round_end", {}), "grant_gp").size() == 1, "爆破会计:本轮挖格花费≥5→+1 GP〔专长批 V3〕")
fx.rebuild(["frontier_miner"], [], [])
check(orch._hits_of(fx.dispatch("dig_reveal", {"is_edge_cell": true}), "grant_material").size() == 1
and orch._hits_of(fx.dispatch("dig_reveal", {"is_edge_cell": false}), "grant_material").is_empty(),
"边陲矿工:边缘格产出 +1·中心格反例零产出〔专长批 V3〕")
# 插旗系
fx.rebuild(["seal_echo"], [], [])
apply_fx.call(fx.dispatch("flag_hit", {}))
apply_fx.call(fx.dispatch("flag_hit", {}))
var se_hits: Array = fx.dispatch("round_end", {})
check(orch._hits_of(se_hits, "grant_ap").size() == 1 and String((orch._hits_of(se_hits, "grant_ap")[0] as Dictionary).get("mode", "")) == "queue",
"封印回响:标中计数≥2→轮末返 1 AP(下轮 queue)〔专长批 V3〕")
fx.rebuild(["seal_smith"], [], [])
var sm_remove := 0
for i in 3:
var sm_dispatch: Array = fx.dispatch("flag_hit", {})
apply_fx.call(sm_dispatch)
for h in sm_dispatch:
if String(h.get("kind", "")) == "remove_mines":
sm_remove += 1
check(sm_remove == 1, "封印匠:标中每第 3 次→中立移除恰一次(频率闸死)〔专长批 V3〕")
fx.rebuild(["seal_stake"], [], [])
check(orch._hits_of(fx.dispatch("flag_hit", {}), "ac_bonus").size() == 1
and String((orch._hits_of(fx.dispatch("flag_hit", {}), "ac_bonus")[0] as Dictionary).get("mode", "")) == "round",
"封印桩:标中本轮 AC +N(round 域——round_start 清零)〔专长批 V3〕")
fx.rebuild(["warning_beacon"], [], [])
check(orch._hits_of(fx.dispatch("flag_hit", {}), "preview_area").size() == 1, "示警烟火:标中揭示 3×3 资源类型(不揭安危位置)〔专长批 V3〕")
# 踩雷/受伤系
fx.rebuild(["pain_memory"], [], [])
apply_fx.call(fx.dispatch("mine_hit", {}))
apply_fx.call(fx.dispatch("mine_hit", {}))
var pm_hits: Array = fx.dispatch("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 = fx.counter(String(h.get("value_counter", "")))
check(pm_bonus == 2, "疼痛记忆:攻击附加层值伤害=层值 2·用后清零(set 行随施加面)〔专长批 V3〕")
fx.rebuild(["fuse_wire"], [], [])
var fw_hits: Array = fx.dispatch("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))
check(orch._hits_of(fx.dispatch("damage_taken", {"kind": "terrain", "amount": 3}), "shield").is_empty(),
"保险丝反例:单次 <5 不攒点不触发〔专长批 V3〕")
for h in fx.dispatch("damage_taken", {"kind": "terrain", "amount": 6}):
if String(h.get("kind", "")) == "counter_add":
fx.counter_add(String(h.get("counter_id", "")), int(h.get("value", 0)))
check(fw_shield == 0, "保险丝:首点不出盾(2 点才出——预评估口径)〔专长批 V3〕")
fx.rebuild(["blood_insight"], [], [])
check(orch._hits_of(fx.dispatch("mine_hit", {}), "reveal_tiles").size() == 1, "见血开窍:踩雷揭示 1 格〔专长批 V3〕")
fx.rebuild(["trap_instinct"], [], [])
apply_fx.call(fx.dispatch("mine_hit", {}))
var ti_hits: Array = fx.dispatch("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 = fx.counter(String(h.get("value_counter", "")))
check(ti_val == 2, "陷阱本能:踩雷本轮攻击 +2(attack_bonus 通道——面板加算)〔专长批 V3〕")
fx.rebuild(["chain_traps", "pain_memory"], [], [])
apply_fx.call(fx.dispatch("mine_hit", {}))
var ct_before: int = fx.counter("pain_layers")
apply_fx.call(fx.dispatch("mine_hit", {}))
check(fx.counter("pain_layers") == ct_before + 2, "连环陷阱:同轮第 2 颗起每颗额外 +1 层(疼痛记忆同账)〔专长批 V3〕")
fx.rebuild(["trap_burst"], [], [])
var tb_hits: Array = fx.dispatch("mine_hit", {"damage": 8})
check(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,
"陷阱爆发:全体敌雷伤×倍率直伤(blast_scaled——施加面值=damage×mult)〔专长批 V3〕")
fx.rebuild(["blood_honey"], [], [])
check(orch._hits_of(fx.dispatch("mine_hit", {}), "heal").size() == 1, "血蜜:踩雷回复 N 生命〔专长批 V3〕")
fx.rebuild(["stone_skin"], [], [])
var ss_hits: Array = fx.dispatch("damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1})
check(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,
"石肤:受伤 AC+1(本场域 cap 3——达上限不再涨由累积器封顶)〔专长批 V3〕")
fx.rebuild(["last_stand"], [], [])
check(orch._hits_of(fx.dispatch("damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 1, "hp_ratio": 40}), "damage_reduce").size() == 1
and orch._hits_of(fx.dispatch("damage_taken", {"kind": "enemy_skill", "amount": 5, "source": "s", "round": 2, "hp_ratio": 80}), "damage_reduce").is_empty(),
"背水:生命≤50% 减伤·过半血反例零减伤〔专长批 V3〕")
# 跨族桥
fx.rebuild(["breaker_strike"], [], [])
check(orch._hits_of(fx.dispatch("mine_hit", {"dug_under_flag": true}), "counter_add").size() == 1
and orch._hits_of(fx.dispatch("mine_hit", {"dug_under_flag": false}), "counter_add").is_empty(),
"破封一击:挖开带封印危险格 +1 层·无旗反例零触发〔专长批 V3〕")
# 节奏系
fx.rebuild(["map_exchange"], [], [])
var me_hits: Array = fx.dispatch("dig_stop", {"ap_left": 4})
apply_fx.call(me_hits)
fx.counter_add("stop_swap", 4) # value_event="ap_left" 解析归施加面(action_economy 载荷取值)——冒烟手动落账
check(orch._hits_of(me_hits, "counter_add").size() == 1, "弃图兑换:停手剩余 AP≥3 入账〔专长批 V3〕")
var me_atk := 0
for h in orch._hits_of(fx.dispatch("attack_commit", {"atk": 10, "target": "敌", "matchup": "", "dig_streak": 0, "nest_count": 0}), "attack_bonus"):
me_atk = fx.counter(String(h.get("value_counter", "")))
check(me_atk == 4, "弃图兑换:折为攻击伤害=剩余 AP 全额·用后清零〔专长批 V3〕")
check(orch._hits_of(fx.dispatch("dig_stop", {"ap_left": 2}), "counter_add").is_empty(),
"弃图兑换反例:剩余 AP<3 不触发〔专长批 V3〕")
fx.rebuild(["last_drop"], [], [])
var ld_hits: Array = fx.dispatch("round_start", {"round": 2, "ap_exhausted_last": true})
check(orch._hits_of(ld_hits, "grant_ap").size() == 1, "一滴不剩:上轮 AP 耗尽→+1 AP〔专长批 V3〕")
check(orch._hits_of(fx.dispatch("round_start", {"round": 3, "ap_exhausted_last": false}), "grant_ap").is_empty(),
"一滴不剩反例:上轮有余不发〔专长批 V3〕")
fx.rebuild(["market_close"], [], [])
for i in 8:
for h in fx.dispatch("dig_reveal", {}):
if String(h.get("kind", "")) == "counter_add":
fx.counter_add(String(h.get("counter_id", "")), 1)
check(orch._hits_of(fx.dispatch("round_end", {}), "grant_gp").size() == 1, "收盘:本轮开格≥8→+1 GP〔专长批 V3〕")
# 战斗系
fx.rebuild(["execution_scholar"], [], [])
check(orch._hits_of(fx.dispatch("kill", {"target": "敌", "overflow": 0}), "reveal_tiles").size() == 1,
"斩首学者:击杀揭示 1 格〔专长批 V3〕")
fx.rebuild(["executioner"], [], [])
var ex_hits: Array = fx.dispatch("enemy_hp_threshold", {"unit": "e1", "pct": 30, "is_boss": false})
check(orch._hits_of(ex_hits, "target_attack_bonus").size() == 1
and orch._hits_of(fx.dispatch("enemy_hp_threshold", {"unit": "e1", "pct": 60, "is_boss": false}), "target_attack_bonus").is_empty(),
"处刑台:敌血量≤30% 挂单位标记加伤·阈值外反例零命中〔专长批 V3〕")
fx.rebuild(["field_dressing"], [], [])
check(orch._hits_of(fx.dispatch("battle_won", {"round": 2}), "heal").size() == 1,
"战地包扎:战斗胜利回复 N 生命(battle_won 新挂点)〔专长批 V3〕")
# 契约尖点
fx.rebuild(["blood_tax"], [], [])
check(orch._hits_of(fx.dispatch("mine_hit", {}), "bonus_delta").size() == 1
and String((orch._hits_of(fx.dispatch("flag_hit", {}), "bonus_delta")[0] as Dictionary).get("axis", "")) == "atk",
"血税:双向同局账本(踩陷阱−2max_hp/封印命中+1atk——bonus_delta 战末汇入)〔专长批 V3〕")
# 迁入 2 件
fx.rebuild(["venom_edge"], [], [])
check(orch._hits_of(fx.dispatch("clamp_check", {"matchup": "magic_vs_heavy"}), "modify_ticket").size() == 1
and orch._hits_of(fx.dispatch("clamp_check", {"matchup": "normal_vs_light"}), "modify_ticket").is_empty(),
"淬毒刃纹(迁入):克制正面(magic_vs_heavy 矩阵值>0)追加·中性面反例零触发〔专长批 V3〕")
fx.rebuild(["flag_burst"], [], [])
check(orch._hits_of(fx.dispatch("flag_hit", {}), "open_ticket").size() == 1,
"封印爆裂(迁入):标中全体敌群伤〔专长批 V3〕")
# 零持有
var orch0 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(74))
orch0._fx.rebuild([], [], [])
check(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(),
"零持有订阅=零命中(relic 关着零扰动——伤害管线既有行为逐位不变)")
# ⑦c 特性持有呈现段(2026-10-04 用户反馈补齐——C 区「特性」节;呈现换代面=06 在册不随批)
var panel = load("res://presentation/views/hero_info_panel_view.gd").new()
panel.set_feat_holdings([])
check(not panel._feat_section.visible, "特性段空态隐藏(视图空态纪律同特性段)")
panel.set_feat_holdings(["疼痛记忆 ×1", "双管镐 ×1"])
check(panel._feat_section.visible and panel._feat_list.get_child_count() == 2,
"特性段持有呈现(逐件行常驻可读——「局内奖励选了什么看不到」缺口承载)")
check(String(load("res://presentation/battle_narrator.gd").feat_card_text({"feat_id": "pain_memory", "display_name": "疼痛记忆", "desc": "触陷阱攒 1 层"})).contains("触陷阱攒 1 层"),
"特性卡文案表值化:desc 行字段直读(零硬编码映射)")
# 手工场景驱动:L1 全程 + L2 推进到商店房(_drive_level 复用 + 兑付确认/选路/大奖励确认手工编排)
func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool:
var guard := 24
while guard > 0 and String(st["result"]) == "":
guard -= 1
autoplay._drive_level(a.runner(), st, null, "normal", "min")
if String(st["result"]) != "":
return false
if a._pending_next_level:
# 三选一停点确认(迭代 10——确定性选件后推进,与 run_adventure 自动策略同口径;TP0 令牌化)
if not a._curio_pending.is_empty():
var bcands: Array = a._curio_pending["candidates"]
var bpick: Dictionary = autoplay._pick_token(bcands)
a.pick_curio(String(bpick.get("kind", "")), String(bpick.get("tier", "")), String(bpick.get("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":
# 奖励节点三选一(迭代 10 追加批——确定性选件、替换放弃新件、兑付确认回图;TP0 令牌化)
var ndraw: Dictionary = a.node_curio_candidates()
var ncands: Array = ndraw.get("candidates", []) as Array
if not ncands.is_empty():
a.present_node_curio(ncands)
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 的同构子集)
func _adv_st() -> Dictionary:
return {"result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0,
"rest_healed": 0, "hp_before_rest": -1, "defeat_node_index": -1, "defeat_node_type": "",
"boss_depth": -1, "hp_at_boss": -1, "rows": [], "timeout": false, "log_path": "",
"policy": "normal", "refreshes": 0, "mine_hits": 0, "visited_ids": [], "choices": 0}
# ---- 战斗屏棋盘布局·格边长公式回归(2026-09-19 真人试玩 bug⑥:L2 12×12 板下缘被裁约 1/6 格高——
# 旧公式 N×cell ≤ 可用高−12 未计 (N−1)×2px 网格间距,网格实高溢出被 clip_contents 裁掉;
# 修复 = 公式计入间距 + 垂直预留 24px〔上 12/下 12——底部不贴 D 区边框〕)----
func _smoke_layout_cell_formula() -> void:
print("--- 棋盘布局格边长公式(底部预留回归)---")
var board_generator = load("res://core/board_generator.gd").new()
var board_view = load("res://presentation/views/board_view.gd").new()
for L in [1, 2]:
var b = board_generator.generate(L, 0, _mk_stream("board_gen", 555), _db)
board_view.rebuild(b, null)
var avail := Vector2(1036, 460) # 受约束 D 区可用面积样本(1280×800 档量级——用户实测裁切场景)
board_view.set_cell_area(avail)
var n: int = maxi(b.width, b.height)
var cell: float = board_view.get("_cell")
var grid_h := n * int(cell) + (n - 1) * 2
check(grid_h <= int(avail.y) - 24, "L%d %dx%d 板网格高 %d ≤ 可用高 %d − 预留 24(格边 %dpx——下缘不贴边不裁切)" % [L, b.width, b.height, grid_h, int(avail.y), int(cell)])
check(cell >= 26.0 and cell <= 46.0, "L%d 格边在 26–46 档内(%dpx)" % [L, int(cell)])
board_view.free()
# ---- 地图战斗资源·身份面单元冒烟(格面资源批:roll 恰一次 / 雷安同池 / 幂等 / 清图批尾)----
func _smoke_reward_identity_unit() -> void:
print("--- 地图战斗资源·格面资源身份面 ---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260911)
var reward_economy_factory = load("res://core/reward_economy.gd")
var reward_economy = reward_economy_factory.new(_db, rng)
var eligible: Array = []
for i in range(6):
eligible.append({"cell": Vector2i(i, 0)})
check(reward_economy.roll_board(eligible), "roll_board 受理")
check(not reward_economy.roll_board(eligible), "重复 roll_board 被拒(恰一次——double-roll 守卫)")
# 身份查询投影 = {reward_id, cell_type, tier};五型封闭
var types_ok := true
var types_seen := {}
for e in eligible:
var ident: Dictionary = reward_economy.query_identity(e.cell)
if not (ident.has("reward_id") and ident.has("cell_type") and ident.has("tier")):
types_ok = false
if not RewardTable.CELL_TYPES.has(String(ident.cell_type)):
types_ok = false
types_seen[String(ident.cell_type)] = true
check(types_ok, "query_identity 投影 = {reward_id, cell_type, tier} 且五型封闭(样本 %s)" % str(types_seen.keys()))
# 清单外坐标 = null(E19——封锁格不入清单的同款出口)
check(reward_economy.query_identity(Vector2i(99, 99)) == null, "清单外坐标查询 = null(封锁格无身份同口径)")
# 同格重复受理幂等:同一快照逐字段相同
var snap1: Dictionary = reward_economy.grant(eligible[0].cell)
var snap2: Dictionary = reward_economy.grant(eligible[0].cell)
check(JSON.stringify(snap1) == JSON.stringify(snap2), "同格重复 grant 幂等(同一快照——受理恰一次)")
check(reward_economy.grant(Vector2i(99, 99)) == null, "清单外 grant 拒绝")
# 清图批尾身份:clear_default 唯一活行 clear_blast(blast 型正向);重调幂等
var cs1: Dictionary = reward_economy.grant_clear()
var cs2: Dictionary = reward_economy.grant_clear()
check(String(cs1.cell_type) == "blast" and String(cs1.reward_id) == "clear_blast", "清图批尾身份 = clear_blast(clear_default 唯一活行)")
var cb_range := [-1, -1]
for row in _db.reward.rows:
if String(row.reward_id) == "clear_blast":
cb_range = [int(row.get("power_min", row.power)), int(row.get("power_max", row.power))]
check(int(cs1.power) >= int(cb_range[0]) and int(cs1.power) <= int(cb_range[1]),
"清图奖励 power ∈ 表区间 [%d, %d](roll 值 %d——配置推导)" % [cb_range[0], cb_range[1], int(cs1.power)])
check(JSON.stringify(cs1) == JSON.stringify(cs2), "grant_clear 同批次重调幂等")
# 退役后受理拒绝
reward_economy.retire_mapping()
check(reward_economy.grant(eligible[0].cell) == null, "映射退役后 grant 拒绝(生命周期闭合)")
# ---- 地图战斗资源·伤害量值区间批冒烟(2026-09-14:身份批 roll 域内 + 中点锚 + 变异实际流动 + 定值行不受扰)----
# ---- 排雷玩法 旗动作耗尽末点 AP 回归(playtest 缺陷②——v1 冒烟盲区:自动玩家不用旗)----
func _smoke_flag_ap_exhausted() -> void:
print("--- 排雷玩法·旗动作耗尽末点 AP 回归 ---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260911)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rounds: Array = []
settlement.round_started.connect(func(ri): rounds.append(ri))
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载旗回归场景)")
var minesweeper = settlement.minesweeper
# 恰花 (AP-1) 点挖掘,留末点给旗(表驱动——2026-09-16 AP 压速批 8→6 不再写死)
var ap_total: int = int(_db.action_economy.ap_per_round)
for i in range(ap_total - 1):
if settlement.action_economy.phase != "Active" or settlement.action_economy.ap == 0:
break
var plan = AutoPlayer.choose_dig(minesweeper)
if plan.is_empty():
break
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
check(settlement.action_economy.phase == "Active" and settlement.action_economy.ap == 1, "%d 次挖掘后恰剩 1 AP(phase=%s ap=%d)" % [ap_total - 1, str(settlement.action_economy.phase), settlement.action_economy.ap])
# 末点 AP 花在插旗——轮次必须终结并结算进入第 2 轮
# 旗目标 = 挖掘策略的下一目标格(构造保证:未开、与小人邻接——先把小人移到其邻接已开格)
var plan2 = AutoPlayer.choose_dig(minesweeper)
check(not plan2.is_empty(), "仍存在可挖目标(旗目标来源)")
var out: Array = []
if not plan2.is_empty():
if plan2.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan2.hero_cell}])
out = minesweeper.settle_batch([{"action": "flag_place", "cell": plan2.dig_cell}])
check(not out.is_empty() and bool(out[0].accepted), "末点 AP 插旗被受理")
check(rounds.size() >= 2, "旗动作耗尽 AP 触发轮末结算并进入第 2 轮(rounds=%s)" % str(rounds))
# ---- 关卡节点地图·线性推进冒烟(2026-09-11 关卡扩展批)----
func _smoke_level() -> void:
print("--- 关卡节点地图·线性推进(战×2→道具→战×2→恢复→Boss)---")
var autoplay = load("res://smoke/level_autoplay.gd")
var hero_base := {"atk": int(_db.hero.atk), "hp": int(_db.hero.max_hp), "ac": int(_db.hero.armor_class)} # 配置基线快照——⑤ 回基线对比用(改表自动跟随,不再硬编码)
# ① 满配通关路径(atk+200%/hp+100%——测试面板同源的属性覆盖)
var win = autoplay.run_level(_db, 42, 1, 200, 100)
check(win.result == "victory", "关卡·满配(atk+200/hp+100)通关(实测:%s @节点 %d)" % [win.result, win.defeat_node_index])
check(win.battles == 5, "战斗节点数 = 5(战×4 + Boss)")
check(win.boss_depth == 4, "Boss 节点以 Dmax=4 解析(棋盘生成 第三轴登记约定)")
check(win.hp_at_boss >= 1 and win.hp_at_boss <= int(_db.hero.max_hp) * 3, "进 Boss HP 携带在域内(%d)" % win.hp_at_boss)
# ② 恢复点:回复 = min(floor(生效max_hp×pct/100), 缺口)——hp+100% 档生效 max = 基线×2
# seed 42(2026-09-19 充能退役批换种:原 4242 因主角输出模型变化在恢复点前败北——探针目的 = 验回复公式,种子仅载体)
var rest = autoplay.run_level(_db, 42, 1, 100, 100)
if rest.result != "start_failed" and rest.hp_before_rest >= 0:
var eff_max := maxi(1, int(round(float(_db.hero.max_hp) * 2.0))) # hp_pct=100(run_level 已恢复基线)
var expect: int = mini(int(floor(float(eff_max) * 0.3)), eff_max - rest.hp_before_rest)
check(rest.rest_healed == expect, "恢复点回复 = floor(生效max_hp×30%%) 与缺口的较小值(%d/%d,生效 max %d)" % [rest.rest_healed, expect, eff_max])
else:
check(false, "恢复点未到达(seed 42 hp+100 在恢复点前败北?%s)" % rest.result)
# ③ 裸装败北路径(atk1/hp10——Roguelike 基线应难以通关)
var lose = autoplay.run_level(_db, 42, 1, -80, -80)
check(lose.result == "defeat", "关卡·裸装(atk≈1/hp≈10)败北路径可达(实测:%s)" % lose.result)
check(lose.defeat_node_index >= 0 and lose.defeat_node_index < 7, "败北节点记录在域内(节点 %d)" % lose.defeat_node_index)
# ④ 回放确定性:同种子两遍步骤流逐行相同
var rep1 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0)
var rep2 = autoplay.run_level(_db, 777, 1, 100, 100, true, null, "normal", "min", 0)
check(JSON.stringify(rep1.rows) == JSON.stringify(rep2.rows), "关卡回放断言:同种子两遍结算步骤流逐行相同")
check(rep1.rows.size() > 0, "关卡步骤流非空(%d 行)" % rep1.rows.size())
# ⑤ 属性覆盖恢复:模拟后 hero 表回基线(调整板不泄漏到后续会话;基线 = 函数入口的配置快照,改表自动跟随)
check(int(_db.hero.atk) == int(hero_base.atk) and int(_db.hero.max_hp) == int(hero_base.hp) \
and int(_db.hero.armor_class) == int(hero_base.ac),
"模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" % [int(_db.hero.atk), int(_db.hero.max_hp), int(_db.hero.armor_class)])
# ---- 迭代 24 N3:敌方攻击类型轴(2026-10-13——表列/单位快照/单据载荷/矩阵命中证词)----
# 克制矩阵命中证词 = 同种子三甲型配对实测(不是查表存在性证词):同种子下 armor_roll 流消费逐位一致、
# 阈值(hero armor_class)不变,三甲型唯一差异 = 克制点数 cdelta(normal_vs_light 0 / heavy −1 / ward +1)
# → heavy 伤害 == light −1、ward == light +1(要求基线伤害 ≥ 2——地板 1 会吞 −1 位移)。
# 承伤位移红线(卡片 §8 L3):全局位移只允许 ±1 量级——非此量级 = 矩阵消费面有未盘点的第二落点(停机)。
func _smoke_enemy_attack_type() -> void:
print("--- 敌方攻击类型轴(迭代 24 N3)---")
# ① 表列:20 行全填 normal(N3 全填不做差异化——Q9)
var at_ok := true
var at_detail := ""
for row in _db.enemy.rows:
var at := String(row.get("attack_type", ""))
if at != "normal":
at_ok = false
at_detail += " %s:%s" % [String(row.get("enemy_id", "?")), at]
check(at_ok, "敌方 %d 行 attack_type 全填 normal(迭代 24 N3 轴开列,越轨行%s)" % [
_db.enemy.rows.size(), at_detail if at_detail != "" else "无"])
# ②③④ 矩阵命中证词扫描:(3,0) 灰矮人 atk 区间 [2,4] 基线足够((1,2) 熊地精 atk 1 会塌地板);
# 同 seed 三甲型各开一场、各取首个敌方伤害行,对拍 ±1。
var matrix_found := false
var snap_ok := true
var payload_at := ""
var t_light := -1
var t_heavy := -1
var t_ward := -1
for seed_offset in range(40):
var totals := {}
for armor in ["light", "heavy", "ward"]:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20261013 + seed_offset)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 3, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "armor_type": armor}):
totals[armor] = -1
continue
if armor == "light":
for u in settlement._enemy_system.all_enemies():
if String(u.get("attack_type", "")) == "":
snap_ok = false
settlement.minesweeper.settle_batch([{"action": "stop"}])
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
totals[armor] = int(row.get("value", 0))
if armor == "light":
payload_at = String(row.get("attack_type", "")) # 载荷摊平在行顶层(_emit_row 摊平 extra)
break
if not totals.has(armor):
totals[armor] = -1
if int(totals.get("light", -1)) >= 2 and int(totals.get("heavy", -1)) >= 1 and int(totals.get("ward", -1)) >= 1:
t_light = int(totals["light"])
t_heavy = int(totals["heavy"])
t_ward = int(totals["ward"])
matrix_found = (t_heavy == t_light - 1 and t_ward == t_light + 1)
break
check(snap_ok, "敌方单位快照 attack_type 非空(spawn 注入面——迭代 24 N3)")
check(matrix_found and t_light >= 2,
"克制矩阵命中证词:同种子三甲型实测 heavy %d == light %d − 1 · ward %d == light %d + 1(迭代 24 N3 全局平移 −1/0/+1)" % [
t_heavy, t_light, t_ward, t_light])
check(payload_at == "normal", "敌方伤害技单据载荷 attack_type 非空(= %s——第 9 参传值面,迭代 24 N3)" % payload_at)
# ---- 护甲等级掷点批·换装护栏(2026-09-16 用户拍板:百分比减伤行整体退役换装 AC,无双轨)----
# ① 换装在位:defense_up 状态行/池映射/奖励行三处全无残留,ac_up 三处就位(unit=ac,2026-09-20 改只续时不叠层);
# ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行
# magnitude × max_stack > pool_clamp_lo——单效果叠满不可触池底(off_guard 已于 2026-09-18
# 结构修复批退离 hero_defense 池换装 AC 轴,池内现无 percent 行——本断言防未来负向行回归时静默造墙);
# ③ 单位表 armor_class 字段:主角 10(暂定)/ 敌方行 0(暂定——正式值归怪物设定批);
# ④ 掷点参数域:档位边界升序/档位值降序无零档/骰面与暴击参数在域(加载校验双保险)。
func _smoke_ac_swap_guard() -> void:
print("--- 护甲等级掷点批·换装护栏 ---")
# ① 换装在位(三处无双轨)
check(_db.status_effect_row("defense_up").is_empty(), "状态表 defense_up 行已退役(无双轨)")
var ac_row: Dictionary = _db.status_effect_row("ac_up")
check(not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \
and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \
and String(ac_row.get("stack_policy", "")) == "refresh",
"ac_up 行在位(hero · +1 ac · 只续时不叠层——2026-09-20 主角增益续时批)")
var ac_reward := {}
for row in _db.reward.rows:
if String(row.get("cell_type", "")) == "defense" and String(row.get("polarity", "")) == "positive":
ac_reward = row
check(String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "盾牌加护",
"奖励表 defense 正向行 effect_id = ac_up(显示名盾牌加护,权重/tier 不变)")
check(not _db.settlement.effect_pool_map.has("defense_up") and not _db.settlement.effect_pool_map.has("ac_up"),
"effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)")
# ② 免伤墙归零护栏(泛化——BUG-0003 免疫)
var wall_ok := true
var wall_detail := ""
for eff_id in _db.settlement.effect_pool_map.keys():
if String(_db.settlement.effect_pool_map[eff_id]) != "hero_defense":
continue
var row: Dictionary = _db.status_effect_row(eff_id)
if row.is_empty() or String(row.get("unit", "")) != "percent":
continue
var floor_v: int = int(row.get("magnitude", 0)) * int(row.get("max_stack", 1))
if floor_v <= int(_db.settlement.pool_clamp_lo):
wall_ok = false
wall_detail += " %s(%d)" % [eff_id, floor_v]
check(wall_ok, "hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" % [
wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.pool_clamp_lo)])
# ③ 单位表 armor_class:域内校验(配置推导——正式投值归怪物/难度批,改表不断锚)
check(int(_db.hero.armor_class) >= 0 and int(_db.hero.armor_class) <= 30,
"主角 armor_class 在域内(%d——暂定基准随表走)" % int(_db.hero.armor_class))
var ac_band := true
var ac_detail := ""
var heavy_names := ["熊地精重击手", "灰矮人重装手"] # U2 裁 v1 硬梯度(2026-09-29):逐行手设值 8/9,非精英编码位
for row in _db.enemy.rows:
var ac: int = int(row.get("armor_class", -1))
var dname := String(row.get("display_name", "?"))
var boss_row: bool = String(row.get("enemy_id", "")).ends_with("_chief") or \
String(row.get("enemy_id", "")) == "drider" or String(row.get("enemy_id", "")) == "duergar_king"
var heavy_row: bool = dname in heavy_names
# 杂鱼 0~5;重击手/重装手 8~9(U2 返工批手设精英值);Boss 8~12 台阶
var lo := 8 if (boss_row or heavy_row) else 0
var hi := 12 if boss_row else (9 if heavy_row else 5)
if ac < lo or ac > hi:
ac_band = false
ac_detail += " %s:%d" % [dname, ac]
check(ac_band, "敌方 AC 行值在已裁带宽内(杂鱼 0~5·重击手/重装手 8~9·Boss 8~12——迭代 5 返工批 v1 重投,越带行%s)" % [
ac_detail if ac_detail != "" else "无"])
# ③b Boss 台阶断言(关卡即台阶——L1 8 < L2 10 < L3 12)+ 首遇位教学位低 AC(≤3)
var boss_ac := {}
for row in _db.enemy.rows:
var eid := String(row.get("enemy_id", ""))
if eid == "ogre_chief" or eid == "drider" or eid == "duergar_king":
boss_ac[int(row.get("level_index", 0))] = int(row.get("armor_class", 0))
check(int(boss_ac.get(1, 0)) < int(boss_ac.get(2, 0)) and int(boss_ac.get(2, 0)) < int(boss_ac.get(3, 0)),
"Boss AC 关卡台阶严格递增(%d < %d < %d)" % [int(boss_ac.get(1, 0)), int(boss_ac.get(2, 0)), int(boss_ac.get(3, 0))])
# ③c 迭代 24 N1(2026-10-13):(1,2) 降档锚定 + 第一关总量曲线护栏
# 降档裁决沿革:2026-09-24 阶名化解方案(50 血系设计非填错)实机未达预期,按体验反馈重裁 hp 22 / ac 2;
# 敌种 bugbear 与池 attack6/charge2/heavy2 钉死不动(防顺手改多——hobgoblin 无立绘,改敌种案已否决);
# 曲线护栏 = 相邻节点「hp × 中位数量」总量比 ≤ 2.5(12/10/22/35/72 现行全绿;不用单调不回撤——会红在 (1,0)→(1,1))
var l12_row: Dictionary = {}
for row in _db.enemy.rows:
if int(row.get("level_index", -1)) == 1 and int(row.get("node_depth", -1)) == 2:
l12_row = row
break
var l12_pool := {}
for entry in l12_row.get("pool", []):
l12_pool[String(entry.skill_id)] = int(entry.weight)
check(String(l12_row.get("enemy_id", "")) == "bugbear" and int(l12_row.get("hp", -1)) == 22 \
and int(l12_row.get("armor_class", -1)) == 2 \
and l12_pool == {"attack": 6, "charge": 2, "heavy": 2},
"第一关 (1,2) 降档锚定(bugbear 保留 · hp 22 · ac 2 · 池 attack6/charge2/heavy2 零改动——迭代 24 N1)")
var l1_totals := {}
for row in _db.enemy.rows:
if int(row.get("level_index", -1)) != 1:
continue
var cnt_lo: int = int(row.get("unit_count", 1))
var cnt_hi: int = int(row.get("unit_count_max", cnt_lo))
l1_totals[int(row.get("node_depth", -1))] = int(row.get("hp", 0)) * (cnt_lo + cnt_hi) / 2.0
var curve_ok := true
var curve_detail := ""
var depths_sorted := l1_totals.keys()
depths_sorted.sort()
for i in range(1, depths_sorted.size()):
var prev: float = float(l1_totals[depths_sorted[i - 1]])
var curr: float = float(l1_totals[depths_sorted[i]])
var ratio: float = curr / prev if prev > 0.0 else 999.0
if ratio > 2.5:
curve_ok = false
curve_detail += " (%d→%d):%.2f" % [depths_sorted[i - 1], depths_sorted[i], ratio]
check(curve_ok, "第一关相邻节点总量比 ≤ 2.5(12/10/22/35/72——迭代 24 N1 曲线护栏,越带%s)" % [
curve_detail if curve_detail != "" else "无"])
# ④ 掷点参数域(加载校验已过——此处钉形状语义)
var bounds: Array = _db.settlement.roll_tier_bounds
var discounts: Array = _db.settlement.roll_tier_discounts
check(int(_db.settlement.roll_faces) == 20 and int(_db.settlement.crit_roll) == 20 \
and int(_db.settlement.crit_multiplier) == 200 and int(_db.settlement.damage_floor) == 1 \
and int(_db.settlement.attack_bonus) == 0 and String(_db.settlement.armor_roll_stream) == "armor_roll",
"掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)")
check(bounds == [3, 7] and discounts == [75, 50, 25], "档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)")
# ---- 护甲等级掷点批·行为面(2026-09-16 拍板验收:折扣档分布 / nat20 暴击 / 伤害下限 / 棋盘来源必中)----
func _smoke_armor_roll() -> void:
print("--- 护甲等级掷点(查档纯函数 + 暴击 + 伤害下限 + AC12 分布 + 棋盘必中)---")
# ① 查档纯函数:全掷点域逐位对照(AC10 / AC12)+ EV 锚(AC10 期望减免 21.25% —— registry 参考数)
var expect_ac10 := {100: 11, 75: 3, 50: 4, 25: 2} # 掷 10-20 全额 / 7-9 / 3-6 / 1-2
var expect_ac12 := {100: 9, 75: 3, 50: 4, 25: 4} # 掷 12-20 全额 / 9-11 / 5-8 / 1-4
for cfg in [{"ac": 10, "expect": expect_ac10}, {"ac": 12, "expect": expect_ac12}]:
var dist := {100: 0, 75: 0, 50: 0, 25: 0}
var tier_ok := true
for roll in range(1, 21):
var d: int = DamageMath.roll_discount_percent(roll, cfg.ac, _db.settlement.roll_tier_bounds, _db.settlement.roll_tier_discounts)
if not dist.has(d):
tier_ok = false
continue
dist[d] += 1
check(tier_ok and dist == cfg.expect, "查档 AC%d 全掷点域分布(100/75/50/25 = %s)" % [cfg.ac, str([dist[100], dist[75], dist[50], dist[25]])])
var ev_sum := 0
for roll in range(1, 21):
ev_sum += DamageMath.roll_discount_percent(roll, 10, _db.settlement.roll_tier_bounds, _db.settlement.roll_tier_discounts)
check(ev_sum == 1575, "AC10 EV 锚:期望系数合计 %d/2000 = 期望减免 21.25%%(nat20 恒全额含内)" % ev_sum)
# ② nat20 暴击(行为面):并流批(2026-09-27 统一流裁决)后掷点位随全局序漂移——探针改为按行为面扫描
# 「主角先手首掷 = 20」的种子,不再假设 armor_roll scope0 位
var crit_seed := -1
var crit_rows := {}
for s in range(1, 800):
crit_rows = _first_hero_attack_rows(s)
if int(crit_rows.get("roll", -1)) == int(_db.settlement.crit_roll):
crit_seed = s
break
check(crit_seed > 0, "nat20 种子可达(seed=%d——主角先手首掷命中 %d)" % [crit_seed, int(_db.settlement.crit_roll)])
check(int(crit_rows.get("roll", -1)) == int(_db.settlement.crit_roll) and bool(crit_rows.get("crit", false)),
"nat20 → 行留档 roll=20 + crit 标记(主角先手普通分量)")
check(int(crit_rows.get("normal", -1)) == 2 * int(_db.hero.atk) and int(crit_rows.get("value", -1)) == 2 * int(_db.hero.atk),
"nat20 → 普通分量 ×2(%d = 2×%d,暴击倍率只乘该分量,穿刺/魔法不吃)" % [int(crit_rows.get("normal", -1)), int(_db.hero.atk)])
# ③ 削弱续时(行为面):授予→衰减一轮→再授予 → 层数保持 1、剩余结算数重置(2026-09-18 结构
# 修复批叠层改续时——削弱 ×2 叠满池触底 ×0 的免伤轮结构性根治,旧 ×2 叠层采样通道随批退役)
var weaken_probe := _weaken_refresh_probe()
check(int(weaken_probe.get("layers", -1)) == 1 and int(weaken_probe.get("remaining", -1)) == 2,
"削弱重复授予 → 层数保持 1、剩余结算数重置 2(续时语义——结构性消饱和)")
check(int(weaken_probe.get("pool", -999)) == int(_db.status_effect_row("weaken").magnitude),
"削弱单层池读数 = 行值(enemy_output 池单层贡献——×2 叠满触底 ×0 不可达,免伤轮根治)")
# ④ AC11 折扣档分布(行为面):ac_up 逐轮授给(时效 1)→ Boss 行攻击拍逐拍判定
# (2026-09-20 主角增益续时批后 ac_up 层数恒 1——局内可达 AC 上限 10+1=11,探针随行下调)
var tiers := _ac11_tier_samples()
var tier_distinct := {}
var tiers_valid := true
for t in tiers:
tier_distinct[int(t)] = true
if not [100, 75, 50, 25].has(int(t)):
tiers_valid = false
check(tiers_valid and tiers.size() >= 3, "AC11 攻击拍逐拍判定(×%d 拍全部落档域 {100,75,50,25})" % tiers.size())
check(tier_distinct.size() >= 2, "折扣档实际流动(AC11 实测档位 %s——≥2 档可见)" % str(tier_distinct.keys()))
# ⑤ 棋盘来源必中 + 吃掷点行留档(全战斗流扫描)
var run = _run_battle(1, 0)
var board_ok := true
var attack_ok := true
var all_positive := true
for row in run.stream:
if String(row.get("type", "")) != "damage":
continue
if int(row.get("value", 0)) <= 0:
all_positive = false
var src := String(row.get("source_class", ""))
if src == "hero_attack" or src == "enemy_action":
if not (row.has("roll") and row.has("ac") and row.has("tier")):
attack_ok = false
elif row.has("roll"):
board_ok = false # 棋盘来源(爆裂/直伤/清障炸药)必中全额——不掷不留档
check(all_positive, "全战斗流零 v=0 伤害行(伤害下限全局生效)")
check(attack_ok, "hero_attack/enemy_action 伤害行掷点读数+档位随行留档(P2 归因)")
check(board_ok, "棋盘来源伤害行零掷点字段(爆裂/直伤/清障炸药/奖励即时必中全额)")
# ⑥ 全来源伤害下限(2026-09-20 用户拍板:任何伤害落地取值域 [1,+∞) 无上限——棋盘克制直伤此前
# 靠表值 2+(−1)=1 贴底无结构保证,反噬/地形变异/爆裂同为表值直落,本批全部收进 damage_floor 闸内)
var df: int = int(_db.settlement.damage_floor)
check(DamageMath.counter_component(2, -1, df) == 1, "棋盘克制直伤贴底 → 下限 1(点数 2 vs 不利修正 −1)")
check(DamageMath.counter_component(1, -3, df) == 1, "克制修正触底穿透 → 下限 1(点数 1 vs 修正 −3)")
check(DamageMath.counter_component(0, 5, df) == 0, "空桶不吃修正恒 0(零点数分量无伤害可落地——边界不变不受下限影响)")
# 定位种子下首场战斗第一行主角攻击伤害行(停手轮——零授予零充能,普通分量 = 攻击基线裸值)
func _first_hero_attack_rows(seed_val: int) -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}):
return {}
settlement.minesweeper.settle_batch([{"action": "stop"}])
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "hero_attack":
return row
return {}
# 削弱续时探测(2026-09-18 结构修复批——旧 _weaken_zero_round_rows ×2 叠层采样随批退役):
# 授予 → 衰减一轮 → 再授予,读 weaken 实例投影 {layers, remaining, pool}。
# 直授通道(来源格真实坐标惯例——沿前例);depth 2 板(D2 熊地精)仅作宿主,行为与敌种无关。
func _weaken_refresh_probe() -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260916)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
return {}
var src: Dictionary = {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index}
settlement._status_effects.apply_effect("weaken", src)
settlement._status_effects.decay_round() # remaining 2 → 1(先兑付后衰减——只借衰减器,不结算)
settlement._status_effects.apply_effect("weaken", src) # 续时:remaining ← 2,层数不动
for eff in settlement._status_effects.query_active("enemy"):
if String(eff.effect_id) == "weaken":
# T6 收口批:改走伤害控制器公有算术面 clamped_pool(原编排器 _clamped_pool 薄委托的直呼退役)
return {"layers": int(eff.layers), "remaining": int(eff.remaining_settlements), "pool": int(settlement._damage.clamped_pool("enemy_output"))}
return {}
# AC11 折扣档采样:每停手轮前直授 ac_up ×1(时效 1 逐轮衰减)——敌拍逐拍判定留档。
# (2026-09-20 主角增益续时批:ac_up 只续时不叠层、层数恒 1——原 ×2 授给达 AC12 的采样随批降为 AC11;
# 敌人技能池批:拍序改为摇号制,原 AC 批定影种子不再保证档位多样性——扫描种子取首个 ≥2 档样本集。)
func _ac11_tier_samples() -> Array:
for seed_offset in range(20):
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260917 + seed_offset)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}):
continue
var guard := 12
while settlement.battle_result == "" and guard > 0:
guard -= 1
settlement._status_effects.apply_effect("ac_up", {"source_class": "safe_cell", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index})
settlement.minesweeper.settle_batch([{"action": "stop"}])
var tiers: Array = []
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "enemy_action":
tiers.append(int(row.get("tier", -1)))
var distinct := {}
for t in tiers:
distinct[int(t)] = true
if tiers.size() >= 3 and distinct.size() >= 2:
return tiers
return []
# ---- 主角乘区接线 + 硬化折半方向(2026-09-18 结构修复批护栏)----
# 同种子停手轮恒同掷点(效果只改乘区、不动掷点流)→ 落地伤害精确关系:
# 增伤 = 基线 ×1.5、乏力 = ×0.75(hero_offense 池乘攻击合计——2026-09-19 充能退役后 = 单分量,直乘旁路退役);
# 硬化 = 基线 ×0.5 且 < 基线(enemy_defense 池负值折半——+100 翻倍符号 bug 随批纠正)。
# 基线构成(加法核 2026-09-18 + 充能退役 2026-09-19):基线 100——hero 攻击类型=normal(锻造工坊配置默认值,
# 迭代 23 T2 起 hero.tres 两轴删除)对轻甲中性 0 修正(目标 = L1 地精轻甲;AC 0 恒全额非暴击种子)。
func _smoke_pool_wiring() -> void:
print("--- 主角乘区接线 + 硬化折半方向 ---")
var base_row := _hero_attack_row_with({}, 100, 20260918)
var up_row := _hero_attack_row_with({"effects": ["attack_up"]}, 100, 20260918)
var sapped_row := _hero_attack_row_with({"effects": ["sapped"]}, 100, 20260918)
var fortify_row := _hero_attack_row_with({"effects": ["fortify"]}, 100, 20260918)
var base_v := int(base_row.get("value", -1))
check(int(base_row.get("normal", -1)) == base_v and base_v > 0, "单分量基线在位(normal 分量 = 落地值、基线 %d——充能退役批)" % base_v)
check(int(up_row.get("value", -1)) == int(floor(base_v * 1.5)),
"增伤经乘区加成合计(%d → %d = ×1.5——attack_up 直乘旁路退役,覆盖面=攻击合计)" % [base_v, int(up_row.get("value", -1))])
check(int(sapped_row.get("value", -1)) == int(floor(base_v * 0.75)),
"乏力经乘区削减合计(%d → %d = ×0.75)" % [base_v, int(sapped_row.get("value", -1))])
check(int(fortify_row.get("value", -1)) == int(floor(base_v * 0.5)) and int(fortify_row.get("value", -1)) < base_v,
"硬化折半方向(%d → %d = ×0.5 且 < 基线——敌方防御池负值减伤,翻倍 bug 退役)" % [base_v, int(fortify_row.get("value", -1))])
# 构造停手轮首行主角攻击行:opts = {effects: Array};hero_atk 覆盖 100 拉开量级
# (floor 关系可精确断言);效果直授沿 _weaken_refresh_probe 前例。
func _hero_attack_row_with(opts: Dictionary, hero_atk_val: int, seed_val: int) -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_atk": hero_atk_val}):
return {}
for eff in opts.get("effects", []):
settlement._status_effects.apply_effect(String(eff), {"source_class": "mine_hit", "origin_cell": Vector2i(0, 0), "round_index": settlement.round_index})
settlement.minesweeper.settle_batch([{"action": "stop"}])
for row in rows:
if String(row.get("type", "")) == "damage" and String(row.get("source_class", "")) == "hero_attack":
return row
return {}
func _smoke_board_generator() -> void:
print("--- 棋盘生成 ---")
var board_generator = load("res://core/board_generator.gd").new()
for L in [1, 2]:
var row = _db.resolve_board_gen(L, 0)
check(not row.is_empty(), "BoardGen 行解析 (%d,0)" % L)
if L == 2:
# R1 ceil 口径(2026-09-18 拍板):无精确行向上取整保证偏难——L2 深度 3 为恢复点无行,解析到深度 4
var ceil_row: Dictionary = _db.resolve_board_gen(2, 3)
check(int(ceil_row.get("node_depth", -1)) == 4, "R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)")
var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db)
check(b1 != null, "L%d 生成产物非空" % L)
if b1 == null:
continue
check(board_generator.is_clearable(b1), "L%d clearable(安全格 8 邻连通)" % L)
check(board_generator.legality(b1, row, _db), "L%d legality 全过" % L)
var inf := float(b1.metrics.get("inferability", 0.0))
var mode := String(b1.metrics.get("accept_mode", ""))
print(" L%d 实测:inferability=%.3f / attempts_used=%d / accept_mode=%s / target=%.2f" % [
L, inf, int(b1.metrics.get("attempts_used", -1)), mode, float(row.inferability_target)])
# 出图验收(棋盘生成 登记口径):first_pass 必须 ≥ target;accept_best(重试耗尽接受最优)允许低于 target
# 但必须打印实测值(正常出口);accept_worst 由下方 illegal_product 断言承担。
check(inf >= float(row.inferability_target) or mode == "accept_best",
"L%d inferability %.3f %s target %.2f(accept_mode=%s)" % [L, inf, "≥" if inf >= float(row.inferability_target) else "<", float(row.inferability_target), mode])
check(b1.variants.is_empty(), "L%d 生成器产物零变体格(变异布置归战斗初始化第 4 步——生成器管线不布置,2026-09-19 用户拍板棋盘设置顺序)" % L)
check(b1.metrics.has("degraded_product") and b1.metrics.has("target_mines"), "L%d 指标集输出降级标记与目标雷数(三分出口表)" % L)
check(b1.degraded_product == (mode == "accept_best"), "L%d 降级产物标记 = (accept_mode==accept_best)(%s→%s)" % [L, mode, str(b1.degraded_product)])
check(b1.illegal_product == (mode == "accept_worst"), "L%d 非法产物标记 = (accept_mode==accept_worst)(%s→%s)" % [L, mode, str(b1.illegal_product)])
check(not b1.degraded_product or not b1.illegal_product, "L%d 降级/非法产物标记互斥(两出口不可同真)" % L)
check(int(b1.metrics.actual_mines) <= int(b1.metrics.target_mines), "L%d 产物断言:实际雷数 %d ≤ 目标雷数 N=%d(先布后删只减不增)" % [L, int(b1.metrics.actual_mines), int(b1.metrics.target_mines)])
check(b1.metrics.get("attempts_used", 99) >= 1 and b1.metrics.get("attempts_used", 99) <= int(row.max_gen_attempts), "L%d attempts_used ∈ [1,%d]" % [L, int(row.max_gen_attempts)])
check(not b1.illegal_product, "L%d 非法产物标记 = false" % L)
# 生成确定性:同种子逐格相同
var b2 = board_generator.generate(L, 0, _mk_stream("board_gen", 777), _db)
var b3 = board_generator.generate(L, 0, _mk_stream("board_gen", 777), _db)
check(_board_dump(b2) == _board_dump(b3), "L%d 同种子两次生成逐格相同" % L)
var b4 = board_generator.generate(L, 0, _mk_stream("board_gen", 778), _db)
check(_board_dump(b2) != _board_dump(b4), "L%d 异种子产物不同(退化护栏)" % L)
# ---- 初始展开收窄批断言(迭代14① T4)----
# ① 结构断言:R₀ ≤ cap(正式表行投值,合法性闸承载——出图必过);② r0_cap_rejected 尝试事实在位;
# ③ ring_removed 量级:剃雷域收窄后 ≤ 域内候选雷格数(5×5−出生 9=16——全域剃雷退役);
# ④ 闸压线行为:cap=9(出生保障面下限)时出图必 r0≤9 或 accept_worst 非法标记(三分出口诚实降级,玩家永不卡死)
var cap_val: int = int(row.get("r0_max_cap", 0))
check(cap_val >= BoardData.SPAWN_AREA_CELLS, "L%d r0_max_cap 正式行投值在位(%d ≥ 出生保障面 9——板面 30%% 锚)" % [L, cap_val])
check(int(b1.metrics.r0_count) <= cap_val, "L%d 结构断言 R₀=%d ≤ cap=%d(R₀≤cap 重掷闸承载)" % [L, int(b1.metrics.r0_count), cap_val])
check(int(b1.metrics.get("r0_cap_rejected", -1)) >= 0, "L%d r0_cap_rejected 尝试事实指标在位(本图 %d 次触发)" % [L, int(b1.metrics.get("r0_cap_rejected", -1))])
check(int(b1.metrics.get("ring_removed", 99)) <= (2 + int(row.get("shave_ring_radius", 1))) * (2 + int(row.get("shave_ring_radius", 1))) * 4 - 5, "L%d ring_removed=%d ≤ 剃雷域候选雷格数((3+2r)²−9——全域剃雷退役)" % [L, int(b1.metrics.get("ring_removed", 99))])
var row9: Dictionary = row.duplicate()
row9["r0_max_cap"] = BoardData.SPAWN_AREA_CELLS
row9["max_gen_attempts"] = 4
var stub9 := _BoardRowStub.new()
stub9.row = row9
var b9 = board_generator.generate(1, 0, _mk_stream("board_gen", 909), stub9)
check(b9 != null and (int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS or b9.illegal_product),
"L1 cap 压线 9:出图 R₀=%d %s(三分出口诚实降级——超阈即 accept_worst 非法标记,玩家永不卡死)" % [int(b9.metrics.r0_count), "≤9" if int(b9.metrics.r0_count) <= BoardData.SPAWN_AREA_CELLS else ">9→illegal_product"])
check(int(b9.metrics.get("r0_cap_rejected", 0)) >= 0 and String(b9.metrics.get("accept_mode", "")) != "", "L1 cap 压线出图指标集完整(accept_mode=%s / 闸触发 %d 次)" % [String(b9.metrics.get("accept_mode", "")), int(b9.metrics.get("r0_cap_rejected", 0))])
func _mk_stream(stream_name: String, scope: int) -> RandomNumberGenerator:
# 并流批(2026-09-27 统一流裁决):stream_name 降格为调用点自述标签(不参与派生),保留参数零改调用方
var rg := RandomNumberGenerator.new()
rg.seed = _rng.derive_at(scope)
return rg
# ---- 地形变异 框架面(首批类型定稿批 2026-09-19:四行两轴×两关 + 门控 + 跨字段互锁)----
func _smoke_variation_framework() -> void:
print("--- 地形变异 框架面(首批四类型 + 候选池门控 + 跨字段互锁)---")
check(_db.variation.rows.size() == 6, "变异清单 6 行(荆棘丛/藤蔓缠绕/岩浆/烬覆 + 落石/矿尘——首批类型定稿批 + 第三关内容批;贫矿脉 2026-10-16 用户令屏蔽删行)")
check(_db.variation.validate().is_empty(), "变异表校验零错误(6 行全过域与跨字段互锁——含 scope=global 三互锁负样本)")
var pool1_ids: Array = []
for r in _db.variant_pool(1, "dungeon"):
pool1_ids.append(String(r.variant_id))
check(pool1_ids == ["thorn", "vine"], "L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)")
var pool2_ids: Array = []
for r in _db.variant_pool(2, "underdark"):
pool2_ids.append(String(r.variant_id))
check(pool2_ids == ["magma", "ash"], "L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)")
check(_db.variant_pool(1, "underdark").is_empty(), "unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)")
var pool2d_ids: Array = []
for r in _db.variant_pool(2, "dungeon"):
pool2d_ids.append(String(r.variant_id))
check(pool2d_ids == ["thorn", "vine"], "board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)")
check(_db.variation.row_by_id("nonexistent").is_empty(), "清单外 variant_id 返回空(读取通道在位)")
# 跨字段互锁负样本(加载期致命——枚举语义一致性闸)
var vt = load("res://data/schemas/variation_table.gd").new()
vt.rows.assign([{"variant_id": "bad1", "display_name": "坏行甲", "unlock_level": 1, "scope": "region",
"effect": "flat_damage", "effect_value": 1, "trigger": "on_open", "placement": "any",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本①:flat_damage + placement=any 被拒(只出现在未打开的格子)")
vt.rows.assign([{"variant_id": "bad2", "display_name": "坏行乙", "unlock_level": 1, "scope": "region",
"effect": "hide_number", "effect_value": 0, "trigger": "on_open", "placement": "any",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本②:hide_number + trigger≠on_number_shown 被拒")
func _errs_has_cross_field(errs: Array) -> bool:
for e in errs:
if String(e.get("category", "")) == "cross_field":
return true
return false
# ---- 全局作用域变异 断言面(2026-10-04 迭代 14 ③号卡首发批立面;2026-10-16 用户令屏蔽贫矿脉删行后
# 改测「全局池空 → region 兜底回落」路径——互锁负样本/门控三维/资源面偏斜守恒与合成序/快照投影不变)----
# 取号计数桩(variant_place 取号断言——子类拦截计数,派生与取数语义零改动)
class _CountVariantRng extends RngServiceCore:
var variant_calls := 0
func open_stream(purpose: String) -> RandomNumberGenerator:
if purpose == "variant_place":
variant_calls += 1
return super.open_stream(purpose)
# 板行覆盖桩(迭代14① T4——cap 压线行为断言;接口面 = resolve_board_gen 单点)
class _BoardRowStub:
var row: Dictionary
func resolve_board_gen(_L: int, _D: int) -> Dictionary:
return row
func status_effect_row(_id: String) -> Dictionary:
return {}
func _smoke_global_variant() -> void:
print("--- 全局作用域变异(门控三维 + 全局池空兜底回落 + 资源面偏斜)---")
# ① 跨字段互锁负样本(resource_face ⟺ scope=global 三条 + effect_value 新域 [0,50])
var vt = load("res://data/schemas/variation_table.gd").new()
vt.rows.assign([{"variant_id": "g1", "display_name": "负甲", "unlock_level": 2, "scope": "region",
"effect": "resource_face", "effect_value": 30, "trigger": "always", "placement": "any",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "互锁负样本③:resource_face + scope=region 被拒(resource_face ⇒ global)")
vt.rows.assign([{"variant_id": "g2", "display_name": "负乙", "unlock_level": 2, "scope": "global",
"effect": "hide_number", "effect_value": 0, "trigger": "always", "placement": "any",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "互锁负样本④:scope=global + effect=hide_number 被拒(global ⇒ resource_face)")
vt.rows.assign([{"variant_id": "g3", "display_name": "负丙", "unlock_level": 2, "scope": "global",
"effect": "resource_face", "effect_value": 30, "trigger": "on_open", "placement": "any",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "互锁负样本⑤:scope=global + trigger=on_open 被拒(global ⇒ always 板级恒在)")
vt.rows.assign([{"variant_id": "g4", "display_name": "负丁", "unlock_level": 2, "scope": "global",
"effect": "resource_face", "effect_value": 30, "trigger": "always", "placement": "closed_only",
"board_types": [], "counter_items": []}])
check(_errs_has_cross_field(vt.validate()), "互锁负样本⑥:scope=global + placement=closed_only 被拒(global ⇒ any 无格子布置域)")
vt.rows.assign([{"variant_id": "g5", "display_name": "负戊", "unlock_level": 2, "scope": "global",
"effect": "resource_face", "effect_value": 51, "trigger": "always", "placement": "any",
"board_types": [], "counter_items": []}])
var vterr: Array = vt.validate()
var domain_hit := false
for e in vterr:
if String(e.get("category", "")) == "domain":
domain_hit = true
check(domain_hit, "互锁负样本⑦:resource_face effect_value=51 越域 [0,50] 被拒(不适用 flat_damage [1,9] 加法核)")
# ② 门控第三维(节点身份——global 行仅 boss/elite 入池;缺省/普通节点静默跳过零报错)
var pool_boss_ids: Array = []
for r in _db.variant_pool(2, "underdark", "boss"):
pool_boss_ids.append(String(r.variant_id))
check(pool_boss_ids == ["magma", "ash"], "门控三维:L2 underdark boss 池 = region 两行(贫矿脉 2026-10-16 屏蔽删行后无 global 行)")
var pool_battle_ids: Array = []
for r in _db.variant_pool(2, "underdark", "battle"):
pool_battle_ids.append(String(r.variant_id))
check(pool_battle_ids == ["magma", "ash"], "门控三维:battle 节点静默跳过 global 行(普通图零报错零警告)")
check(_db.variant_pool(2, "underdark").is_empty() == false and _db.variant_pool(2, "underdark").size() == 2,
"门控三维:缺省双参调用 = global 行跳过(既有调用口径零扰动)")
check(_db.variant_pool(1, "underdark", "boss").is_empty(), "L1 Boss 免疫:unlock_level=2 门控在 boss 节点同样生效(教学保护带 B)")
check(_db.global_variant_row(2, "underdark", "battle").is_empty(), "全局决议:battle 节点恒无全局型")
check(_db.global_variant_row(2, "underdark", "boss").is_empty(), "全局决议:L2 boss 全局池空 → 空行(贫矿脉屏蔽后兜底回落承载)")
check(_db.global_variant_row(1, "underdark", "boss").is_empty() and _db.global_variant_row(1, "dungeon", "boss").is_empty(),
"全局决议:L1 boss 全局池空 → 空行(兜底回落承载)")
check(_db.global_variant_row(2, "underdark", "elite").is_empty(), "elite 前向词位:boss 同池恒空(贫矿脉屏蔽后——词表位不造行)")
# ③ 兜底回落:全局池空 → Boss 图改走 region 候选池(恰一行 region 变异 + 格子级布置 + 正常取号)
var crng = _CountVariantRng.new()
crng.set_master_seed(42)
var boss_settlement = load("res://core/battle_orchestrator.gd").new(_db, crng)
var boss_ok: bool = boss_settlement.begin_battle({"level_index": 2, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"})
check(boss_ok, "L2 Boss 战斗启动(node_depth=4 boss 节点——兜底回落断言载体)")
if boss_ok:
var bb = boss_settlement.minesweeper.current_board()
check(bb.board_variant_ids().size() == 1 and (String(bb.board_variant_ids()[0]) in ["magma", "ash"]),
"屏蔽后回落:Boss 图改走 region 候选池(恰一行 region 变异,无全局型——全局池空回落路径)")
check(not bb.variants.is_empty(), "屏蔽后回落:Boss 图恢复 region 变异格布置(原替换制跳过 region 的逻辑随全局池空失效)")
check(crng.variant_calls > 0, "屏蔽后回落:variant_place 恢复取号(原全局取用零取号态消失)")
var snap: Dictionary = boss_settlement.presentation_snapshot()
check(_arr_str_eq(snap.get("board_variants", []), bb.board_variant_ids()), "快照投影:board_variants 与板载 region 行一致(横幅/角标唯一读取面)")
# Boss 图确定性:同种子两遍逐位相同
var crng2 = _CountVariantRng.new()
crng2.set_master_seed(42)
var boss2 = load("res://core/battle_orchestrator.gd").new(_db, crng2)
if boss2.begin_battle({"level_index": 2, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}):
var bb2 = boss2.minesweeper.current_board()
check(_arr_str_eq(bb2.board_variant_ids(), bb.board_variant_ids()) and crng2.variant_calls == crng.variant_calls,
"确定性:同种子 Boss 图两遍 board_variants/取号逐位相同(region 布置路径)")
# ④ 资源面偏斜投影(RewardEconomy 纯函数面——守恒/白名单/构造例/合成序)
var eco = load("res://core/reward_economy.gd").new(_db, load("res://core/rng_service.gd").new())
var base_rows: Array = eco._rollable_rows()
var base_sum: int = eco._pool_weight(base_rows)
check(base_sum == 77, "偏斜基线:正向池现行合计 77(挖掘风险批口径)")
eco.board_skew = 0
var off_rows: Array = eco._biased_cell_rows(0) # 内容数据驱动扩展批 T4:偏运权重改 reward_roll 订阅命中入参(luck_bias 注入位退役)
check(off_rows.size() == base_rows.size() and _rows_weights_equal(off_rows, base_rows), "off 态零扰动:board_skew=0 行集与基线逐位相同(不进投影域)")
eco.board_skew = 30
var on_rows: Array = eco._biased_cell_rows(0)
check(eco._pool_weight(on_rows) == 77, "合计守恒:偏斜后正向池合计 = 77(整数最大余数重归一)")
var wmap := _rows_weight_map(on_rows)
check(wmap.get("weaken", -1) == 8 and wmap.get("ap_up", -1) == 6 and wmap.get("gp_coin", -1) == 2,
"白名单豁免:weaken/gp/action 三行偏斜后逐位不动")
check(wmap.get("heal_minor", -1) == 3 and wmap.get("ac_up", -1) == 7, "生存族下调:heal 4→3 / defense 11→7(v=30 构造例)")
check(wmap.get("attack_up", -1) == 15 and wmap.get("spark_aoe", -1) == 18 and wmap.get("pierce_strike", -1) == 9 and wmap.get("magic_bolt", -1) == 9,
"进攻族上浮:attack 14→15 / blast 16→18 / pierce 8→9 / magic 8→9(v=30 构造例,等比上浮守恒)")
eco.board_skew = 30
var combo_rows: Array = eco._biased_cell_rows(2) # 件级加算 2=偏运硬币行 value(reward_roll bias_rewards 命中面同值)
var cw := _rows_weight_map(combo_rows)
check(eco._pool_weight(combo_rows) == 77 + 4 * 2, "合成序:图级偏斜先(乘法面)件级 luck_bias 后(加法 +2×四稀有类)——合计 77+8")
check(cw.get("heal_minor", -1) == 5 and cw.get("gp_coin", -1) == 4 and cw.get("pierce_strike", -1) == 11 and cw.get("magic_bolt", -1) == 11,
"合成序构造例:skew(heal 3)+2=5 / gp 2+2=4 / pierce 9+2=11 / magic 9+2=11(图级定型后玩家构筑叠加)")
eco.board_skew = 50
var v50: Dictionary = _rows_weight_map(eco._biased_cell_rows(0))
check(eco._pool_weight(eco._biased_cell_rows(0)) == 77 and int(v50.get("heal_minor", -1)) > 0, "域上界 v=50:守恒成立且逐行权重恒正(域 [0,50] 安全)")
# 普通图摇号全链路确定性(off 路径)——同种子普通图资源映射逐位相同(golden 对拍另有批前快照)
var nrng = load("res://core/rng_service.gd").new()
nrng.set_master_seed(42)
var normal1 = load("res://core/battle_orchestrator.gd").new(_db, nrng)
normal1.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
var n1: Dictionary = _board_resource_dump(normal1)
var nrng2 = load("res://core/rng_service.gd").new()
nrng2.set_master_seed(42)
var normal2 = load("res://core/battle_orchestrator.gd").new(_db, nrng2)
normal2.begin_battle({"level_index": 2, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
check(n1 == _board_resource_dump(normal2) and not n1.is_empty(), "确定性:同种子普通图布置/摇号两遍逐位相同(off 全链路)")
func _arr_str_eq(a, b: Array) -> bool:
var arr: Array = a if a is Array else []
if arr.size() != b.size():
return false
for i in arr.size():
if String(arr[i]) != String(b[i]):
return false
return true
func _rows_weights_equal(a: Array, b: Array) -> bool:
if a.size() != b.size():
return false
for i in a.size():
if String(a[i].get("reward_id", "")) != String(b[i].get("reward_id", "")) or int(a[i].weight) != int(b[i].weight):
return false
return true
func _rows_weight_map(rows: Array) -> Dictionary:
var m := {}
for r in rows:
m[String(r.get("reward_id", ""))] = int(r.weight)
return m
func _board_resource_dump(settlement) -> Dictionary:
var b = settlement.minesweeper.current_board()
var dump := {}
for c in b.variants.keys():
dump["v:%d,%d" % [c.x, c.y]] = ",".join(b.variants[c])
for y in b.height:
for x in b.width:
var ident = settlement._reward_economy.query_identity(Vector2i(x, y))
if ident != null:
dump["r:%d,%d" % [x, y]] = String(ident.reward_id)
return dump
# ---- 地形变异 布置段(首批类型定稿批 2026-09-19——棋盘设置顺序第 4 步断言面)----
# 布置发生在编排器战斗初始化(install 初始展开 + roll_board 资源格之后),生成器产物零变异——
# 本段经 begin_battle 走完整初始化序后断言:门控一致性 / 布置域 / 数量公式 / 连通片上限 / 确定性。
func _smoke_variant_placement(L: int) -> void:
print("--- 地形变异 布置段(L%d)---" % L)
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(42)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
if not settlement.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp}):
check(false, "L%d 布置段战斗启动失败" % L)
return
var b = settlement.minesweeper.current_board()
check(b != null, "L%d 布置段板非空" % L)
if b == null:
return
var pool_ids: Array = []
for r in _db.variant_pool(L, String(b.board_type)):
pool_ids.append(String(r.variant_id))
var vcells: Array = b.variants.keys()
var vid0 := ""
var single_type := true
var bad_id := false
for c in vcells:
var ids: Array = b.variants[c]
if ids.size() != 1:
single_type = false
for vid in ids:
if vid0 == "":
vid0 = String(vid)
elif vid0 != String(vid):
single_type = false
if not pool_ids.has(String(vid)):
bad_id = true
check(not bad_id, "L%d 变异 id 全在候选池(门控一致性:%s)" % [L, str(pool_ids)])
check(single_type, "L%d 每图至多一种变异类型(单格单 id + 全图同 id)" % L)
var r0 := {}
for c in b.compute_r0():
r0[c] = true
var domain_ok := true
for c in vcells:
if r0.has(c) or b.is_mine(c):
domain_ok = false
check(domain_ok, "L%d 变异格全在初始展开区外且不吃雷格(布置域结构保障——closed_only)" % L)
var cand_count := 0
for c in b.safe_cells():
if not r0.has(c):
cand_count += 1
var expect_cap: int = int(round(float(cand_count) * float(_db.variation.percent_of_cells) / 100.0)) \
+ (L - 1) * int(_db.variation.per_level_bonus)
check(vcells.size() <= expect_cap, "L%d 变异格数 %d ≤ 公式上限 %d(可挖池百分比 + 关卡递增)" % [L, vcells.size(), expect_cap])
# 数量公式量随候选池缩放(2026-09-21 基数修正批):L1 无关卡加成,极小池(<10 格)可 round 至 0——
# 零布置为合法态(同池空口径);非零断言改为条件式,公式量>0 时才要求非零(L2/L3 关卡加成保底恒>0)
check(expect_cap == 0 or vcells.size() > 0, "L%d 公式量>0 时非零布置(极小池 round 至 0 = 合法零布置——2026-09-21 基数修正批)" % L)
var pack_max: int = int(_db.variation.pack_max)
var vset := {}
for c in vcells:
vset[c] = true
var seen := {}
var pack_ok := true
for c in vcells:
if seen.has(c):
continue
seen[c] = true
var comp_size := 1
var q: Array = [c]
while q.size() > 0:
var x: Vector2i = q.pop_front()
for n in b.neighbors8(x):
if vset.has(n) and not seen.has(n):
seen[n] = true
comp_size += 1
q.append(n)
if comp_size > pack_max:
pack_ok = false
check(pack_ok, "L%d 全部连通片 ≤ %d 格(全连通生长、2×2/1×4/1×3+1 等形)" % [L, pack_max])
# 布置确定性:全新 RngService 同种子重开,变异布置逐格相同(variant_place 流确定性)
var rng2 = load("res://core/rng_service.gd").new()
rng2.set_master_seed(42)
var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2)
s2.begin_battle({"level_index": L, "node_depth": 0, "hero_hp": _db.hero.max_hp})
var b2 = s2.minesweeper.current_board()
check(b2 != null and _variant_dump(b2) == _variant_dump(b), "L%d 同种子变异布置逐格相同(variant_place 流确定性)" % L)
func _variant_dump(b) -> String:
var keys: Array = b.variants.keys()
keys.sort_custom(func(a, b2): return a.y * 1000 + a.x < b2.y * 1000 + b2.x)
var parts: Array = []
for c in keys:
parts.append("%d,%d:%s" % [c.x, c.y, str(b.variants[c])])
return "|".join(parts)
func _board_dump(b) -> String:
var parts: Array = [str(b.width), str(b.height), str(b.spawn_center)]
for y in b.height:
for x in b.width:
var c := Vector2i(x, y)
parts.append("%d:%d:%d:%s" % [x, y, 1 if b.is_mine(c) else 0, str(b.variants.get(c, []))])
return "\n".join(parts)
# ---- 完整战斗回放(自动玩家,确定性)——回放断言:同种子两遍步骤流逐行相同 ----
func _smoke_full_battle(L: int, D: int, label: String) -> void:
print("--- 完整战斗回放:%s ---" % label)
var run1 = _run_battle(L, D)
var run2 = _run_battle(L, D)
check(run1.terminated, "%s 战斗在轮次上限内终局(%d 轮,%s)" % [label, run1.rounds, run1.result])
if JSON.stringify(run1.stream) != JSON.stringify(run2.stream):
for i in range(maxi(run1.stream.size(), run2.stream.size())):
var a := JSON.stringify(run1.stream[i]) if i < run1.stream.size() else "<none>"
var b := JSON.stringify(run2.stream[i]) if i < run2.stream.size() else "<none>"
if a != b:
print(" 流差异 @%d:\n run1=%s\n run2=%s" % [i, a, b])
break
check(JSON.stringify(run1.stream) == JSON.stringify(run2.stream), "%s 回放断言:结算步骤流逐行相同" % label)
check(JSON.stringify(run1.events) == JSON.stringify(run2.events), "%s 回放断言:事件流逐位相同" % label)
check(run1.stream.size() > 0, "%s 结算步骤流非空(%d 行)" % [label, run1.stream.size()])
var has_verdict := false
var hero_attacks := 0
var hero_breakdown_ok := true
for row in run1.stream:
if row.get("type", "") == "verdict":
has_verdict = true
if row.get("source_class", "") == "hero_attack":
hero_attacks += 1
# 单分量伤害构成行(2026-09-19 充能退役批):hero_attack 行必须携带实际攻击类型键 + 掷点读数(P2 归因)
# 迭代 23 T2:类型键主语换代——hero.tres 两轴删除,实际攻击类型=锻造工坊配置默认值(本夹具零注入)
var atk_t: String = String(_db.forge_default_attack_type())
if not (row.has(atk_t) and row.has("roll") and row.has("ac") and row.has("tier")):
hero_breakdown_ok = false
check(has_verdict, "%s 步骤流含判定行" % label)
# 案C(2026-10-06 用户裁):授予段不再产出伤害——进入战斗段时主角恒存活且场上有敌,
# 先手必兑;旧「挖掘期兑付可在授予段单独击杀、零先手合法」结局随地图账迁战斗段末整体消失。
check(hero_attacks >= 1, "%s 主角先手攻击行必存在(2026-09-11 拍板① + 案C 地图账殿后)×%d" % [label, hero_attacks])
check(hero_breakdown_ok, "%s 主角攻击行携带类型键与掷点读数(单分量——充能退役批)" % label)
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.flag_judged.connect(func(evt): out.events.append(evt))
settlement.minesweeper.round_end_board.connect(func(p): out.events.append({"ev": "round_end_board", "path_tag": p.path_tag}))
settlement.action_economy.round_end_stopped.connect(func(p): out.events.append({"ev": "round_end_stopped", "path_tag": p.path_tag}))
var minesweeper = settlement.minesweeper
var action_economy = settlement.action_economy
var enc := {"level_index": L, "node_depth": D, "hero_hp": _db.hero.max_hp}
enc.merge(axes, true) # 迭代 23 T4——两根类型轴注入尾参(锻造工坊批对拍用;缺省零注入=回退面)
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"
if out.terminated:
# P0-3 回归断言(2026-10-02 修复批):notify_combat_end 曾被并进行尾注释从不执行——
# 战斗收束后相位必须回 Idle(RAP 清零),任何「相位卡 Waiting」回归在此被抓
check(action_economy.phase == "Idle",
"战斗收束后行动经济相位回 Idle(notify_combat_end 生效,实测=%s)" % String(action_economy.phase))
return out
func _auto_play_round(settlement, out: Dictionary) -> void:
var minesweeper = settlement.minesweeper
var action_economy = settlement.action_economy
var start_round: int = settlement.round_index
# 确定性自动玩家:挖最近的已探明邻接未开格;无可挖则停手(结算窗口由信号回调提交)
var guard := 400
while action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0 and out.result == "":
guard -= 1
var plan := AutoPlayer.choose_dig(minesweeper)
if plan.is_empty():
break
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
if action_economy.phase != "Active" or settlement.round_index != start_round:
break
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "":
minesweeper.settle_batch([{"action": "stop"}])
# ---- 地图战斗资源·伤害量值区间批冒烟(2026-09-14:身份批 roll 域内 + 中点锚 + 变异实际流动 + 定值行不受扰)----
func _smoke_power_interval() -> void:
print("--- 地图战斗资源·伤害量值区间(上阶/下阶随机)---")
# ① 表侧静态锚(配置推导——2026-09-23 重构,改表不再断锚):凡带 power_min/power_max 的行 = 区间行,
# 校验配对齐全(lo ≤ hi)+ power = floor 中点(行动经济 条款①期望当量锚);其余行必须零区间字段。
# 行清单与区间值全部随表走——现行区间行 heal_minor/clear_blast 只是恰然,不再是锚。
var interval_rows := {} # rid → [lo, hi](读表)
var anchors_ok := true
for row in _db.reward.rows:
var rid := String(row.reward_id)
if row.has("power_min") or row.has("power_max"):
var lo: int = int(row.get("power_min", -1))
var hi: int = int(row.get("power_max", -1))
interval_rows[rid] = [lo, hi]
if lo > hi or int(row.power) != (lo + hi) / 2:
anchors_ok = false
check(anchors_ok and interval_rows.size() >= 1,
"区间行配对齐全且 power = floor 中点(配置推导 %d 行:%s)" % [interval_rows.size(), str(interval_rows.keys())])
# ② 运行侧:24 板 roll——区间行快照 power 全域内 + 上下阶实际流动 + 定值行不受扰
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260914)
var reward_economy_factory = load("res://core/reward_economy.gd")
var eligible: Array = []
for i in range(6):
eligible.append({"cell": Vector2i(i, 0)})
var seen := {}
for rid in interval_rows.keys():
seen[rid] = {} # rid → {roll 值集}(区间行随表注册)
var in_domain := true
var fixed_ok := true
for trial in range(24):
var reward_economy = reward_economy_factory.new(_db, rng)
if not reward_economy.roll_board(eligible):
continue
for e in eligible:
var snap: Dictionary = reward_economy.query_full_snapshot(e.cell)
var rid := String(snap.reward_id)
if interval_rows.has(rid):
var v := int(snap.power)
seen[rid][v] = true
if v < interval_rows[rid][0] or v > interval_rows[rid][1]:
in_domain = false
elif int(snap.power) != _row_power(rid):
fixed_ok = false # 定值行快照 power 必须仍是表值
var cs: Dictionary = reward_economy.grant_clear()
var cv := int(cs.power)
var c_rid := String(cs.reward_id)
if not seen.has(c_rid):
seen[c_rid] = {}
seen[c_rid][cv] = true
if interval_rows.has(c_rid) and (cv < interval_rows[c_rid][0] or cv > interval_rows[c_rid][1]):
in_domain = false
check(in_domain, "24 板全区间行快照 power ∈ [power_min, power_max](含清图槽)")
check(fixed_ok, "定值行快照 power 恒等表值——区间批零外溢")
# 定值行(区间 lo==hi 的行,如 heal_minor)roll 恒等表值;区间行(lo<hi,如 clear_blast)须实际流动。
# 全部按 interval_rows 配置推导(2026-09-23 重构)——新增/改区间行自动纳入,不再逐行硬编码。
var variance_ok := true
var fixed_rows_ok := true
for rid in interval_rows.keys():
var lo: int = interval_rows[rid][0]
var hi: int = interval_rows[rid][1]
var samples: int = seen.get(rid, {}).size()
if lo == hi:
if samples > 0 and not (samples == 1 and seen[rid].has(lo)):
fixed_rows_ok = false
elif samples >= 2 and seen[rid].size() < 2:
variance_ok = false
check(variance_ok, "区间行上下阶实际流动(≥2 样本的区间行须 ≥2 个不同 roll 值)")
check(fixed_rows_ok, "定值行(lo=hi)roll 恒等表值")
func _row_power(rid: String) -> int:
for row in _db.reward.rows:
if String(row.reward_id) == rid:
return int(row.power)
return -1
# ---- 行动经济·轮内刷新冒烟(2026-09-12 方案 A 用户拍板:Active 相位当场换图继续挖)----
func _smoke_active_refresh() -> void:
print("--- 行动经济·轮内刷新(方案 A)---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260912)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
# 计数器用容器(GDScript lambda 对局部 int 按值捕获——int 计数在 lambda 内加无效)
var ct := {"board_swaps": 0, "safe_events": 0, "safe_rows": 0, "waiting_refresh_rejected": true, "waiting_probe_hit": false}
settlement.board_changed.connect(func(): ct["board_swaps"] += 1)
settlement.minesweeper.cell_revealed.connect(func(evt):
if evt.kind == "safe" and evt.channel != "initial":
ct["safe_events"] += 1)
settlement.step_row.connect(func(row):
if row.source_class == "safe_cell":
ct["safe_rows"] += 1 # 结算流中 safe_cell 来源行(含兑付/未生效——每格必有行)
# 结算中(Waiting 相位——段内)发起刷新必须被拒(等待期换图通道 2026-09-15 整体移除的回归探针:
# 衰减行恒在每结算出现,借其在段内同步探测一次)
if String(row.get("type", "")) == "decay" and not bool(ct["waiting_probe_hit"]):
ct["waiting_probe_hit"] = true
ct["waiting_refresh_rejected"] = not settlement.player_request_refresh())
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)")
var minesweeper = settlement.minesweeper
# 轮 1–3:零挖掘停手攒 RAP = 3×AP(每轮全转存;表驱动——2026-09-16 AP 压速批 8→6 不再写死)
for i in range(3):
minesweeper.settle_batch([{"action": "stop"}])
var rap_banked: int = 3 * int(_db.action_economy.ap_per_round)
check(settlement.round_index == 4 and settlement.action_economy.rap == rap_banked, "三轮停手攒 RAP=%d(round=%d rap=%d)" % [rap_banked, settlement.round_index, settlement.action_economy.rap])
check(settlement.action_economy.phase == "Active", "第 4 轮 Active 相位")
# 挖 1 格 → 刷 → 挖 1 格 → 刷 → 挖 1 格 → 第 3 刷拒(上限)
var plan = AutoPlayer.choose_dig(minesweeper)
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
var ap_before: int = settlement.action_economy.ap
var board_before = minesweeper.current_board()
var swaps_before: int = ct["board_swaps"]
var ok1: bool = settlement.player_request_refresh()
check(ok1, "轮内第 1 次刷新受理")
check(settlement.action_economy.phase == "Active" and settlement.action_economy.ap == ap_before, "相位保持 Active、AP 保留不重置(ap=%d)" % settlement.action_economy.ap)
check(settlement.action_economy.rap == rap_banked - int(_db.action_economy.rap_refresh_cost), "RAP -刷新价(%d)" % settlement.action_economy.rap)
check(int(ct["board_swaps"]) == swaps_before + 1 and minesweeper.current_board() != board_before, "当场换新图(board_changed +1)")
var plan2 = AutoPlayer.choose_dig(minesweeper)
if plan2.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan2.hero_cell}])
minesweeper.settle_batch([{"action": "dig", "cell": plan2.dig_cell}])
var ok2: bool = settlement.player_request_refresh()
check(ok2, "轮内第 2 次刷新受理(RAP=%d)" % settlement.action_economy.rap)
var plan3 = AutoPlayer.choose_dig(minesweeper)
if plan3.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan3.hero_cell}])
minesweeper.settle_batch([{"action": "dig", "cell": plan3.dig_cell}])
var ok3: bool = settlement.player_request_refresh()
check(not ok3, "第 3 次刷新被拒(每轮兜底上限 2——RAP %d 足额仍拒)" % settlement.action_economy.rap)
# V5(迭代 27 T3——刷新双账收口):受理计数单源=结算快照 economy.refresh_used,与 can_refresh 门一致
check(int(settlement.presentation_snapshot().get("refresh_used", -1)) == 2,
"V5 快照刷新计数=2(受理单源——main 副本已删)〔迭代27 T3〕")
check(not settlement.can_refresh(), "V5 上限达门禁一致(can_refresh 与快照计数同源)〔迭代27 T3〕")
# 跨图 facts 完整兑现:拒绝后挖到 AP 耗尽自动结算(扩大跨图样本——三段挖掘分属三张图)
var dig_guard := 20
while settlement.action_economy.phase == "Active" and settlement.round_index == 4 and dig_guard > 0:
dig_guard -= 1
var p = AutoPlayer.choose_dig(minesweeper)
if p.is_empty():
minesweeper.settle_batch([{"action": "stop"}])
break
if p.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": p.hero_cell}])
minesweeper.settle_batch([{"action": "dig", "cell": p.dig_cell}])
check(settlement.round_index == 5 and settlement.battle_result == "", "轮 4 挖至 AP 耗尽自动结算(round=%d result='%s')" % [settlement.round_index, settlement.battle_result])
check(int(ct["safe_rows"]) == int(ct["safe_events"]), "跨图 facts 完整兑现(safe_cell 结算行 %d == safe 事件 %d——快照批次隔离)" % [int(ct["safe_rows"]), int(ct["safe_events"])])
check(int(ct["safe_events"]) >= 3, "跨图挖掘 safe 事件 ≥3(%d——断言非空转)" % int(ct["safe_events"]))
check(bool(ct["waiting_refresh_rejected"]) and bool(ct["waiting_probe_hit"]), "结算段内(Waiting 相位)刷新被拒(等待期换图通道已移除)")
# 回放确定性:同种子两遍步骤流逐行相同(含两次轮内刷新取号序)
var rep1 = _active_refresh_replay()
var rep2 = _active_refresh_replay()
check(JSON.stringify(rep1) == JSON.stringify(rep2), "轮内刷新回放断言:同种子两遍步骤流逐行相同")
check(rep1.size() > 0, "轮内刷新步骤流非空(%d 行)" % rep1.size())
func _active_refresh_replay() -> Array:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260912)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}):
return rows
var minesweeper = settlement.minesweeper
for i in range(3):
minesweeper.settle_batch([{"action": "stop"}])
for attempt in range(3):
var plan = AutoPlayer.choose_dig(minesweeper)
if not plan.is_empty():
if plan.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": plan.hero_cell}])
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
settlement.player_request_refresh()
minesweeper.settle_batch([{"action": "stop"}])
return rows
# ---- 难度曲线与Boss DifficultyTable 结构位冒烟(AC-10:空表零行为 + 套乘点 = 行值快照前)----
func _smoke_difficulty_table() -> void:
print("--- 难度曲线与Boss·DifficultyTable 结构位 ---")
check(_db.difficulty != null and _db.difficulty.rows.is_empty(), "DifficultyTable MVP 空表加载(结构位)")
# ① 空表零行为:敌人系统.spawn 敌值 = EnemyTable 行值原样
var enemy_system = load("res://core/unit_container.gd").new(_db)
check(enemy_system.spawn(1, 0, int(_db.hero.max_hp)), "spawn 受理")
var unit = enemy_system.all_enemies()[0]
var d0: Dictionary = _db.resolve_enemy_rowset(1, 0)[0] # 行集数组口径取首行(零内容期每坐标恰一行,T2 机械适配)
check(int(unit.hp) == int(d0.hp) and int(unit.atk) == int(d0.atk), "空表零修饰(hp=%d atk=%d = 行值 %d/%d)" % [int(unit.hp), int(unit.atk), int(d0.hp), int(d0.atk)])
# ② 修饰行运行时注入(debug 通道——R3 override 同族):套乘生效于 hp/atk
_db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 1.5, "atk_mult": 2.0})
var enemy_system_b = load("res://core/unit_container.gd").new(_db)
check(enemy_system_b.spawn(1, 0, int(_db.hero.max_hp)), "修饰后 spawn 受理")
var g2 = enemy_system_b.all_enemies()[0]
check(int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp), "hp 修饰 ×1.5(%d→%d,max_hp 同步)" % [int(d0.hp), int(g2.hp)])
check(int(g2.atk) == int(d0.atk) * 2, "atk 修饰 ×2.0(%d→%d)" % [int(d0.atk), int(g2.atk)])
# ③ 越域 clamp:乘数 50 → clamp 10
_db.difficulty.rows.clear()
_db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 50.0, "atk_mult": 1.0})
var enemy_system_c = load("res://core/unit_container.gd").new(_db)
check(enemy_system_c.spawn(1, 0, int(_db.hero.max_hp)), "越域修饰 spawn 受理")
check(int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10, "越域乘数 clamp 至 10(%d→%d)" % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)])
_db.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线)
# ---- 攻击类型 × 护甲克制 + 标错惩罚冒烟(2026-09-12 克制批:护甲快照/克制矩阵/充能/反噬三选一)----
func _smoke_damage_types() -> void:
print("--- 攻击类型 × 护甲克制 + 标错惩罚 ---")
# ① 护甲类型快照传播(spawn 行值 → 单位字典)
var enemy_system = load("res://core/unit_container.gd").new(_db)
check(enemy_system.spawn(1, 0, int(_db.hero.max_hp)) and String(enemy_system.all_enemies()[0].armor_type) == "light", "goblin 护甲 = light(轻甲——教学位)")
var enemy_system_b = load("res://core/unit_container.gd").new(_db)
check(enemy_system_b.spawn(1, 2, int(_db.hero.max_hp)) and String(enemy_system_b.all_enemies()[0].armor_type) == "light", "bugbear 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)")
var enemy_system_c = load("res://core/unit_container.gd").new(_db)
check(enemy_system_c.spawn(1, 4, int(_db.hero.max_hp)) and String(enemy_system_c.all_enemies()[0].armor_type) == "light", "ogre_chief 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)")
# ② 克制矩阵表值在位(2026-09-18 加法核换装:三档点数 克制 +1 / 中性 0 / 被克 −1——percent 量纲退役)
var am: Dictionary = _db.settlement.armor_effectiveness
check(int(am.normal.light) == 0 and int(am.normal.ward) == 1 and int(am.normal.heavy) == -1, "普通行:轻甲 0 / 魔法护甲 +1 / 重甲 −1(加法核)")
check(int(am.pierce.light) == -1 and int(am.pierce.ward) == 1 and int(am.pierce.heavy) == 0, "穿刺行:轻甲 −1 / 魔法护甲 +1 / 重甲 0 中性(用户拍板)")
check(int(am.magic.light) == 0 and int(am.magic.ward) == -1 and int(am.magic.heavy) == 1, "魔法行:轻甲 0 / 魔法护甲 −1 / 重甲 +1(加法核)")
# ③ 标错(类型化负向兑付)+ 标中正向兑付(2026-09-19 充能退役后标中旗只兑格面资源正向;Boss 行多轮样本;板真相直读保证 hit/miss 确定性)
var run1 = _run_flag_backlash_round(20260912)
var miss_rows := 0
var negative_rows := 0
var hit_rows := 0
for row in run1.rows:
if row.get("type", "") == "flag_miss":
miss_rows += 1
if String(row.get("source_class", "")) == "flag_miss" and String(row.get("type", "")) in ["effect", "damage"]:
negative_rows += 1 # 标错的负向兑付行(该安全格资源的负向字典行——effect/直伤)
if String(row.get("source_class", "")) == "flag_hit":
hit_rows += 1 # 标中的正向兑付行(该雷格资源的正向字典行——2026-09-19 充能退役后标中只走格面资源正向)
check(miss_rows >= 1, "标错旗产生 flag_miss 归因行(×%d)" % miss_rows)
check(negative_rows >= 1, "标错旗兑付该安全格资源的负向字典行(×%d——attack/defense/weaken 效果行或 heal 直伤行)" % negative_rows)
check(hit_rows >= 1, "标中旗兑付该雷格资源的正向字典行(×%d——充能退役后无魔法分量,正向=格面资源)" % hit_rows)
# ④ 同种子回放(极性路由零随机——同种子逐行相同)
var run2 = _run_flag_backlash_round(20260912)
check(JSON.stringify(run1.rows) == JSON.stringify(run2.rows), "标错惩罚轮回放断言:同种子步骤流逐行相同(类型化负向兑付确定性)")
# 构造轮:标 1 面"安全格旗"(必 miss → 该格资源负向兑付)+ 标 1 面"雷旗"(必 hit → 正向兑付 + 充能)后停手收轮。
# 返回 flagged safe 格与其 cell_type(Ready 域内查询——类型化断言的数据面)。
func _run_flag_backlash_round(seed_val: int) -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}):
return {"rows": rows, "safe_cell": Vector2i(-1, -1), "safe_type": ""}
var minesweeper = settlement.minesweeper
var safe_t := _find_flag_target(minesweeper, false)
var mine_t := _find_flag_target(minesweeper, true)
var safe_type := ""
if not safe_t.is_empty():
var ident = settlement.query_reward_identity(safe_t.flag_cell)
safe_type = String(ident.get("cell_type", "")) if ident != null else ""
if safe_t.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": safe_t.hero_cell}])
minesweeper.settle_batch([{"action": "flag_place", "cell": safe_t.flag_cell}])
if not mine_t.is_empty():
if mine_t.hero_cell != minesweeper.hero_pos():
minesweeper.settle_batch([{"action": "move", "cell": mine_t.hero_cell}])
minesweeper.settle_batch([{"action": "flag_place", "cell": mine_t.flag_cell}])
minesweeper.settle_batch([{"action": "stop"}])
return {"rows": rows, "safe_cell": safe_t.get("flag_cell", Vector2i(-1, -1)), "safe_type": safe_type}
# 板真相直读找旗目标:未开 + 指定雷/安全属性 + 存在可达已开邻格(测试允许读板真相——排雷玩法 冒烟同口径)
func _find_flag_target(minesweeper, want_mine: bool) -> Dictionary:
var revealed: Array = minesweeper.revealed_cells()
var revealed_set := {}
for c in revealed:
revealed_set[c] = true
for y in minesweeper.current_board().height:
for x in minesweeper.current_board().width:
var c := Vector2i(x, y)
if minesweeper.is_revealed(c) or minesweeper.has_flag(c):
continue
if minesweeper.current_board().is_mine(c) != want_mine:
continue
for n in minesweeper.current_board().neighbors8(c):
if revealed_set.has(n) and (minesweeper.compute_move_path(n).size() > 0 or n == minesweeper.hero_pos()):
return {"flag_cell": c, "hero_cell": n}
return {}
# ---- 插旗不可逆四情况冒烟(2026-09-19 用户拍板全套落地:禁拔旗 + 挖旗格兑付改判)----
# 四情况:① 插雷格不动 = 标中奖励照常;② 插雷格再挖开 = 只受挖雷一层惩罚(mine_hit 照常)标中不发;
# ③ 插安全格不动 = 标空惩罚照常;④ 插安全格再挖开 = 零惩罚零奖励(正向兑付作废、标空不判)。
# 退役面:flag_pull 出目录(out_of_catalog);重复插旗仍 flag_present 拒绝。
# 场景 A(④ + 退役面):标安全格 → flag_pull 拒绝 → 重复插旗拒绝 → 直接挖开 → 收轮。
# 场景 B(②):标雷格 → 直接挖开 → 收轮。
# 场景 C(①③):标安全格 + 标雷格 → 不挖收轮 → 标空/标中判定照常。
func _smoke_flag_irreversible_quadrants() -> void:
print("--- 插旗不可逆四情况(禁拔旗 + 挖旗格兑付改判)---")
# 场景 A:安全格直挖(情况④)+ flag_pull/重复插旗退役面
var rng_a = load("res://core/rng_service.gd").new()
rng_a.set_master_seed(20260919)
var settle_a = load("res://core/battle_orchestrator.gd").new(_db, rng_a)
var rows_a: Array = []
settle_a.step_row.connect(func(row): rows_a.append(row))
check(settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 A:开局受理")
var ms_a = settle_a.minesweeper
var t_a := _find_flag_target(ms_a, false)
check(not t_a.is_empty(), "四情况 A:找到安全格旗目标")
if not t_a.is_empty():
if t_a.hero_cell != ms_a.hero_pos():
ms_a.settle_batch([{"action": "move", "cell": t_a.hero_cell}])
check(ms_a.settle_batch([{"action": "flag_place", "cell": t_a.flag_cell}])[0].accepted, "四情况 A:插旗受理")
var pull_out: Array = ms_a.settle_batch([{"action": "flag_pull", "cell": t_a.flag_cell}])
check(not pull_out.is_empty() and not bool(pull_out[0].get("accepted", true)) and String(pull_out[0].get("sub_reason", "")) == "out_of_catalog",
"四情况 A:flag_pull 出目录拒绝(拔旗退役)")
var dup_place: Array = ms_a.settle_batch([{"action": "flag_place", "cell": t_a.flag_cell}])
check(not dup_place.is_empty() and not bool(dup_place[0].get("accepted", true)) and String(dup_place[0].get("sub_reason", "")) == "flag_present",
"四情况 A:重复插旗仍被拒(flag_present)")
var dig_a: Array = ms_a.settle_batch([{"action": "dig", "cell": t_a.flag_cell}])
check(not dig_a.is_empty() and bool(dig_a[0].get("accepted", false)), "四情况 A:旗格直接挖开被受理(不再须先拔旗)")
check(not ms_a.has_flag(t_a.flag_cell), "四情况 A:旗随挖掘隐式退役(has_flag = false)")
ms_a.settle_batch([{"action": "stop"}])
var target_a: Array = [t_a.flag_cell.x, t_a.flag_cell.y]
var miss_a := 0
var grant_a := 0
var void_a := 0
for row in rows_a:
if row.get("origin_cell", [0, 0]) != target_a:
continue
if row.get("type", "") == "flag_miss" or String(row.get("source_class", "")) == "flag_miss":
miss_a += 1
if String(row.get("source_class", "")) == "safe_cell" and row.get("type", "") != "reward_ineffective":
grant_a += 1
if row.get("type", "") == "reward_ineffective":
void_a += 1
check(miss_a == 0, "四情况 A:标空判定零兑付(×%d——旗已擦除不入轮末基线)" % miss_a)
check(grant_a == 0, "四情况 A:安全格正向兑付作废(×%d——零奖励)" % grant_a)
check(void_a == 1, "四情况 A:兑付作废归因行恰一次(×%d——零惩罚零奖励留痕)" % void_a)
# 场景 B:雷格直挖(情况②)
var rng_b = load("res://core/rng_service.gd").new()
rng_b.set_master_seed(20260919)
var settle_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b)
var rows_b: Array = []
settle_b.step_row.connect(func(row): rows_b.append(row))
check(settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 B:开局受理")
var ms_b = settle_b.minesweeper
var t_b := _find_flag_target(ms_b, true)
check(not t_b.is_empty(), "四情况 B:找到雷格旗目标")
if not t_b.is_empty():
if t_b.hero_cell != ms_b.hero_pos():
ms_b.settle_batch([{"action": "move", "cell": t_b.hero_cell}])
check(ms_b.settle_batch([{"action": "flag_place", "cell": t_b.flag_cell}])[0].accepted, "四情况 B:插旗受理")
var dig_b: Array = ms_b.settle_batch([{"action": "dig", "cell": t_b.flag_cell}])
check(not dig_b.is_empty() and bool(dig_b[0].get("accepted", false)), "四情况 B:旗雷格直接挖开被受理")
check(ms_b.is_revealed(t_b.flag_cell) and ms_b.current_board().is_mine(t_b.flag_cell), "四情况 B:雷格已揭示(挖雷本体照常)")
ms_b.settle_batch([{"action": "stop"}])
var target_b: Array = [t_b.flag_cell.x, t_b.flag_cell.y]
var mine_hit_b := 0
var hit_b := 0
for row in rows_b:
if row.get("origin_cell", [0, 0]) != target_b:
continue
if String(row.get("source_class", "")) == "mine_hit":
mine_hit_b += 1
if row.get("type", "") == "flag_hit" or String(row.get("source_class", "")) == "flag_hit":
hit_b += 1
check(mine_hit_b >= 1, "四情况 B:挖雷惩罚照常兑付(×%d——只受挖雷一层惩罚)" % mine_hit_b)
check(hit_b == 0, "四情况 B:标中不发(×%d——旗已擦除不判)" % hit_b)
# 场景 C:不挖收轮(①③判定照常)
var rng_c = load("res://core/rng_service.gd").new()
rng_c.set_master_seed(20260919)
var settle_c = load("res://core/battle_orchestrator.gd").new(_db, rng_c)
var rows_c: Array = []
settle_c.step_row.connect(func(row): rows_c.append(row))
check(settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 C:开局受理")
var ms_c = settle_c.minesweeper
var t_safe := _find_flag_target(ms_c, false)
var t_mine := _find_flag_target(ms_c, true)
check(not t_safe.is_empty() and not t_mine.is_empty(), "四情况 C:安全格+雷格旗目标齐备")
if not t_safe.is_empty() and not t_mine.is_empty():
for t in [t_safe, t_mine]:
if t.hero_cell != ms_c.hero_pos():
ms_c.settle_batch([{"action": "move", "cell": t.hero_cell}])
ms_c.settle_batch([{"action": "flag_place", "cell": t.flag_cell}])
ms_c.settle_batch([{"action": "stop"}]) # 不挖收轮——两旗均在场基线
var safe_c: Array = [t_safe.flag_cell.x, t_safe.flag_cell.y]
var mine_c: Array = [t_mine.flag_cell.x, t_mine.flag_cell.y]
var miss_c := 0
var miss_neg_c := 0
var hit_c := 0
for row in rows_c:
if row.get("origin_cell", [0, 0]) == safe_c and row.get("type", "") == "flag_miss":
miss_c += 1
if row.get("origin_cell", [0, 0]) == safe_c and String(row.get("source_class", "")) == "flag_miss" and row.get("type", "") != "flag_miss":
miss_neg_c += 1
if row.get("origin_cell", [0, 0]) == mine_c and (row.get("type", "") == "flag_hit" or String(row.get("source_class", "")) == "flag_hit"):
hit_c += 1
check(miss_c >= 1 and miss_neg_c >= 1, "四情况 C:标空惩罚照常(归因×%d 负向兑付×%d)" % [miss_c, miss_neg_c])
check(hit_c >= 1, "四情况 C:标中奖励照常(×%d)" % hit_c)
# ---- 试玩日志器冒烟(结算快照 JSONL + 诊断转储)----
func _smoke_playtest_logger() -> void:
print("--- PlaytestLogger(结算快照 JSONL + 诊断转储)---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260912)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var logger = load("res://presentation/playtest_logger.gd").new()
var path: String = logger.start_session(20260912)
logger.attach_battle(settlement)
var snaps: Array = []
settlement.settlement_snapshot.connect(func(snap): snaps.append(snap))
check(path.contains("playtest_20260912"), "会话文件开启(%s)" % path)
check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}), "快照战斗启动")
var minesweeper = settlement.minesweeper
for i in range(2):
minesweeper.settle_batch([{"action": "stop"}])
check(snaps.size() == 2, "每结算一快照(%d 次结算 → %d 快照)" % [2, snaps.size()])
var s1: Dictionary = snaps[0]
var required := ["kind", "level", "depth", "round", "verdict", "hero", "economy", "board", "enemies", "buffs", "round_facts", "spawn_count"]
var missing := []
for k in required:
if not s1.has(k):
missing.append(k)
check(missing.is_empty(), "快照字段齐备(缺 %s)" % str(missing))
check(int(s1.hero.max_hp) == int(_db.hero.max_hp) and s1.economy.has("phase") and s1.board.has("seq"), "快照含英雄/经济/板序号(板 seq=%d)" % int(s1.board.seq))
# spawn_count 回归(playtest-2026-09-12 报告 Bug#1):停手两轮零击杀(主角攻击 6×2 < 24 HP 确定)→
# unit_count 必须 == spawn_count(全灭才允许 <);快照级区分「首轮全灭」vs「空场」的数据面在位
check(int(s1.get("spawn_count", -1)) >= 1 and int(s1.get("spawn_count", -1)) == int(s1.get("unit_count", -2)),
"快照 spawn_count 在位且 == unit_count(无击杀轮:%d/%d——全灭/空场可判别)" % [int(s1.get("spawn_count", -1)), int(s1.get("unit_count", -1))])
logger.log_action("dig", Vector2i(1, 1), true)
var diag: String = logger.dump_diagnosis({"test": true})
check(diag.contains("diag_"), "诊断转储文件生成(%s)" % diag)
# JSONL 文件内容验证(重新读取)
var f := FileAccess.open(path, FileAccess.READ)
var lines := 0
var parse_ok := true
while not f.eof_reached():
var line := f.get_line()
if line.strip_edges() == "":
continue
lines += 1
var parsed = JSON.parse_string(line)
if parsed == null:
parse_ok = false
check(lines >= 3 and parse_ok, "JSONL 全部行可解析(%d 行 = session + 2 结算)" % lines)
logger.close_session()
# ---- 遭遇随机化冒烟(难度曲线与Boss Core Rule 8:区间骰/encounter 流/可回放/空表定值路径)----
func _smoke_encounter_roll() -> void:
print("--- 遭遇随机化(unit_count 区间骰)---")
var counts: Array = []
for i in range(20):
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(30000 + i)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
check(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}), "遭遇扫描战斗启动(quiet 计数档——计数入总账,成败由后续 spawn 断言归因)", true)
counts.append(settlement.presentation_snapshot().enemies.size())
# 区间生效:20 个种子应出现 1 和 2(D0 [1,2])
check(counts.has(1) and counts.has(2), "D0 区间 [1,2] 两值均出现(分布 %s)" % str(counts))
# 域内断言带标签(原恒真 check(true) 已废——断言必须能抓住失效,code-review 2026-09-12)
var all_in_domain := true
for c in counts:
if c < 1 or c > 2:
all_in_domain = false
check(all_in_domain, "全部计数在域内 [1,2](分布 %s)" % str(counts))
# 同种子可回放:两遍同数量(encounter 流确定性)
var r1 := _encounter_replay(30000)
var r2 := _encounter_replay(30000)
check(r1 == r2, "同种子遭遇骰可回放(%s == %s)" % [str(r1), str(r2)])
# D1 [2,3]:seed 30005 路径(任意种子域内即可)
var rng3 = load("res://core/rng_service.gd").new()
rng3.set_master_seed(31000)
var settlement_d1 = load("res://core/battle_orchestrator.gd").new(_db, rng3)
check(settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.hero.max_hp)}), "D1 战斗启动")
var n1: int = settlement_d1.presentation_snapshot().enemies.size()
check(n1 >= 2 and n1 <= 3, "D1 区间 [2,3] 域内(%d 只)" % n1)
# D2 固定(教学位不加骰)
var rng4 = load("res://core/rng_service.gd").new()
rng4.set_master_seed(31000)
var settlement_d2 = load("res://core/battle_orchestrator.gd").new(_db, rng4)
settlement_d2.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)})
check(settlement_d2.presentation_snapshot().enemies.size() == 1, "D2 熊地精固定 1(首遇教学位)")
# 全深度 × 多种子域内扫描(playtest-2026-09-12 报告 Bug#1 回归——区间下限 1 不可掷 0:
# 战斗结算 roll_lo 下限 + 敌人系统 spawn count<1 fail-fast 双闸;单深度单种子是盲区,全域扫描才钉死不变式)
var depths: Array = []
for node in _level_battle_nodes(1):
depths.append(int(node.get("node_depth", -1)))
var scan_bad := 0
var scan_total := 0
for i in range(12):
var rngs = load("res://core/rng_service.gd").new()
rngs.set_master_seed(32000 + i)
for depth_v in depths:
scan_total += 1
var settlement_scan = load("res://core/battle_orchestrator.gd").new(_db, rngs)
if not settlement_scan.begin_battle({"level_index": 1, "node_depth": int(depth_v), "hero_hp": int(_db.hero.max_hp)}):
scan_bad += 1
continue
var n: int = settlement_scan.presentation_snapshot().enemies.size()
var erow2: Dictionary = _db.resolve_enemy_rowset(1, int(depth_v))[0] # 行集首行(T2 机械适配)
var lo2: int = int(erow2.get("unit_count", 1))
var hi2: int = int(erow2.get("unit_count_max", lo2))
if n < 1 or n < lo2 or n > hi2:
scan_bad += 1
check(scan_bad == 0, "全深度 × 12 种子 spawn 全域内(%d 场,∈[unit_count, unit_count_max] 且 ≥1)——空遭遇不可达(Bug#1 回归)" % scan_total)
# 模板感知取节点表(M-3 迭代 5 T5——row.templates 非空取模板 0=现行固定图,保持既有断言语义)
func _level_active_nodes(level_index: int) -> Array:
for row in _db.level.rows:
if int(row.level_index) == level_index:
var tpls: Array = row.get("templates", [])
if not tpls.is_empty():
return tpls[0].get("nodes", [])
return row.get("nodes", [])
return []
func _level_battle_nodes(level_index: int) -> Array:
var out: Array = []
for row in _db.level.rows:
if int(row.level_index) == level_index:
for node in _level_active_nodes(level_index):
var t := String(node.get("type", ""))
if t == "battle" or t == "boss":
out.append(node)
return out
# ---- 战斗段 fresh 池攻击链回归(playtest-2026-09-12 报告 Bug#2 沉淀——
# 「Boss 攻击拍零兑现」实为旧 defense_up 数据 ×2 叠满 = 池触底 ×0(设计语义);本断言钉死:
# 池为空时攻击链必须按掷点档位足额兑现——2026-09-16 护甲等级掷点批改口径:
# d_tick = max(damage_floor, pool_damage(floor(基线×档位%), 0, 0)),档位读数随行重建可归因)----
func _smoke_attack_chain_fresh_pool() -> void:
print("--- 战斗结算·fresh 池攻击链回归(Bug#2·掷点口径)---")
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260913)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var ticks: Array = [] # {baseline, attach, roll, tier, crit, tick}——敌行动伤害行重建
settlement.step_row.connect(func(row):
if row.type == "damage" and row.source_class == "enemy_action":
ticks.append({"baseline": int(row.baseline), "attach": int(row.get("attach", 0)), "roll": int(row.roll),
"tier": int(row.tier), "crit": bool(row.get("crit", false)), "tick": int(row.value)}))
check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "fresh 池回归战斗启动(Boss 行)")
# 停手轮:零挖掘零旗 → 零授予 → 池恒空;Boss 拍 = 开轮摇号结果(伤害技/普攻兑付伤害行、硬化施放行不计)
for i in range(6):
if settlement.battle_result != "":
break
settlement.minesweeper.settle_batch([{"action": "stop"}])
check(ticks.size() >= 2, "非施放攻击拍采样充足(%d 拍——增益施放行零兑付为设计语义不计)" % ticks.size())
var all_full := true
for t in ticks:
var base_factor: float = float(t.tier) * (float(_db.settlement.crit_multiplier) / 100.0 if t.crit else 1.0)
var rolled_base := int(floor(float(t.baseline) * base_factor / 100.0))
var expected: int = maxi(int(_db.settlement.damage_floor), rolled_base + int(t.get("attach", 0)))
if int(t.tick) != expected or int(t.tick) <= 0:
all_full = false
check(all_full, "fresh 池非蓄力拍 d_tick = max(1, 掷点档位基线)(Boss 攻击链健康——Bug#2 回归断言)")
func _encounter_replay(seed_val: int) -> Array:
var out: Array = []
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
out.append(settlement.presentation_snapshot().enemies.size())
return out
# ==================== 清图奖励双份兑付链 / 轮内刷新 provenance 守卫 / 窗口锚行(2026-09-15 补覆盖批) ====================
# 清图 = 全安全格开完。清图测试用"真雷旗(零误标→完美排雷)+ AutoPlayer 逐轮清图"真实流驱动;
# DifficultyTable 注入 hp_mult=10 把 Boss 血量抬到 1500,保证清图批次发生在战斗结束之前(测试台账,跑完即还原)。
const CLEAR_SMOKE_DEPTH := 4
const CLEAR_SMOKE_HERO_HP := 100000
var _regular_clear_reward_rows := 0 # 常规(非刷新图)清图的 clear_reward 行数——刷新守卫对照基线
func _smoke_clear_reward_chain() -> void:
print("--- 清图奖励·双份兑付链(完美排雷 / 零兑付 / 第二份被拒 / 高光记实)---")
# ① 真实流完美排雷:标中一面真雷旗 + 零误标 + 清全图 → grant_clear 身份(clear_blast)双份真实兑付
var rows: Array = []
var run := _run_full_clear(20260915, false, rows)
check(bool(run.cleared), "① 完美排雷清图达成(%d 轮)" % int(run.rounds))
var dmg := []
for r in rows:
if String(r.get("type", "")) == "damage" and String(r.get("source_class", "")) == "clear_reward":
dmg.append(r)
var hl := _rows_of_type(rows, "highlight")
_regular_clear_reward_rows = dmg.size()
check(dmg.size() == 2, "① 清图奖励双份真实兑付(clear_reward 伤害行 ×%d——clear_blast 身份两份各打一发)" % dmg.size())
if dmg.size() == 2:
check(String(dmg[0].display_name).contains("清图奖励") and String(dmg[1].display_name).contains("完美第二份"),
"① 第二份按身份类型走真实兑付(首份『清图奖励』/ 第二份『完美第二份』)")
check(hl.size() == 1 and int(hl[0].value) == 2, "④ 高光行 value = 实际有效份数 2(不记名义值;行数 %d)" % hl.size())
if hl.size() == 1:
check(String(hl[0].get("highlight", "")) == "perfect_sweep", "高光标记 = perfect_sweep")
# ②/③ 弱形式:正常战斗流不可达的零兑付分支(见函数头注)
_smoke_clear_reward_zero_paths()
# 弱形式登记:正常战斗流下"授段时刻无存活敌"不可达——敌全灭当轮判定段即判胜并终止战斗,而清图批在判定段之前,
# 恒有存活敌;故本组直调编排私有面构造状态,只断言"路径存在 + 行为符合契约"(构造手法见各行注释)。
func _smoke_clear_reward_zero_paths() -> void:
# ②-a 首份零兑付判定(无存活敌)→ first_ok=false + 未生效行
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260915)
var s = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
s.step_row.connect(func(row): rows.append(row))
check(s.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
"零兑付构造战斗启动")
for u in s._enemy_system.all_enemies():
s._enemy_system.apply_damage(u, int(u.hp)) # 测试通道:构造"无存活敌"(弱形式——全灭而非 despawn)
var snap = s._reward_economy.grant_clear()
check(snap != null and String(snap.cell_type) == "blast",
"清图槽身份 = blast 型(clear_default 唯一活行 clear_blast——第二份同身份兑付)")
rows.clear()
var first_ok: bool = s._grant_clear_identity(snap, 1, "(清图奖励)")
check(not first_ok, "② 即时类首份零兑付(无存活敌)→ first_ok=false")
check(_has_source_row(rows, "reward_ineffective", "clear_reward", "clear_blast"),
"② 零兑付产出 reward_ineffective 行(batch_key=clear_blast)")
# ②-b 高光守卫:perfect 谓词成立但首份零兑付 → 高光行不发(不记名义值)
var rng_b = load("res://core/rng_service.gd").new()
rng_b.set_master_seed(20260915)
var s_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b)
var rows_b: Array = []
s_b.step_row.connect(func(row): rows_b.append(row))
check(s_b.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
"高光守卫构造战斗启动")
for u in s_b._enemy_system.all_enemies():
s_b._enemy_system.apply_damage(u, int(u.hp))
s_b._perfect_hit_true = 1 # 测试通道:构造完美谓词成立(真值在正常流由旗判定累加)
s_b._perfect_hit_false = 0
rows_b.clear()
s_b._grant_clear_reward(1)
check(_has_source_row(rows_b, "reward_ineffective", "clear_reward", "clear_blast"),
"② _grant_clear_reward 守卫路径重走首份 → 未生效行仍在案")
check(_rows_of_type(rows_b, "highlight").is_empty(), "② 首份零兑付 → 高光行不发(不记名义值 2)")
# ③ 第二份被拒:唯一存活敌 HP ≤ 首份 aoe 量 → 首份击杀 → 第二份无存活敌(zero + 未生效行)
var rng2 = load("res://core/rng_service.gd").new()
rng2.set_master_seed(20260915)
var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2)
var rows2: Array = []
s2.step_row.connect(func(row): rows2.append(row))
check(s2.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}),
"第二份被拒构造战斗启动")
var boss = s2._enemy_system.all_enemies()[0]
boss.hp = 1 # 测试通道:压低至首份 aoe 必杀(弱形式——正常流 Boss 血量不可控)
s2._perfect_hit_true = 1
s2._perfect_hit_false = 0
rows2.clear()
s2._grant_clear_reward(1)
check(_has_source_row(rows2, "damage", "clear_reward"), "③ 首份 aoe 真实兑付(击杀唯一存活敌)")
check(_has_source_row(rows2, "reward_ineffective", "clear_reward", "clear_blast"),
"③ 第二份被拒产出 reward_ineffective 行(零兑付)")
var hl2 := _rows_of_type(rows2, "highlight")
check(hl2.size() == 1 and int(hl2[0].value) == 1, "④ 高光行 value = 实际有效份数 1(不记名义值 2)")
# 轮内刷新 provenance 守卫:刷新换来的图同轮清图不发清图奖励(_board_from_midround_refresh 守卫)
func _smoke_refresh_provenance_guard() -> void:
print("--- 轮内刷新 provenance 守卫(刷新图清图不发清图奖励)---")
var rows: Array = []
var run := _run_full_clear(20260916, true, rows)
check(bool(run.refreshed), "轮内刷新受理(三轮停手攒 RAP 后 Active 相位发起)")
check(bool(run.cleared), "刷新图清图达成(%d 轮)" % int(run.rounds))
var cr := 0
var hl := 0
for r in rows:
if String(r.get("source_class", "")) == "clear_reward":
cr += 1
if String(r.get("type", "")) == "highlight":
hl += 1
check(cr == 0, "刷新图清图 → 无 clear_reward 行(守卫在位——实测 %d 行)" % cr)
check(hl == 0, "刷新图清图 → 无高光行(守卫在位——实测 %d 行)" % hl)
check(_regular_clear_reward_rows > 0, "对照基线:常规换图清图有 clear_reward 行(×%d——守卫非全局静默)" % _regular_clear_reward_rows)
# ==================== 格面资源批新增覆盖(极性路由 / 负向字典 / 资源面 / 五段全序) ====================
# 极性路由断言(战斗结算 验收):挖安全格/标中旗只出正向、挖雷格/标错旗只出负向、兑付行指回来源格。
# 正向字典效果集 = {attack_up, ac_up, weaken}(ac_up = 2026-09-16 护甲等级掷点批换装,原 defense_up)
# + spark_aoe/heal_minor(flat)+ gp_gain(九型批 2026-09-20——金币袋,escrow 缓冲路由键非增益容器行);
# 负向字典效果集 = {enrage, off_guard, sapped}(percent)+ 直伤(heal flat)/ alert 警觉(blast percent——2026-09-20 换装)
# + gp_loss(九型批——破财,escrow 缓冲路由键)。
const POSITIVE_EFFECTS := ["attack_up", "ac_up", "weaken", "gp_gain"]
const NEGATIVE_EFFECTS := ["enrage", "off_guard", "sapped", "alert", "gp_loss"]
# 构造验收谓词(hunt 用——并流批后种子按行为面扫描:四触达 + 标空旗负向兑付行在案)
func _polarity_probe_accepts(probe: Dictionary) -> bool:
if not (bool(probe["flag_hit_done"]) and bool(probe["flag_miss_done"])):
return false
if int(probe["mine_events"]) < 1 or int(probe["safe_events"]) < 1:
return false
var miss_rows := _rows_at(probe["rows"], "flag_miss", probe["miss_cell"])
if miss_rows.is_empty():
return false
for r in miss_rows:
if String(r.get("type", "")) == "flag_miss":
continue
var eff := String(r.get("effect_id", ""))
if eff != "" and not NEGATIVE_EFFECTS.has(eff):
return false
return true
func _smoke_polarity_routing() -> void:
print("--- 极性路由(挖安全格正向 / 挖雷格负向 / 标中正向 / 标空负向)---")
# 并流批(2026-09-27 统一流裁决)后战斗随机值面漂移——构造种子改行为面扫描(谓词见 _polarity_probe_accepts)
var probe := {}
var polarity_seed := -1
for s in [20260918, 20260919, 20260920, 20260921, 42, 4242, 7, 100, 20260922, 123, 2024, 31337, 555, 1111, 2222, 20260923, 999, 777]:
probe = _run_polarity_probe(s)
if _polarity_probe_accepts(probe):
polarity_seed = s
break
check(polarity_seed > 0, "极性路由构造种子可达(seed=%d——并流后行为面重锚)" % polarity_seed)
check(bool(probe["flag_hit_done"]) and bool(probe["flag_miss_done"]), "构造触达:标中真雷 + 标空安全格均入队")
check(int(probe["mine_events"]) >= 1, "构造触达:挖到雷格 ≥1(%d)" % int(probe["mine_events"]))
check(int(probe["safe_events"]) >= 1, "构造触达:挖到安全格 ≥1(%d)" % int(probe["safe_events"]))
# 挖到的安全格:只出正向——其全部来源行必存在且 effect 行 ∈ 正向集
# (action 型排队行 effect_id=ap_delta 正负两极共用路由键——按行值符号辨极性:正向格恒 +power)
var safe_all_pos := true
for e in probe["safe_cells"].keys():
var cell_arr: Array = [e.x, e.y]
var rows := _rows_at(probe["rows"], "safe_cell", cell_arr)
if rows.is_empty():
safe_all_pos = false # 每个资源格必有 ≥1 兑付行(每格必有货 → 每格必有行)
for r in rows:
var eff := String(r.get("effect_id", ""))
if eff == "ap_delta":
if int(r.get("value", 0)) <= 0:
safe_all_pos = false
continue
if eff != "" and not POSITIVE_EFFECTS.has(eff):
safe_all_pos = false
check(safe_all_pos, "挖安全格 → 只出正向兑付(效果行 ∈ 正向字典 + 每格 ≥1 行——%d 格)" % probe["safe_cells"].size())
# 挖到的雷格:只出负向——来源行必存在且 effect 行 ∈ 负向集(alert = blast 负向;heal 负向 = damage 行;
# action 型排队行同按行值符号辨极性:雷格恒 −power)
var mine_all_neg := true
for e in probe["mine_cells"].keys():
var cell_arr: Array = [e.x, e.y]
var rows := _rows_at(probe["rows"], "mine_hit", cell_arr)
if rows.is_empty():
mine_all_neg = false
for r in rows:
var eff := String(r.get("effect_id", ""))
if eff == "ap_delta":
if int(r.get("value", 0)) >= 0:
mine_all_neg = false
continue
if eff != "" and not NEGATIVE_EFFECTS.has(eff):
mine_all_neg = false
check(mine_all_neg, "挖雷格 → 只出负向兑付(效果行 ∈ 负向字典 + 每格 ≥1 行——%d 格)" % probe["mine_cells"].size())
# 标中旗:该雷格正向兑付行(flag_hit 来源)+ 效果 ∈ 正向字典
var hit_ok := false
for r in probe["rows"]:
if String(r.get("source_class", "")) == "flag_hit" and r.get("origin_cell", [0, 0]) == probe["hit_cell"]:
hit_ok = true
var eff := String(r.get("effect_id", ""))
if eff != "" and not POSITIVE_EFFECTS.has(eff):
hit_ok = false
check(hit_ok, "标中旗 → 该雷格资源的正向兑付行(origin 指回雷格,效果 ∈ 正向字典)")
# 条款3 条件二旗溢价断言(迭代14② T4——标中兑付行数值 = 同型正向 ×(1+δ)):
# δ 读 settlement.flag_hit_premium(125 provisional);九型正向行值全固定(唯一区间行 clear_blast
# 系清图槽不入格池)⇒ 逐型期望全确定。整数量级四舍五入域注记:ac_up/action 1 点量纲取整后不变
# =整数粒度地板——等号压线判读在当量聚合面(audit 工具/registry clause3_inequality),非断言面。
var prem_pct: int = int(_db.settlement.flag_hit_premium)
check(prem_pct == 125, "旗溢价配置在位(flag_hit_premium=125 provisional——形态 a 兑付溢价)")
var prem_rows := 0
var prem_ok := true
for r in probe["rows"]:
if String(r.get("source_class", "")) != "flag_hit" or String(r.get("highlight", "")) != "flag_hit":
continue
prem_rows += 1
var rtype := String(r.get("type", ""))
var v := int(r.get("value", 0))
var eff := String(r.get("effect_id", ""))
if rtype == "effect":
if eff == "gp_gain":
prem_ok = prem_ok and v == int(round(3 * prem_pct / 100.0)) # 金币袋 3→4
elif eff == "ap_delta":
prem_ok = prem_ok and v == 1 # +1AP:1 点量纲(round(1.25)=1——整数粒度地板)
else:
var mag: int = int(_db.status_effect_row(eff).get("magnitude", -999))
prem_ok = prem_ok and v == int(round(mag * prem_pct / 100.0)) # 增伤50→63/削弱65→81/护甲1→1
elif rtype == "heal":
# 急救 2→3(clamp 封顶只降不升:满血走 reward_ineffective 不入本行——区间断言)
prem_ok = prem_ok and v >= 1 and v <= int(round(2 * prem_pct / 100.0))
elif rtype == "damage":
var at := String(r.get("attack_type", ""))
if at == "":
prem_ok = prem_ok and v == int(round(1 * prem_pct / 100.0)) # 爆裂粉尘 1→1(群伤行值=表值直付)
else:
# 穿刺/魔法 2→3(行值=落地伤=基准+克制点数——反推基准核对;damage_floor 兜底下成立)
prem_ok = prem_ok and (v - int(r.get("effectiveness", 0))) == int(round(2 * prem_pct / 100.0))
check(prem_rows >= 1 and prem_ok, "标中兑付溢价(×%d 行逐型=同型正向 ×%d/100——等号压线注记:δ=1.25 时条件二 E[标中]=1.25·E_dig 恰等成立)" % [prem_rows, prem_pct])
# 标空不乘溢价:负向直伤行(反噬6/陷阱8/魔法反噬8 固定表值)零溢价污染——溢价值挂标中正向单点
var miss_flat_ok := true
for r in _rows_at(probe["rows"], "flag_miss", probe["miss_cell"]):
if String(r.get("type", "")) == "damage":
miss_flat_ok = miss_flat_ok and int(r.get("value", 0)) in [6, 8]
check(miss_flat_ok, "标空惩罚零溢价(负向直伤行=固定表值原值——溢价只挂标中正向)")
# 标空旗:归因行 + 该安全格负向兑付行(flag_miss 来源)
var miss_rows := _rows_at(probe["rows"], "flag_miss", probe["miss_cell"])
var miss_neg_ok := false
for r in miss_rows:
if String(r.get("type", "")) == "flag_miss":
miss_neg_ok = true
else:
var eff := String(r.get("effect_id", ""))
if eff != "" and not NEGATIVE_EFFECTS.has(eff):
miss_neg_ok = false
check(not miss_rows.is_empty() and miss_neg_ok, "标空旗 → 标旗未中归因行 + 该安全格资源负向兑付(同格同批)")
# 抵消过滤既有语义:标旗后被撤旗不判(发射面基线过滤)——由排雷玩法 冒烟与旗判定基线覆盖,此处核兑付行零泄漏
var flag_srcs := {}
for r in probe["rows"]:
if String(r.get("source_class", "")) in ["flag_hit", "flag_miss"]:
flag_srcs[r.get("origin_cell")] = true
check(int(flag_srcs.size()) <= 2, "旗判定兑付仅旗判定批两旗在案(撤旗抵消过滤零泄漏——%d 格)" % flag_srcs.size())
# 极性路由探针驱动:标 1 面真雷旗(标中)+ 1 面安全格旗(标空)→ AutoPlayer 挖到战斗终局(多轮样本)。
# 收集全部 cell_revealed / flag_judged 事件与结算行;返回格子集合与旗格坐标。
func _run_polarity_probe(seed_val: int) -> Dictionary:
_db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 10.0, "atk_mult": 0.5}) # 抬敌血压低敌攻——多轮挖掘样本不被终局截断
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var probe := {"rows": [], "safe_cells": {}, "mine_cells": {}, "hit_cell": [], "miss_cell": [],
"mine_events": 0, "safe_events": 0, "flag_hit_done": false, "flag_miss_done": false}
settlement.step_row.connect(func(row): probe["rows"].append(row))
settlement.minesweeper.cell_revealed.connect(func(evt):
if String(evt.get("channel", "")) == "initial":
return
if evt.kind == "mine":
probe["mine_cells"][evt.cell] = true
probe["mine_events"] = int(probe["mine_events"]) + 1
elif evt.kind == "safe":
probe["safe_cells"][evt.cell] = true
probe["safe_events"] = int(probe["safe_events"]) + 1)
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 300, "hero_max_hp": 300})
var ms = settlement.minesweeper
# 旗 1:真雷(标中 → 正向);旗 2:安全格(标空 → 负向)
var mine_t := _find_flag_target(ms, true)
var safe_t := _find_flag_target(ms, false)
if not mine_t.is_empty():
if mine_t.hero_cell != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": mine_t.hero_cell}])
ms.settle_batch([{"action": "flag_place", "cell": mine_t.flag_cell}])
probe["hit_cell"] = [mine_t.flag_cell.x, mine_t.flag_cell.y]
probe["flag_hit_done"] = true
if not safe_t.is_empty():
if safe_t.hero_cell != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": safe_t.hero_cell}])
ms.settle_batch([{"action": "flag_place", "cell": safe_t.flag_cell}])
probe["miss_cell"] = [safe_t.flag_cell.x, safe_t.flag_cell.y]
probe["flag_miss_done"] = true
# 挖到终局(旗挡链不影响 AutoPlayer——choose_dig 跳过旗格)
var out := {"result": "", "rounds": 0, "terminated": false, "stream": []}
var guard := 40
while settlement.battle_result == "" and guard > 0:
guard -= 1
_auto_play_round(settlement, out)
_db.difficulty.rows.clear() # 测试台账还原
return probe
func _rows_at(rows: Array, source_class: String, cell) -> Array:
var out: Array = []
for r in rows:
if String(r.get("source_class", "")) == source_class and r.get("origin_cell", [9, 9]) == cell:
out.append(r)
return out
# 负向字典逐型直验(类型化兑付行协议):找真实板上各型非封锁格,直调 _grant_routed 负向路由,
# 断言每型的兑付形态(enrage/off_guard/sapped 效果行、heal 直伤行、blast 警觉行、action 行动点排队)。
func _smoke_negative_dictionary() -> void:
print("--- 负向字典逐型直验(sapped 在位 / heal 直伤 / blast 警觉 / action 行动点)---")
# 数据面:sapped 行在位(状态表七行 + effect_pool_map 收编)
var sapped: Dictionary = _db.status_effect_row("sapped")
check(not sapped.is_empty() and String(sapped.get("target_side", "")) == "hero" \
and int(sapped.get("magnitude", 0)) == -25 and int(sapped.get("duration_settlements", 0)) == 1 \
and int(sapped.get("max_stack", 0)) == 16 and String(sapped.get("stack_policy", "")) == "stack",
"sapped 行在位(hero · −25 percent · dur 1 · max 16 · stack——weaken 型负向)")
check(String(_db.settlement.effect_pool_map.get("sapped", "")) == "hero_offense",
"effect_pool_map 收编 sapped → hero_offense(attack_up 镜像池)")
# 行为面:逐型负向兑付形态(真实板 + 真实路由路径)
# 数值压缩批(2026-09-17):heal 权重 15→3 后单板五型齐备不再必然(无 heal 板概率 ≈1/3)——
# 改多板扫描,取第一块五型齐备的板做行为断言。
var settlement = null
var board = null
var by_type := {} # cell_type → 首个非封锁格(query_identity 雷安同池——雷格可查)
var rows: Array = []
for seed_i in range(6):
# 每板独立 rng 实例(迭代 7 T1 修正——复用实例重播种触发 set_master_seed 的 _counter==0 守卫;
# 原版第一板即 break 掩盖了该违例,aux 供给挤占后多板扫描暴露)
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260919 + seed_i)
settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
rows = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_max_hp": int(_db.hero.max_hp)}):
continue
board = settlement.minesweeper.current_board()
var r0 := {}
for c in board.compute_r0():
r0[c] = true
by_type = {}
for y in board.height:
for x in board.width:
var c := Vector2i(x, y)
if r0.has(c) or by_type.size() == 5:
continue
var ident = settlement.query_reward_identity(c)
# aux 身份不入五型轴(秘宝内容批 2026-09-29——五型齐备=格面资源历史断言轴,
# aux 供给挤占不改变本断言语义;扫描排除 aux 身份格)
if ident != null and String(ident.cell_type) != RewardTable.TREASURE_ITEM_TYPE and not by_type.has(String(ident.cell_type)):
by_type[String(ident.cell_type)] = c
# break 条件=percent_map 三型齐备(迭代 7 T1——aux 供给挤占下 weaken/defense 收集时机漂移,
# 直验可达性优先;五型多样性 check 保留不动)
if by_type.has("attack") and by_type.has("defense") and by_type.has("weaken"):
break
check(by_type.size() == 5, "扫描 6 板内出现五型齐备板(%s)" % str(by_type.keys()))
# attack/defense/weaken:负向字典行走 apply_effect(off_guard 2026-09-18 换装 ac 轴——兑付形态不变)
var percent_map := {"attack": "enrage", "defense": "off_guard", "weaken": "sapped"}
for ctype in percent_map.keys():
if not by_type.has(ctype):
continue
rows.clear()
var snap: Dictionary = settlement._reward_economy.query_full_snapshot(by_type[ctype])
settlement._grant_routed(snap, "negative", "mine_hit", by_type[ctype], 1)
var eff: String = percent_map[ctype]
check(_has_source_row(rows, "effect", "mine_hit") and _has_effect_id(rows, eff),
"%s 型负向 → %s 效果行(mine_hit 来源指回该格)" % [ctype, eff])
# heal:负向 = 主角直伤(表值——数值压缩批 4→1;不经增益状态)
if by_type.has("heal"):
rows.clear()
var hp_before: int = int(settlement.presentation_snapshot().hero.hp)
var snap_h: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["heal"])
settlement._grant_routed(snap_h, "negative", "mine_hit", by_type["heal"], 1)
var hp_after: int = int(settlement.presentation_snapshot().hero.hp)
var backlash_power: int = 0
for row in _db.reward.rows:
if String(row.get("reward_id", "")) == "heal_backlash":
backlash_power = int(row.power)
check(hp_after == hp_before - backlash_power, "heal 型负向 → 主角直伤 %d(%d → %d,不经增益状态)" % [backlash_power, hp_before, hp_after])
# blast:负向 = 警觉(敌方护甲等级 +1 percent 行——2026-09-20 伏击退役换装)
if by_type.has("blast"):
rows.clear()
var snap_b: Dictionary = settlement._reward_economy.query_full_snapshot(by_type["blast"])
settlement._grant_routed(snap_b, "negative", "mine_hit", by_type["blast"], 1)
var alert_ok: bool = false
for eff_a in settlement._status_effects.query_active("enemy"):
if String(eff_a.effect_id) == "alert":
alert_ok = true
check(alert_ok, "blast 型负向 → 警觉(敌方护甲等级 +1 状态在位)")
# action:负向 = 下轮行动点 −1 / 正向 = +1(排队,轮开始兑现——八型批 2026-09-20;确定性直验不依赖板面摇号)
rows.clear()
var d0: int = settlement.action_economy.pending_ap_delta()
settlement._grant_routed({"reward_id": "ap_drain", "cell_type": "action", "polarity": "negative", "display_name": "迟滞", "power": 1}, "negative", "mine_hit", Vector2i(0, 0), 1)
check(settlement.action_economy.pending_ap_delta() == d0 - 1, "action 型负向 → 下轮行动点 −1(排队)")
settlement._grant_routed({"reward_id": "ap_up", "cell_type": "action", "polarity": "positive", "display_name": "行动点", "power": 1}, "positive", "safe_open", Vector2i(0, 0), 1)
check(settlement.action_economy.pending_ap_delta() == d0, "action 型正负抵消 → 排队净值归零")
# 资源面断言:chain/dig 事件恒携带 resource=true(雷格同权);initial 恒 false(封锁格);
# 封锁格查询 = null、雷格未开恒可查。
func _smoke_resource_surface() -> void:
print("--- 格面资源资格面(雷格携带 / 封锁格无资源)---")
var run1 = _run_battle(1, 0)
var run2 = _run_battle(2, 0)
var all_events: Array = run1.events + run2.events
var digchain_bad := 0
var initial_bad := 0
var mine_with_resource := 0
var initial_count := 0
for evt in all_events:
if not evt.has("resource"):
continue # 非揭示事件(round_end 标记等)
if String(evt.get("channel", "")) == "initial":
initial_count += 1
if bool(evt.resource):
initial_bad += 1
else:
if not bool(evt.resource):
digchain_bad += 1
if evt.kind == "mine":
mine_with_resource += 1
check(digchain_bad == 0, "chain/dig 揭示事件 resource 恒 true(雷安同池——违例 %d)" % digchain_bad)
check(initial_bad == 0 and initial_count >= 1, "initial 揭示事件(封锁格)resource 恒 false(×%d 事件)" % initial_count)
check(mine_with_resource >= 1, "雷格揭示携带资源(mine ×resource=true %d——雷格不再恒无)" % mine_with_resource)
# 封锁格无身份 / 雷格未开恒可查(未开格恒可查——秘宝 桥接结构前提)
var rng3 = load("res://core/rng_service.gd").new()
rng3.set_master_seed(20260920)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng3)
settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)})
var board = settlement.minesweeper.current_board()
var r0_first: Variant = null
for c in board.compute_r0():
r0_first = c
break
var mine_unrevealed = null
for c in board.mines.keys():
if not settlement.minesweeper.is_revealed(c):
mine_unrevealed = c
break
check(settlement.query_reward_identity(r0_first) == null, "封锁格(R₀)身份查询 = null(不入摇号清单)")
check(mine_unrevealed != null and settlement.query_reward_identity(mine_unrevealed) != null,
"未开雷格身份恒可查({reward_id, cell_type, tier}——秘宝 桥接前提)")
# 五段全序断言(战斗结算 验收"五段全序"+ 行型封闭 10 类):逐轮行相位序 = 授予 → 增益/减益 → 战斗 → 衰减 → 判定。
func _smoke_settlement_segments() -> void:
print("--- 五段全序 + 行型封闭 ---")
check(Array(_db.settlement.segment_order) == SettlementTable.FROZEN_SEGMENT_ORDER
and SettlementTable.FROZEN_SEGMENT_ORDER == ["grant", "buff_debuff", "combat", "decay", "verdict"],
"segment_order 五段冻结在位(表值 = 拍板序恒等)")
var run = _run_battle(1, 0)
var legacy := {"item": 0, "consumed_ineffective": 0, "use_rejected": 0, "window_open": 0, "window_skip": 0}
for row in run.stream:
if legacy.has(String(row.get("type", ""))):
legacy[String(row.get("type", ""))] += 1
var legacy_total := 0
for k in legacy:
legacy_total += int(legacy[k])
check(legacy_total == 0, "行型封闭 10 类(删五类零残留:item/consumed_ineffective/use_rejected/window_open/window_skip)")
var order_ok := true
var decay_verdict_ok := true
var by_round := {}
for row in run.stream:
var ri: int = int(row.get("round_index", 0))
if not by_round.has(ri):
by_round[ri] = []
by_round[ri].append(row)
for ri in by_round.keys():
var phases: Array = []
for row in by_round[ri]:
phases.append(_row_phase(row, ri))
var max_phase := 0
for p in phases:
if int(p) < max_phase:
order_ok = false
max_phase = maxi(max_phase, int(p))
var decays := 0
var verdicts := 0
for row in by_round[ri]:
if String(row.get("type", "")) == "decay":
decays += 1
if String(row.get("type", "")) == "verdict":
verdicts += 1
if decays != 1 or verdicts != 1:
decay_verdict_ok = false # 衰减/判定每结算恰一次
check(order_ok, "每轮行相位单调(授予→增益/减益→战斗→衰减→判定)")
# 案C 段内序(2026-10-06 用户裁):段内伤害序 = 主角先手 →〔连珠矢随先手段〕→ 敌方逐个 → 地图账殿后。
# 地图账 = 全部棋盘来源伤害行 + 主角倒下不兑留痕行;违序(先于先手或插进敌方还手中间)即失败。
var mapdmg_ok := true
for ri_key in by_round.keys():
var rows_r: Array = by_round[ri_key]
var first_hero := -1
var last_enemy := -1
for i in rows_r.size():
var rw: Dictionary = rows_r[i]
if String(rw.get("type", "")) != "damage":
continue
if String(rw.get("source_class", "")) == "hero_attack" and first_hero < 0:
first_hero = i
elif String(rw.get("source_class", "")) == "enemy_action":
last_enemy = i
for i in rows_r.size():
var rw: Dictionary = rows_r[i]
var sc := String(rw.get("source_class", ""))
var is_board_acct: bool = (String(rw.get("type", "")) == "damage" \
and not ["hero_attack", "enemy_action", "fx_streak_segments"].has(sc)) \
or String(rw.get("forfeit", "")) == "hero_down"
if not is_board_acct:
continue
if (first_hero >= 0 and i < first_hero) or (last_enemy >= 0 and i < last_enemy):
mapdmg_ok = false
check(mapdmg_ok, "案C 段内序:地图伤害账殿后(主角先手→敌方逐个→地图账;连珠矢随先手段)")
check(decay_verdict_ok, "衰减/判定段每结算恰一段(先兑付后衰减——含末轮)")
# 行相位归类(五段序断言用):0=授予(含高光/归因)1=增益减益(续存组合行)2=战斗 3=衰减 4=判定
func _row_phase(row: Dictionary, ri: int) -> int:
match String(row.get("type", "")):
"decay":
return 3
"verdict":
return 4
"cast":
return 2
"damage":
return 2 # 案C(2026-10-06 用户裁):全部伤害兑付=战斗段(主角先手/敌方还手/地图账殿后统一结账)
"reward_ineffective":
return 2 if String(row.get("forfeit", "")) == "hero_down" else 0 # 战斗段末地图账不兑留痕 vs 授予段未生效
match String(row.get("source_class", "")):
"safe_cell", "mine_hit", "flag_hit", "flag_miss", "clear_reward":
if int(row.get("round_index", ri)) < ri:
return 1 # 前轮授予实例的续存组合行(增益/减益段)
return 0
_:
return 0
# 清图驱动(真实流):标一面真雷旗(完美前置)→ 逐轮 AutoPlayer 挖到轮末;board_seq 递增 = 清图换图。
# from_refresh=true 时先三轮停手攒 RAP、第 4 轮轮内刷新换图,再清刷新图。
func _run_full_clear(seed_val: int, from_refresh: bool, rows: Array) -> Dictionary:
_db.difficulty.rows.append({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hp_mult": 10.0, "atk_mult": 1.0})
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
settlement.step_row.connect(func(row): rows.append(row))
var res := {"cleared": false, "refreshed": false, "rounds": 0}
if not settlement.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH,
"hero_hp": CLEAR_SMOKE_HERO_HP, "hero_max_hp": CLEAR_SMOKE_HERO_HP}):
_db.difficulty.rows.clear()
return res
if from_refresh:
for i in range(3):
settlement.minesweeper.settle_batch([{"action": "stop"}])
res["refreshed"] = settlement.player_request_refresh()
var drive := _drive_clear(settlement)
res["cleared"] = drive.cleared
res["rounds"] = drive.rounds
_db.difficulty.rows.clear() # 测试台账还原(后续冒烟基线)
return res
func _drive_clear(settlement) -> Dictionary:
var flag_done := false
var rounds := 0
while rounds < 60:
rounds += 1
var ms = settlement.minesweeper
if not flag_done:
var t := _find_flag_target(ms, true)
if not t.is_empty():
if t.hero_cell != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": t.hero_cell}])
ms.settle_batch([{"action": "flag_place", "cell": t.flag_cell}])
flag_done = true # 一面真雷旗 = 完美排雷前半(零误标),后半靠 AutoPlayer 清图
var seq_before: int = int(settlement.diagnostic_snapshot().board_seq)
var out := {"result": "", "rounds": 0, "terminated": false, "stream": []}
_auto_play_round(settlement, out)
if int(settlement.diagnostic_snapshot().board_seq) > seq_before:
return {"cleared": true, "rounds": rounds} # 清图换图 = board_seq 递增
if settlement.battle_result != "":
return {"cleared": false, "rounds": rounds}
return {"cleared": false, "rounds": rounds}
func _rows_of_type(rows: Array, t: String) -> Array:
var out: Array = []
for r in rows:
if String(r.get("type", "")) == t:
out.append(r)
return out
func _has_source_row(rows: Array, t: String, source: String, batch_key: String = "") -> bool:
for r in rows:
if String(r.get("type", "")) != t or String(r.get("source_class", "")) != source:
continue
if batch_key == "" or String(r.get("batch_key", "")) == batch_key:
return true
return false
func _has_effect_id(rows: Array, effect_id: String) -> bool:
for r in rows:
if String(r.get("effect_id", "")) == effect_id:
return true
return false
# 手搓战斗状态袋单点(T6 收口批——7 处字面量收编;_autoplay_battle 直接消费的最小字段族。
# 大字段族同构袋仍走 _adv_st()——_drive_level/_autoplay_battle 全字段消费面)
func _battle_st(policy: String) -> Dictionary:
return {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": policy}
# ==================== 敌人技能池批(2026-09-16 用户拍板——开轮摇号出招系统) ====================
# 六项验收断言(proposal-skill-pool.md〔提案已吸收退役 git a6833b6a——现行事实源=敌人系统.md 技能池节〕 §8 批 1):同种子回放逐位复现 / 单敌轨迹独立复放 /
# EV 抽样对锚 / 死亡打断作废 / 空池不可能 / 加载校验拒绝。
func _smoke_skill_pool() -> void:
print("--- 敌人技能池(开轮摇号 · 冷却/保底两闸 · 三乘法链 · 独立子流)---")
_smoke_skill_pool_data()
_smoke_skill_pool_replay_independence()
_smoke_skill_pool_ev_anchor()
_smoke_skill_pool_death_void()
_smoke_skill_pool_validation_reject()
_smoke_debuff_rider()
# ① 数据面:技能表行集 + 普攻锁死 + 建议池行值 + 空池不可能(结构断言)
func _smoke_skill_pool_data() -> void:
check(_db.skill.rows.size() == 14, "技能表十四行(普攻/突刺/蓄力/重击/硬化 + 撕咬/毒爆/蛛网束缚 + 大地脉动 + 震地怒击/遏毒/岩崩 + 毒吐/盾击——前三批 + Boss 半血解锁技三行〔迭代14④ T5〕+ 普通敌错峰两行〔迭代 24 N2〕)")
var basic: Dictionary = _db.skill.row_by_id("attack")
check(not basic.is_empty() and String(basic.type) == "basic" and int(basic.cooldown_beats) == 0
and (not basic.has("damage_mult") or is_equal_approx(float(basic.damage_mult), 1.0)),
"普攻行:倍率恒 1 · 冷却恒 0(域校验锁死——池恒非空的结构前提)")
var lunge: Dictionary = _db.skill.row_by_id("lunge")
check(not lunge.is_empty() and is_equal_approx(float(lunge.damage_mult), 1.5) and int(lunge.cooldown_beats) == 2,
"突刺行(伤害 ×1.5 · 冷却 2——提案 §2.5 建议池新行)")
var charge_row: Dictionary = _db.skill.row_by_id("charge")
check(not charge_row.is_empty() and String(charge_row.buff_ref) == "empower",
"蓄力行 = 纯攻击增益技(0 伤害、buff_ref → empower——与重击无组合语义)")
var bite: Dictionary = _db.skill.row_by_id("bite")
check(not bite.is_empty() and is_equal_approx(float(bite.damage_mult), 1.5) and int(bite.cooldown_beats) == 1,
"撕咬行(伤害 ×1.5 · 冷却 1——巨蜘蛛池载体,与突刺同倍率错开冷却轴)")
var venom: Dictionary = _db.skill.row_by_id("venom_burst")
check(not venom.is_empty() and is_equal_approx(float(venom.damage_mult), 2.5) and int(venom.cooldown_beats) == 4
and is_equal_approx(float(venom.rider_mult), 0.75),
"毒爆行(伤害 ×2.5 · 冷却 4 · 必中附加 ×0.75——蛛化卓尔精灵专属池独占技,新效应词汇批)")
var venom_sole_ok := true
for row in _db.enemy.rows:
for entry in row.pool:
if String(entry.skill_id) == "venom_burst" and String(row.enemy_id) != "drider":
venom_sole_ok = false
check(venom_sole_ok, "毒爆独占性(仅蛛化卓尔精灵池引用——Boss 专属池独占技纪律)")
# 新效应词汇批(2026-09-19):妨害技首发行 + off_guard 落点 + 实装面收窄
var snare: Dictionary = _db.skill.row_by_id("web_snare")
check(not snare.is_empty() and String(snare.type) == "debuff" and String(snare.buff_ref) == "off_guard"
and int(snare.buff_duration) == 2 and int(snare.cooldown_beats) == 4,
"蛛网束缚行(妨害型 · buff_ref → off_guard 失衡 · duration 2 = 1 有效拍 · 冷却 4——新效应词汇批首发)")
var snare_sole_ok := true
for row in _db.enemy.rows:
for entry in row.pool:
if String(entry.skill_id) == "web_snare" and String(row.enemy_id) != "drider":
snare_sole_ok = false
check(snare_sole_ok, "蛛网束缚独占性(仅蛛化卓尔精灵池引用——实装面收窄,其余敌种不动)")
var off_guard_row: Dictionary = _db.status_effect_row("off_guard")
check(not off_guard_row.is_empty() and String(off_guard_row.target_side) == "hero" and String(off_guard_row.unit) == "ac",
"失衡状态行(hero · ac 轴——妨害技落点,ConfigDB side 守卫正例)")
# 迭代 24 N2(2026-10-13):普通敌错峰两新行——毒吐/盾击(V3)
var spit: Dictionary = _db.skill.row_by_id("venom_spit")
check(not spit.is_empty() and String(spit.display_name) == "毒吐" and String(spit.type) == "damage" \
and is_equal_approx(float(spit.damage_mult), 1.6) and int(spit.cooldown_beats) == 2 and not spit.has("rider_mult"),
"毒吐行(伤害 ×1.6 · 冷却 2 · 无 rider——低 atk 下 rider 恒被 damage_floor 兜成 1 白占字段,迭代 24 N2)")
var bash: Dictionary = _db.skill.row_by_id("shield_bash")
check(not bash.is_empty() and String(bash.display_name) == "盾击" and String(bash.type) == "damage" \
and is_equal_approx(float(bash.damage_mult), 1.7) and int(bash.cooldown_beats) == 3 and not bash.has("rider_mult"),
"盾击行(伤害 ×1.7 · 冷却 3 · 无 rider——迭代 24 N2)")
var spit_sole_ok := true # 毒吐只挂巨蜘蛛族且首遇位 (2,4) 不投(Q6——教学位池零新招)
var bash_sole_ok := true # 盾击只挂灰矮人族三行
for row in _db.enemy.rows:
for entry in row.pool:
var sid := String(entry.skill_id)
if sid == "venom_spit" and not (String(row.enemy_id) == "giant_spider" and int(row.level_index) == 2 and int(row.node_depth) >= 5):
spit_sole_ok = false
if sid == "shield_bash" and not (String(row.enemy_id) == "duergar" and int(row.level_index) == 3):
bash_sole_ok = false
check(spit_sole_ok, "毒吐投放面(仅 (2,5)/(2,6)/(2,7) 巨蜘蛛族——首遇位 (2,4) 池不动,迭代 24 N2/Q6)")
check(bash_sole_ok, "盾击投放面(仅 (3,0)/(3,5)/(3,7) 灰矮人族——钩爪恐魔零触碰,迭代 24 N2)")
# 行集键 = (level_index, node_depth, enemy_id)——分支节点图批起 bugbear 等敌种跨关异池,enemy_id 不再唯一;
# 2026-10-07 行集多行批:同坐标混编多行后每行各一池,键升维到三元组(行序 = 文件序 = 契约序)
var expect_pools := {
"1_0_goblin": {"attack": 9, "lunge": 1},
"1_1_goblin": {"attack": 9, "lunge": 1},
"1_2_bugbear": {"attack": 6, "charge": 2, "heavy": 2},
"1_3_hobgoblin": {"attack": 6, "charge": 2, "heavy": 2},
"1_4_ogre_chief": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2},
"2_0_bugbear": {"attack": 6, "lunge": 1, "heavy": 2},
"2_1_bugbear": {"attack": 6, "lunge": 1, "heavy": 2},
"2_4_giant_spider": {"attack": 6, "bite": 2, "charge": 1},
"2_5_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2},
"2_5_bugbear": {"attack": 5, "lunge": 2, "heavy": 2},
"2_6_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2},
"2_7_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2},
"2_9_drider": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2},
"3_0_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2},
"3_1_hook_horror": {"attack": 6, "bite": 2, "charge": 1},
"3_4_hook_horror": {"attack": 6, "bite": 2, "charge": 1},
"3_5_duergar": {"attack": 5, "lunge": 2, "heavy": 2, "shield_bash": 2},
"3_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1},
"3_7_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2},
"3_9_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
}
var pools_ok := true
for row in _db.enemy.rows:
var rk := "%d_%d_%s" % [int(row.level_index), int(row.node_depth), String(row.enemy_id)]
if not expect_pools.has(rk):
pools_ok = false
continue
var got := {}
for entry in row.pool:
got[String(entry.skill_id)] = int(entry.weight)
if got != expect_pools[rk]:
pools_ok = false
check(pools_ok, "二十行九种池模板 = 建议池行值((L,D,enemy_id) 三元键逐行精确比对——2026-10-07 行集多行批口径;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔(2,5)-(2,7) 投放,首遇位 (2,4) 不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔(2,9)——商店房批 Boss 重编号〕;L3 灰矮人 6/2/2+盾击2〔(3,0)/(3,7)〕 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse·(3,9)〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同坐标多行各一池合法——首批投放 (2,5) 双行〔迭代 18 卡⑤:重击手+巢群,新行行值复制 (2,6)〕,其余坐标仍恰一行)")
var basic_count_ok := true
for row in _db.enemy.rows:
var n := 0
for entry in row.pool:
var srow: Dictionary = _db.skill_row(String(entry.skill_id))
if not srow.is_empty() and String(srow.type) == "basic":
n += 1
if n != 1:
basic_count_ok = false
check(basic_count_ok, "每池恰一行普攻(空池不可能的加载校验同款)")
# 迭代 24 N2 复查(V4):池构成纪律零违反——增益 ≤1 / 妨害 ≤1(敌人系统 v0.13 结构冻结;载入期
# validate_skill_pool_links 已硬闸,此处冒烟侧显式复核新投放后仍绿)
var comp_ok := true
for row in _db.enemy.rows:
var n_buff := 0
var n_debuff := 0
for entry in row.pool:
var srow: Dictionary = _db.skill_row(String(entry.skill_id))
if srow.is_empty():
continue
if String(srow.type) == "buff":
n_buff += 1
elif String(srow.type) == "debuff":
n_debuff += 1
if n_buff > 1 or n_debuff > 1:
comp_ok = false
check(comp_ok, "逐池增益 ≤1 · 妨害 ≤1(池构成纪律——迭代 24 N2 毒吐/盾击投放后复查)")
# Boss 半血解锁技·编排集成断言(迭代14④ T6——真实编排回路:半血 → 下轮开轮闩检查 → 解锁行恰一次)
var st_unlock: Dictionary = {"rows": []}
var sett_u = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(4242))
sett_u.step_row.connect(func(row): st_unlock["rows"].append(row))
check(sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp)}), "前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)")
var boss_u = sett_u._enemy_system.all_enemies()[0] # 白盒夹具(smoke 惯例——autoplay 同族私有面)
sett_u._enemy_system.apply_damage(boss_u, (int(boss_u.max_hp) - 65))
check(int(boss_u.hp) == 65, "前置:Boss 压到阈值 65(=round(130×0.5))")
sett_u.minesweeper.settle_batch([{"action": "stop"}]) # 收轮 → 结算 → 次轮开轮闩检查
var unlock_rows: Array = []
for r in st_unlock["rows"]:
if String(r.get("highlight", "")) == "skill_unlock":
unlock_rows.append(r)
check(unlock_rows.size() == 1, "解锁归因行恰一次(×%d——闩去重,二次检查零行)" % unlock_rows.size())
if unlock_rows.size() == 1:
check(String(unlock_rows[0].get("display_name", "")) == "蛛化卓尔精灵 解锁了 遏毒",
"解锁行文本 =「蛛化卓尔精灵 解锁了 遏毒」(高光键 skill_unlock——呈现层一次性技能名 toast 路由键)")
check(String(unlock_rows[0].get("type", "")) == "highlight", "解锁行型 = highlight 既有行型(零行型新增——行协议封闭)")
var rollable_ok := true
for depth in [0, 1, 2, 3, 4]:
var es = load("res://core/unit_container.gd").new(_db)
es.spawn(1, int(depth), int(_db.hero.max_hp))
for u in es.all_enemies():
if es.rollable_skills(u).is_empty():
rollable_ok = false
for depth in [0, 1, 4, 5, 6, 7, 8]:
var es2 = load("res://core/unit_container.gd").new(_db)
es2.spawn(2, int(depth), int(_db.hero.max_hp))
for u in es2.all_enemies():
if es2.rollable_skills(u).is_empty():
rollable_ok = false
check(rollable_ok, "全深度实例 spawn 后可摇集合恒非空(普攻恒冷却 0——容器级断言,L1/L2 双关卡)")
# 状态表新行 + 敌方防御池收编
var empower: Dictionary = _db.status_effect_row("empower")
var fortify: Dictionary = _db.status_effect_row("fortify")
check(not empower.is_empty() and String(empower.target_side) == "enemy" and int(empower.magnitude) == 100,
"蓄力状态行 empower(enemy +100 percent——敌方输出 ×2 走既有 enemy_output 池)")
check(not fortify.is_empty() and String(fortify.target_side) == "enemy" and int(fortify.magnitude) == -50,
"硬化状态行 fortify(enemy −50 percent——敌方防御池负值 = 主角攻击折半;+100 符号 bug 2026-09-18 纠正,provisional 归 状态层重设计批)")
check(String(_db.settlement.effect_pool_map.get("empower", "")) == "enemy_output" \
and String(_db.settlement.effect_pool_map.get("fortify", "")) == "enemy_defense",
"effect_pool_map 收编 empower/fortify(敌方防御池 enemy_defense 入表)")
# ② 同种子回放逐位复现(D3 大地精——原熊地精双雄,多实例同种)
# ③「单敌轨迹独立复放」随并流批退役(2026-09-27 统一流裁决):子流派生向量退役后单敌轨迹无法
# 脱离全序重建(位置与其它流取数交错);同种子全战斗逐位复现由 ② 全对拍承载,单点定位归事件日志。
func _smoke_skill_pool_replay_independence() -> void:
var seed_val := -1
for s in range(1, 60):
var seqs := _record_pool_trajectories(s, 1)
if not seqs.is_empty() and int(seqs.count) >= 2:
seed_val = s
break
check(seed_val > 0, "多实例种子可达(seed=%d——D3 遭遇 ≥2 实例)" % seed_val)
# 数值压缩批(2026-09-17):敌 hp 18→6 后 hero_atk=1 十轮内必然击杀(实例错位、轨迹断裂)——
# 观测窗 10 轮→5 轮(5 拍 ×1 伤 < 6 HP 全员存活;仍覆盖保底 M=4 的触发窗)。
var rounds := 5
var run1 := _record_pool_trajectories(seed_val, rounds)
var run2 := _record_pool_trajectories(seed_val, rounds)
check(not run1.is_empty() and JSON.stringify(run1.seqs) == JSON.stringify(run2.seqs),
"同种子回放:各实例摇定动作序列逐位相同(×%d 轮 × %d 实例——子流独立复放面随并流退役)" % [rounds, int(run1.count)])
# 多敌战逐轮记录各实例摇定动作(hero_atk=1/hp=300 构造全员存活满 N 轮——轨迹长度 = 轮数)
func _record_pool_trajectories(seed_val: int, rounds: int) -> Dictionary:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
if not settlement.begin_battle({"level_index": 1, "node_depth": 3, "hero_hp": 300, "hero_max_hp": 300, "hero_atk": 1}):
return {}
var seqs: Array = []
for u in settlement._enemy_system.all_enemies():
seqs.append([])
for r in rounds:
var handles: Array = settlement._enemy_system.all_enemies()
for i in handles.size():
seqs[i].append(settlement._enemy_system.pending_action(handles[i]))
settlement.minesweeper.settle_batch([{"action": "stop"}])
if settlement.battle_result != "":
return {}
return {"count": seqs.size(), "seqs": seqs}
# ④ EV 抽样对锚(含闸长期速率 vs 静态锚——proposal §2.5;精确重锚归 难度曲线与Boss)。
# 带宽口径:熊地精/酋长 EV 结构性恒 1(普攻 1 + 重击 2 与蓄力/硬化 0 加权守恒)→ 窄带;
# 地精突刺池受保底闸抬升(唯一特殊技 w=1/10,保底 M=4 把突刺率从自然 10% 抬到 ~20%+)→ 宽带登记。
func _smoke_skill_pool_ev_anchor() -> void:
var bands := {"goblin": [1.00, 1.20], "bugbear": [0.97, 1.03], "ogre_chief": [1.10, 1.30]} # 酋长重锚〔迭代14④ T5——震地怒击半血解锁入池,含闸长期速率上移:实测 1.1948;EV 锚批注 enemy.tres 同批换代〕
var species := ["goblin", "bugbear", "ogre_chief"]
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(20260921)
var n_beats := 5000
var pity_m: int = int(_db.skill.pity_threshold)
for si in species.size():
var eid: String = species[si]
var template: Array = []
for row in _db.enemy.rows:
if String(row.enemy_id) == eid:
template = row.pool
break
var pool: Array = []
for entry in template:
pool.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight), "cooldown_left": 0})
var streak := 0
var ev_sum := 0.0
for k in n_beats:
var entries: Array = []
for e in pool:
if int(e.cooldown_left) <= 0:
entries.append({"skill_id": e.skill_id, "weight": e.weight})
if streak >= pity_m:
var specials: Array = []
for e in pool:
var sr: Dictionary = _db.skill_row(String(e.skill_id))
if not sr.is_empty() and String(sr.type) != "basic":
specials.append({"skill_id": e.skill_id, "weight": e.weight})
if not specials.is_empty():
entries = specials
var sid := SkillLottery.draw(entries, rng.open_stream("behavior_roll"))
var srow: Dictionary = _db.skill_row(sid)
var mult := 0.0
if String(srow.type) == "basic":
mult = 1.0
elif String(srow.type) == "damage":
mult = float(srow.get("damage_mult", 1.0))
ev_sum += mult
for e in pool:
e.cooldown_left = maxi(0, int(e.cooldown_left) - 1)
if String(e.skill_id) == sid:
e.cooldown_left = int(srow.cooldown_beats)
streak = streak + 1 if String(srow.type) == "basic" else 0
var measured: float = ev_sum / float(n_beats)
var band: Array = bands[eid]
var in_band: bool = measured >= float(band[0]) and measured <= float(band[1])
check(in_band, "EV 抽样对锚 %s(实测 %.4f ∈ [%.2f, %.2f] 含闸长期速率,×%d 拍)" % [eid, measured, float(band[0]), float(band[1]), n_beats])
# ⑤ 死亡打断作废(摇定后、兑现前死亡 → 本拍技能作废、不动手、不进账)
func _smoke_skill_pool_death_void() -> void:
var void_seed := -1
for s in range(1, 400):
var rg = load("res://core/rng_service.gd").new()
rg.set_master_seed(s)
var erow: Dictionary = _db.resolve_enemy_rowset(1, 2)[0] # 行集首行(T2 机械适配)
var entries: Array = []
for entry in erow.pool:
entries.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight)})
var sid := SkillLottery.draw(entries, rg.open_stream("behavior_roll"))
if String(_db.skill_row(sid).type) != "basic":
void_seed = s
break
check(void_seed > 0, "死亡打断种子可达(seed=%d——D2 首拍摇中特殊技)" % void_seed)
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(void_seed)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
check(settlement.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}),
"死亡打断战斗启动(hero_atk 999——先手必杀 40HP 熊地精)")
var u: Dictionary = settlement._enemy_system.all_enemies()[0] # 句柄预捕获(胜利 despawn 后容器已清,字典引用仍可读)
var pending: String = settlement._enemy_system.pending_action(u)
check(pending != "", "开轮已摇定本拍动作(待发 = %s)" % pending)
settlement.minesweeper.settle_batch([{"action": "stop"}])
var enemy_rows := 0
for row in rows:
if String(row.get("source_class", "")) == "enemy_action":
enemy_rows += 1
check(enemy_rows == 0, "先手击杀 → 摇定技能作废(第 1 轮零敌方行动行——死亡即不动手)")
var cd_sum := 0
for e in u.pool:
cd_sum += int(e.cooldown_left)
check(cd_sum == 0 and int(u.basic_streak) == 0,
"作废不进账(全池冷却 0 · 保底计数 0——执行上报未发生)")
# ⑥ 加载校验拒绝(单表域校验 + 跨表池构成纪律)
func _smoke_skill_pool_validation_reject() -> void:
var skill_table = load("res://data/schemas/skill_table.gd").new()
var enemy_table = load("res://data/schemas/enemy_table.gd").new()
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "ghost", "weight": 1}]}])
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "dangling_skill"),
"加载校验拒绝:池条目 skill_id 悬空致命")
skill_table.rows.assign([{"skill_id": "a", "display_name": "a", "type": "damage", "damage_mult": 1.5, "cooldown_beats": 2}])
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"),
"加载校验拒绝:池普攻行数 ≠ 1(空池不可能前提)")
skill_table.rows.assign([
{"skill_id": "b1", "display_name": "b1", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 3},
{"skill_id": "b2", "display_name": "b2", "type": "buff", "buff_ref": "fortify", "buff_duration": 2, "cooldown_beats": 4}])
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "b1", "weight": 1}, {"skill_id": "b2", "weight": 1}]}])
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"),
"加载校验拒绝:池增益技 > 1(每池增益 ≤1 纪律)")
skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "damage_mult": 2.0, "cooldown_beats": 0}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:普攻配倍率 ≠ 1(域校验锁死)")
skill_table.rows.assign([{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 2}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:普攻配冷却 ≠ 0(域校验锁死)")
skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 30.0, "cooldown_beats": 1}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:damage_mult 越域 [1.1,16]")
var et2 = load("res://data/schemas/enemy_table.gd").new()
et2.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "a", "weight": 0}]}])
check(not et2.validate().is_empty(), "加载校验拒绝:池权重 ≤ 0")
et2.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": []}])
check(not et2.validate().is_empty(), "加载校验拒绝:空池(禁)")
# 新效应词汇批(2026-09-19):妨害 / 必中附加分量校验组
skill_table.rows.assign([
{"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4},
{"skill_id": "w2", "display_name": "w2", "type": "debuff", "buff_ref": "off_guard", "buff_duration": 2, "cooldown_beats": 4}])
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "w1", "weight": 1}, {"skill_id": "w2", "weight": 1}]}])
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table), "pool_composition"),
"加载校验拒绝:池妨害技 > 1(每池妨害 ≤1 纪律)")
skill_table.rows.assign([{"skill_id": "w1", "display_name": "w1", "type": "debuff", "cooldown_beats": 4}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:妨害技缺 buff_ref(效果引用)")
skill_table.rows.assign([{"skill_id": "d", "display_name": "d", "type": "damage", "damage_mult": 1.5, "rider_mult": 1.5, "cooldown_beats": 1}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:rider_mult 越域 (0,1]")
skill_table.rows.assign([{"skill_id": "b", "display_name": "b", "type": "buff", "buff_ref": "empower", "buff_duration": 2, "rider_mult": 0.5, "cooldown_beats": 1}])
check(not skill_table.validate().is_empty(), "加载校验拒绝:非伤害技带必中附加分量")
# 施放类 side 守卫:妨害技引用敌方侧状态 → 拒绝(增益技引用主角侧同罪,本组以妨害反例覆盖)
skill_table.rows.assign([
{"skill_id": "atk", "display_name": "a", "type": "basic", "cooldown_beats": 0},
{"skill_id": "w1", "display_name": "w1", "type": "debuff", "buff_ref": "empower", "buff_duration": 2, "cooldown_beats": 4}])
enemy_table.rows.assign([{"enemy_id": "x", "display_name": "x", "pool": [{"skill_id": "atk", "weight": 1}, {"skill_id": "w1", "weight": 1}]}])
check(_has_err_category(_db.validate_skill_pool_links(skill_table, enemy_table, _db.status_effects), "cast_side"),
"加载校验拒绝:妨害技引用敌方侧状态(施放类 side 守卫)")
func _has_err_category(errs: Array, category: String) -> bool:
for e in errs:
if String(e.get("category", "")) == category:
return true
return false
# ⑦ 新效应词汇批实装探针(2026-09-19——妨害技蛛网束缚 + 毒爆必中附加分量,实装面 = L2 Boss 卓尔):
# 停手轮驱动 (2,9)——零挖掘零旗 → 零池,伤害行可逐拍精确复算;种子 hunt 至少各命一粒毒爆伤害行
# 与蛛网束缚施放行;毒分量随开战掷定的攻击基线缩放(atk 区间 2–4 → rider 1–3)。
# (2026-09-21 商店房批连带:Boss 重编号 8→9——(2,8) 现为商店房无战斗行,驱动点随行迁移。)
func _smoke_debuff_rider() -> void:
var venom_seed := -1
var venom_check_ok := true
var venom_rows_seen := 0
var snare_seed := -1
var base_ac: int = int(_db.hero.armor_class)
var off_m: int = int(_db.status_effect_row("off_guard").magnitude)
var floor_v: int = int(_db.settlement.damage_floor)
for s in range(1, 401):
if venom_seed > 0 and snare_seed > 0:
break
var rg = load("res://core/rng_service.gd").new()
rg.set_master_seed(s)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rg)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
if not settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp)}):
continue
var boss_atk: int = int(settlement._enemy_system.all_enemies()[0].atk) # 开战掷定攻击基线(毒分量随其缩放)
for i in range(6):
if settlement.battle_result != "":
break
settlement.minesweeper.settle_batch([{"action": "stop"}])
var has_venom := false
var has_snare := false
var snare_round := -1
for row in rows:
if String(row.get("type", "")) != "damage" or String(row.get("source_class", "")) != "enemy_action":
if String(row.get("type", "")) == "cast" and String(row.get("skill_id", "")) == "web_snare":
has_snare = true
snare_round = int(row.get("round_index", -1))
continue
if String(row.get("skill_id", "")) == "venom_burst":
has_venom = true
venom_rows_seen += 1
var rider_v: int = int(row.get("rider", -1))
if rider_v != maxi(floor_v, int(floor(float(boss_atk) * 0.75))):
venom_check_ok = false
# 终值精确复算(停手轮池恒空):主分量链结果 + 必中附加后置
var base_factor: float = float(int(row.tier)) * (float(_db.settlement.crit_multiplier) / 100.0 if bool(row.get("crit", false)) else 1.0)
var rolled_main: int = int(floor(float(int(row.baseline)) * base_factor / 100.0))
var expected: int = maxi(floor_v, rolled_main + int(row.get("attach", 0))) + rider_v
if int(row.value) != expected:
venom_check_ok = false
elif row.has("rider"):
venom_check_ok = false # rider 键只允许出现在带 rider_mult 的技能行
if has_snare:
# 施放后主角掷点阈值下调(off_guard ac 轴):同轮起的伤害行阈值读数 = 基准 AC + 失衡幅度。
# 只取"可观测"种子(施放轮后仍有敌方伤害行——施放在末轮随战斗终局属合法不可观测,不作失败)。
var seen_lowered := false
for row in rows:
if String(row.get("type", "")) != "damage" or String(row.get("source_class", "")) != "enemy_action":
continue
if int(row.get("round_index", -1)) >= snare_round and int(row.get("ac", -999)) == base_ac + off_m:
seen_lowered = true
break
if seen_lowered and snare_seed < 0:
snare_seed = s
if has_venom and venom_seed < 0:
venom_seed = s
check(venom_seed > 0 and venom_rows_seen >= 1, "毒爆必中附加分量种子可达(seed=%d,毒爆伤害行 ×%d)" % [venom_seed, venom_rows_seen])
check(venom_check_ok, "毒爆伤害行逐拍精确复算:终值 = max(1, 掷点档位主分量) + 必中附加(rider 只随 rider_mult 行出现)")
check(snare_seed > 0, "蛛网束缚落主角:施放后掷点阈值 = 基准 AC + 失衡幅度(off_guard ac 轴端到端,seed=%d)" % snare_seed)
# ---- 分支节点图批冒烟:图合法性校验 + L2 幽暗地域图版行进(2026-09-17——关卡节点地图 v1.0 图版落地)----
func _smoke_level_graph() -> void:
print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---")
var autoplay = load("res://smoke/level_autoplay.gd")
# ① L1 单链与 L2 图经同一套加载校验(单链 = 出边恰一的退化形态——load_tables 全绿已含,显式复述)
check(_db.level.validate().is_empty(), "LevelTable 图合法性校验:L1 单链 + L2 图同套算法零错误")
# ② L2 行集结构(网状图建议规模)
var l2 := {}
for row in _db.level.rows:
if int(row.level_index) == 2:
l2 = row
check(not l2.is_empty() and _level_active_nodes(2).size() == 10, "L2 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——商店房批 Boss 重编号 8→9)")
var tcount := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0}
var branch := 0
for node in _level_active_nodes(2):
tcount[String(node.get("type", ""))] = int(tcount[String(node.get("type", ""))]) + 1
if node.get("next", []).size() >= 2:
branch += 1
check(int(tcount.battle) == 6 and int(tcount.boss) == 1, "L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)")
check(int(tcount.reward) == 1 and int(tcount.rest) == 1 and int(tcount.shop) == 1, "L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)")
check(branch >= 2, "L2 真实岔路 = %d 处(≥2)" % branch)
# ③ 图校验正反断言(违规行集定位到类别——加载期致命的同款算法)
var lt = load("res://data/schemas/level_table.gd").new()
var good := [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [2]},
{"node_depth": 2, "type": "boss"}]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": good}])
check(lt.validate().is_empty(), "图校验正例:两岔合流小图零错误")
var dup = JSON.parse_string(JSON.stringify(good))
dup[1].node_depth = 0
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dup}])
check(_has_err_category(lt.validate(), "dup_key"), "图校验拒绝:节点编号重复(关卡内唯一定位地址)")
var dual = JSON.parse_string(JSON.stringify(good))
dual.append({"node_depth": 9, "type": "battle", "next": [2]})
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dual}])
check(_has_err_category(lt.validate(), "structural"),
"图校验拒绝:第二无入边节点(入口唯一违例——入口节点数 = 3)")
var boss_out = JSON.parse_string(JSON.stringify(good))
boss_out[2].next = [1]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": boss_out}])
check(_has_err_category(lt.validate(), "structural"), "图校验拒绝:Boss 带出边(终点不可外出)")
var cyc = JSON.parse_string(JSON.stringify(good))
cyc[1].next = [0]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": cyc}])
check(_has_err_category(lt.validate(), "structural"), "图校验拒绝:回边 1→0(连线只朝前进方向 ⇒ 无环)")
var dang = JSON.parse_string(JSON.stringify(good))
dang[0].next = [1, 7]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dang}])
check(_has_err_category(lt.validate(), "dangling_next"), "图校验拒绝:出边指向不存在编号(悬空致命)")
var orphan = JSON.parse_string(JSON.stringify(good))
orphan[0].next = [1]
orphan[1].next = [2]
orphan.append({"node_depth": 5, "type": "battle", "next": [2]})
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": orphan}])
var orphan_errs: Array = lt.validate()
check(_has_err_category(orphan_errs, "structural"), "图校验拒绝:孤儿节点(从入口不可达 / 双入口同体违例)")
# ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开)
# 并流批(2026-09-27 统一流裁决)后战斗随机值面漂移——max 路径种子改行为面扫描(路线形状/商店/选路口为断言本体)
var win_min = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "min", 0)
check(win_min.result == "victory", "L2·满配 min 路径通关(实测:%s)" % win_min.result)
check(win_min.boss_depth == 9, "L2 Boss 以编号 9 解析(终点编号最大——商店房批重编号 8→9)")
check(win_min.visited_ids == [0, 1, 3, 7, 8, 9], "min 路径 = 0→1→3(休整)→7→8(商店零购离开)→9(实测 %s——T17 出度≤2 拍板模板 0 重建)" % str(win_min.visited_ids))
check(win_min.choices >= 3, "选路口发生 %d 次(每节点兑付后——零自动推进)" % win_min.choices)
# max 路径 = 存储种子(T17 改造——废除逐种子扫描):本项目核心承诺=确定性复放(同代码同表
# 同种子逐位一致),「能赢且路径符合的种子」是恒定事实,采一次存死即可。旧形态 range(42,300)
# 逐种子全关卡通关扫描找命中,期望路径过期时 258 次模拟撞 180s 闸(台账 257 + 2026-10-02
# 出度≤2 重建两次实证)。存储种子常态一次跑完;图表/数值有意变更致本断言红=快速失败(一次
# 即红非扫描超时),重采=临时脚本跑同款 run_level 循环取首命中种子回填(离线一次性)。
const MAX_PATH_SEED := 43 # 2026-10-05 判式换代批重采(旗溢价 δ=1.25 落地致种子 42 战局位移=败于终点;同款循环首命中——断言注释既定程序,临时脚本用后即删)
var win_max: Dictionary = autoplay.run_level(_db, MAX_PATH_SEED, 2, 200, 100, false, null, "normal", "max", 0)
check(win_max.result == "victory" and win_max.visited_ids == [0, 2, 6, 7, 8, 9],
"L2·满配 max 路径(max 取最大编号候选)通关 = 0→2(奖励)→6→7→8(商店零购离开)→9(存储种子 %d——T17 出度≤2 拍板模板 0 重建)" % MAX_PATH_SEED)
# ⑤ 零自动推进状态机(手动驱动——兑付确认 / 选路拒绝语义逐拍断言)
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42))
runner.set_hero_override(200, 100)
var choice_sizes: Array = []
runner.choices_presented.connect(func(c): choice_sizes.append(c.size()))
check(runner.start_level(2, 42, {}, 0), "手动驱动:L2 启动(入口战斗直接开打——入口无前置兑付;template_pick=0 固定现行图——精确选路断言)")
var st = _battle_st("normal")
var first = runner.current_battle()
var res_box := {} # 信号写回容器(lambda 按值捕获局部变量——Dictionary 引用可变)
first.battle_finished.connect(func(p): res_box["result"] = String(p.get("result", "")))
while runner.current_battle() == first and not res_box.has("result"):
autoplay._autoplay_battle(runner, first, st, null)
check(String(res_box.get("result", "")) == "victory" and runner.current_battle() == null and runner.awaiting_choice(),
"首战胜利后:settlement 释放 + 进选路态(战斗胜利不再自动推进——接口漂移清偿)")
check(runner.next_candidate_ids() == [1, 2], "入口岔路候选 = [1, 2](T17 出度≤2 拍板模板 0 宽廊双干——层宽 4 上限随出度约束)")
check(not runner.pick_next_node(8) and runner.awaiting_choice(), "候选外编号拒绝且仍待选(拒绝语义)")
check(runner.pick_next_node(2), "选编号 2(奖励点)受理")
check(String(runner.current_node().get("type", "")) == "reward" and not runner.awaiting_choice(),
"到达奖励点:直通兑付(确认前不出选路口)")
check(runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [5, 6],
"兑付确认后才出现选路口(continue_after_node 泛化语义)候选 = [5, 6]")
check(runner.pick_next_node(6) and runner.current_battle() != null, "选编号 6(巨蜘蛛巢群)开战")
# ⑥ L2 锚行首遇战:基线 0 加成 · 完美档(零盲挖 = 零踩雷上限口径)多种子扫描
# (Package B 第 7 轮阶段拔高后锚行 45→50 退出绿档——13/12 全胜改多数胜 + 零踩雷恒要求,
# 2026-09-23 拍板 B 接受现曲线;还原绿档需回调锚行血量,归用户决策)
var green_ok := true
var green_min_hp := 99999
var green_wins := 0
for i in range(12):
var g_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i))
g_runner.start_level(2, 9100 + i)
var g_st = _battle_st("perfect")
var g_first = g_runner.current_battle()
var g_box := {}
g_first.battle_finished.connect(func(p):
g_box["result"] = String(p.get("result", ""))
g_box["hero_hp"] = int(p.get("hero_hp", 0)))
var g_guard := 6
while g_runner.current_battle() == g_first and not g_box.has("result") and g_guard > 0:
g_guard -= 1
autoplay._autoplay_battle(g_runner, g_first, g_st, null)
if int(g_st.mine_hits) > 0:
green_ok = false
if String(g_box.get("result", "")) == "victory":
green_wins += 1
if int(g_box.get("hero_hp", 0)) > 0:
green_min_hp = mini(green_min_hp, int(g_box.hero_hp))
check(green_ok, "首遇位零踩雷:完美档 12 种子(节点 0 熊地精)恒零踩雷")
check(green_wins >= 7, "首遇战胜局 ≥7/12(实际 %d——阶段拔高后锚行退出绿档,多数胜口径)" % green_wins)
check(green_min_hp > 0, "首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green_min_hp)
# ⑦ 跨表行完整:L2 每战斗/Boss 节点 (2, 编号) 在 board_gen 与 enemy 精确行集(加载校验同款显式复述;
# 2026-10-07 行集多行批:「精确行」口径改「精确行集」——enemy 侧同坐标多行合法,键聚合坐标级天然兼容)
var bg_keys := {}
for row in _db.board_gen.rows:
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var en_keys := {}
for row in _db.enemy.rows:
en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var cross_ok := true
for node in _level_active_nodes(2):
var t := String(node.get("type", ""))
if t != "battle" and t != "boss":
continue
var key := "2_%d" % int(node.node_depth)
if not bg_keys.has(key) or not en_keys.has(key):
cross_ok = false
check(cross_ok, "跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点)")
# ⑦a 定点:L2 首遇行集与 Boss 行集(2026-10-07 行集多行批口径——断言行集构成而非单行:
# 零内容期每坐标恰一行 = 单元素行集;行序 = 文件序,首行即叙述取数面)
var l2_spider_set: Array = _db.resolve_enemy_rowset(2, 4)
var l2_spider_ids := {}
for r in l2_spider_set:
l2_spider_ids[String(r.get("enemy_id", ""))] = true
check(l2_spider_ids.has("giant_spider") and l2_spider_ids.size() == 1 and l2_spider_set.size() == 1,
"巨蜘蛛首遇行集 (2,4) = {giant_spider} 恰一行(固定 2 只——首遇教学位不加骰;行集口径断言——行集构成纪律下单行集 = 现行形态)")
var l2_boss_set: Array = _db.resolve_enemy_rowset(2, 9)
var l2_boss_ids := {}
for r in l2_boss_set:
l2_boss_ids[String(r.get("enemy_id", ""))] = true
check(l2_boss_ids.has("drider") and l2_boss_ids.size() == 1 and String(l2_boss_set[0].get("armor_type", "")) == "ward",
"蛛化卓尔精灵 Boss 行集 (2,9) = {drider} 恰一行且首行魔法甲(护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲;商店房批重编号 (2,8)→(2,9);P4 乙 Boss 行集多行合法——零内容期仍单行)")
# ⑦b 定点:首批混编遭遇行集 (2,5)(2026-10-07 迭代 18 卡⑤投放——R6 行序契约运行面:
# 行集两行文件内相邻、行序 = 声明序 = 生成序;静态最坏骰和 Σ(unit_count_max,固定行缺省 = unit_count) ≤ 4)
var l2_mix_set: Array = _db.resolve_enemy_rowset(2, 5)
var mix_shape_ok: bool = l2_mix_set.size() == 2 \
and String(l2_mix_set[0].get("enemy_id", "")) == "bugbear" \
and String(l2_mix_set[1].get("enemy_id", "")) == "giant_spider"
check(mix_shape_ok,
"首批混编行集 (2,5) = 双行且行序 [bugbear 重击手 → giant_spider 巢群](R6 行序契约——文件序 = 声明序 = 生成序,首行默认目标 = 前排坦克;迭代 18 卡⑤投放)")
var mix_sum_max := 0
for r in l2_mix_set:
mix_sum_max += int(r.get("unit_count_max", int(r.get("unit_count", 1))))
check(mix_sum_max <= 4,
"首批混编行集 (2,5) 静态最坏骰和 = Σ(unit_count_max,固定行缺省=unit_count) = %d ≤ 4(行集实例总数守卫同口径——重击手固定 1 + 巢群 [2,3])" % mix_sum_max)
check(int(l2_mix_set[1].get("unit_count", 0)) == 2 and int(l2_mix_set[1].get("unit_count_max", 0)) == 3 \
and int(l2_mix_set[1].get("hp", 0)) == 26 and int(l2_mix_set[1].get("armor_class", -1)) == 2,
"首批混编新行 (2,5,giant_spider) 行值 = 复制 (2,6) 巢群行(P4 甲零数值新增——2-3 只/hp 26/atk [1,2]/light/ac 2;既有 19 行现值零变更)")
# ---- 第三关内容批(2026-09-20)L3 灰矮人矿城断言段 ----
# ⑧ L3 行集结构(复用 L2 九节点图模板——6 战 + Boss + 奖励 + 恢复,真实岔路 4 处)
var l3 := {}
for row in _db.level.rows:
if int(row.level_index) == 3:
l3 = row
check(not l3.is_empty() and _level_active_nodes(3).size() == 10, "L3 节点数 = 10(入口 1 + 中间三层 2/3/2 + 商店房 + Boss——L2 同模板 + 商店房批)")
var tcount3 := {"battle": 0, "reward": 0, "rest": 0, "boss": 0, "shop": 0}
var branch3 := 0
for node in _level_active_nodes(3):
tcount3[String(node.get("type", ""))] = int(tcount3[String(node.get("type", ""))]) + 1
if node.get("next", []).size() >= 2:
branch3 += 1
check(int(tcount3.battle) == 6 and int(tcount3.boss) == 1, "L3 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)")
check(int(tcount3.reward) == 1 and int(tcount3.rest) == 1 and int(tcount3.shop) == 1, "L3 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)")
check(branch3 == 3, "L3 真实岔路 = %d 处(0→[1,2,3,4,5] · 2→[6,7] · 5→[6,7]——U2 返工批五岔环廊层宽 5)" % branch3)
# ⑨ 跨表行完整:L3 每战斗/Boss 节点 (3, 编号) 在 board_gen 与 enemy 精确行集(⑦ 的 bg_keys/en_keys 复用)
var cross_ok3 := true
for node in _level_active_nodes(3):
var t3 := String(node.get("type", ""))
if t3 != "battle" and t3 != "boss":
continue
var key3 := "3_%d" % int(node.node_depth)
if not bg_keys.has(key3) or not en_keys.has(key3):
cross_ok3 = false
check(cross_ok3, "跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点)")
# ⑩ 定点:L3 首遇行集与 Boss 行集(克制面重轻错开——重甲灰矮人首次登台;行集口径断言同 ⑦a)
var l3_lead_set: Array = _db.resolve_enemy_rowset(3, 0)
var l3_lead_ids := {}
for r in l3_lead_set:
l3_lead_ids[String(r.get("enemy_id", ""))] = true
check(l3_lead_ids.has("duergar") and l3_lead_ids.size() == 1 and String(l3_lead_set[0].get("armor_type", "")) == "heavy",
"灰矮人首遇行集 (3,0) = {duergar} 恰一行且首行重甲(重甲首次登台——固定 1 只,首遇教学位不加骰)")
var l3_king_set: Array = _db.resolve_enemy_rowset(3, 9)
var l3_king_ids := {}
for r in l3_king_set:
l3_king_ids[String(r.get("enemy_id", ""))] = true
check(l3_king_ids.has("duergar_king") and l3_king_ids.size() == 1 and String(l3_king_set[0].get("armor_type", "")) == "heavy",
"灰矮人石君王 Boss 行集 (3,9) = {duergar_king} 恰一行且首行重甲(重甲收束「轻甲→魔法甲→重甲」教学梯度;商店房批重编号 (3,8)→(3,9))")
# ⑪ L3 成长代理档首遇绿档:模拟 GP 商店成长后的入关参数(atk_pct=200 / hp_pct=100)· 完美档多种子首遇战稳过
# (零加成基线红档属预期——断言口径 = 成长代理档,非基线;跑法同 ⑥)
var green3_ok := true
var green3_min_hp := 99999
for i in range(12):
var g3_runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(9100 + i))
g3_runner.set_hero_override(200, 100)
g3_runner.start_level(3, 9100 + i)
var g3_st = _battle_st("perfect")
var g3_first = g3_runner.current_battle()
var g3_box := {}
g3_first.battle_finished.connect(func(p):
g3_box["result"] = String(p.get("result", ""))
g3_box["hero_hp"] = int(p.get("hero_hp", 0)))
var g3_guard := 6
while g3_runner.current_battle() == g3_first and not g3_box.has("result") and g3_guard > 0:
g3_guard -= 1
autoplay._autoplay_battle(g3_runner, g3_first, g3_st, null)
if String(g3_box.get("result", "")) != "victory" or int(g3_st.mine_hits) > 0:
green3_ok = false
elif int(g3_box.get("hero_hp", 0)) > 0:
green3_min_hp = mini(green3_min_hp, int(g3_box.hero_hp))
check(green3_ok, "L3 首遇位绿档:成长代理档口径(atk_pct=200/hp_pct=100)完美档 12 种子首遇战(节点 0 灰矮人)全胜且零踩雷")
check(green3_min_hp > 0, "L3 首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green3_min_hp)
# ⑫ 混编行集运行面正反断言(2026-10-07 行集多行批——T4 组 D 新增):注入坐标 (1,5)/(1,6) =
# L1 节点图与全部既有扫描面之外的新深度(ceil 请求命中注入行集——新 L 会被 ceil 封顶到 L3 不可用),
# 断言后立即还原表,后续断言面零污染。R3 fail-fast 正反验证 + R6 行序运行面验证。
var mix_row_a := {"level_index": 1, "node_depth": 5, "enemy_id": "goblin", "display_name": "地精", "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}
var mix_row_b := {"level_index": 1, "node_depth": 5, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 2, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]}
_db.enemy.rows.append(mix_row_a)
_db.enemy.rows.append(mix_row_b)
var mix_sys = load("res://core/unit_container.gd").new(_db)
check(mix_sys.spawn(1, 5, int(_db.hero.max_hp)), "混编行集 spawn 受理((1,5) 双行 地精1+熊地精2——定值无区间路径,行集构成纪律放开面正向)")
var mix_units: Array = mix_sys.all_enemies()
check(mix_units.size() == 3, "混编实例总数 = 各行数量求和(1+2=3——行集实例总数 ≤4 守卫内)")
check(String(mix_units[0].get("enemy_id")) == "goblin" and String(mix_units[1].get("enemy_id")) == "bugbear" and String(mix_units[2].get("enemy_id")) == "bugbear",
"混编生成序 = 文件行序(行序即契约——地精在前熊地精在后,handle_id 跨行连续派发)")
check(String(mix_units[0].get("display_name")) == "地精" and int(mix_units[1].get("hp")) == 20 and int(mix_units[1].get("atk")) == 3,
"混编各行行值独立投影(display_name/hp/atk 按各自行值——同场不同种不同值)")
_db.enemy.rows.append({"level_index": 1, "node_depth": 6, "enemy_id": "goblin", "display_name": "地精", "unit_count": 3, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]})
_db.enemy.rows.append({"level_index": 1, "node_depth": 6, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 3, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]})
var over_sys = load("res://core/unit_container.gd").new(_db)
check(not over_sys.spawn(1, 6, int(_db.hero.max_hp)), "行集实例总数守卫:两行各定值 3(总 6 > 4)spawn 被拒(行集构成纪律③——R3 fail-fast 运行面)")
_db.enemy.rows.remove_at(_db.enemy.rows.size() - 1)
_db.enemy.rows.remove_at(_db.enemy.rows.size() - 1)
_db.enemy.rows.remove_at(_db.enemy.rows.size() - 1)
_db.enemy.rows.remove_at(_db.enemy.rows.size() - 1)
check(int(_db.enemy.rows.size()) == 20, "混编断言段注入行全量还原(表回 20 行——首批混编投放后基线行数,迭代 18 卡⑤;后续断言面零污染)")
# 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律)
func _fresh_rng(seed_val: int):
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(seed_val)
return rng
# ==================== GP+商店房实施批冒烟(2026-09-21——接 L3 断言段后) ====================
# ① GP 到账(战斗完成入账恰一次 + Boss 关级加值)② escrow 隔离(格面兑付入缓冲/破财不透支)
# ③ 商店三收口(购买出账/加算区/余额不足拒/离开/收束清零)④ 图不变式(商店必经位/跨表行集迁移)
# ⑤ 九型与 gp_drop 流(奖励字典 18 格 + 黄金向量)。
# ---- ① GP 冒险钱包·到账 ----
func _smoke_gp_wallet() -> void:
print("--- GP 冒险钱包·到账(战斗完成入账恰一次 + Boss 加值)---")
var autoplay = load("res://smoke/level_autoplay.gd")
# ① L1 首战(普通战档——掷定 [2,4] + 关级加值 0):runner 驱动,收集 battle_finished 与钱包事件
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(42))
runner.set_hero_override(200, 100)
var fin := {}
var wallet_events: Array = []
runner.wallet_changed.connect(func(ev, gp_val, extra): wallet_events.append({"event": String(ev), "gp": int(gp_val), "amount": int(extra.get("amount", 0))}))
check(runner.start_level(1, 42), "GP 到账:L1 启动(普通战档)")
var battle = runner.current_battle()
var rows: Array = []
battle.step_row.connect(func(row): rows.append(row))
battle.battle_finished.connect(func(p): fin["p"] = p)
var st := _battle_st("normal")
var guard := 30
while runner.current_battle() == battle and not fin.has("p") and guard > 0:
guard -= 1
autoplay._autoplay_battle(runner, battle, st, null)
check(fin.has("p") and String(fin["p"].get("result", "")) == "victory", "L1 首战胜利(battle_finished gp_dropped 载荷在案)")
var escrow_sum := _escrow_sum_of(rows)
var gp_dropped: int = int(fin["p"].get("gp_dropped", -999))
check(gp_dropped - escrow_sum >= 2 and gp_dropped - escrow_sum <= 4,
"L1 普通战 gp_dropped − escrow ∈ [2+0, 4+0](掷定+关级加值 %d,escrow %+d)" % [gp_dropped - escrow_sum, escrow_sum])
var gains: Array = []
for ev in wallet_events:
if ev["event"] == "gain":
gains.append(ev)
check(gains.size() == 1, "钱包恰入账一次(gain 事件 ×%d)" % gains.size())
if gains.size() == 1:
check(int(gains[0]["amount"]) == gp_dropped, "入账金额 = gp_dropped 载荷(%d)" % gp_dropped)
check(runner.gp == maxi(0, gp_dropped) and runner.gp == int(gains[0]["gp"]),
"钱包读数 = 入账后投影(gp=%d,破财负值场景经 clamp ≥0)" % runner.gp)
# ② L2 Boss 战(节点 9——商店房批重编号):掷定 [6,10] + 关级加值 1
var boss = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(42))
var rows_b: Array = []
var fin_b := {}
boss.step_row.connect(func(row): rows_b.append(row))
boss.battle_finished.connect(func(p): fin_b["p"] = p)
check(boss.begin_battle({"level_index": 2, "node_depth": 9, "node_type": "boss", "hero_hp": int(_db.hero.max_hp)}),
"L2 Boss 战启动((2,9)——Boss 档掷定 [6,10] + 关级加值 1)")
var guard_b := 60
while boss.battle_result == "" and guard_b > 0:
guard_b -= 1
boss.minesweeper.settle_batch([{"action": "stop"}])
check(fin_b.has("p"), "L2 Boss 战终局(%s——gp_dropped 于战斗末掷定上抛)" % String(boss.battle_result))
var escrow_b := _escrow_sum_of(rows_b)
var gp_b: int = int(fin_b.get("p", {}).get("gp_dropped", -999)) if fin_b.has("p") else -999
check(gp_b - escrow_b >= 7 and gp_b - escrow_b <= 11,
"L2 Boss 战 gp_dropped − escrow ∈ [6+1, 10+1](掷定+关级加值 %d,escrow %+d)" % [gp_b - escrow_b, escrow_b])
# 步骤流中 GP 型兑付行(effect_id gp_gain/gp_loss)的 value 代数和 = escrow 会计算(归因行随行携带)
func _escrow_sum_of(rows: Array) -> int:
var sum := 0
for row in rows:
if String(row.get("effect_id", "")) == "gp_gain" or String(row.get("effect_id", "")) == "gp_loss":
sum += int(row.get("value", 0))
return sum
# ---- ② escrow 隔离(金币安全格兑付 / 破财直验 / 钱包 clamp)----
func _smoke_gp_escrow() -> void:
print("--- GP escrow 隔离(金币安全格兑付入缓冲 · 破财不透支)---")
# ① 种子扫描:找金币袋(gp 型安全格)可挖掘局——挖开兑付 +3 入 escrow,战斗末随 gp_dropped 一次上抛
var scan := {}
for s in range(1, 121):
var rng = _fresh_rng(s)
var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng)
var rows: Array = []
settlement.step_row.connect(func(row): rows.append(row))
var fin := {}
settlement.battle_finished.connect(func(p): fin["p"] = p)
if not settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}):
continue
var target := _find_gp_dig(settlement.minesweeper, settlement)
if target.is_empty():
continue
scan = {"seed": s, "settlement": settlement, "target": target, "rows": rows, "fin": fin}
break
check(not scan.is_empty(), "金币袋(gp 型安全格)可挖掘局种子可达(seed=%s)" % str(scan.get("seed", -1)))
if not scan.is_empty():
var settlement2 = scan["settlement"]
var ms = settlement2.minesweeper
var t: Dictionary = scan["target"]
var dig_arr: Array = [t["dig_cell"].x, t["dig_cell"].y]
if t["hero_cell"] != ms.hero_pos():
ms.settle_batch([{"action": "move", "cell": t["hero_cell"]}])
ms.settle_batch([{"action": "dig", "cell": t["dig_cell"]}])
var guard_stop := 12
while settlement2.battle_result == "" and guard_stop > 0:
guard_stop -= 1
ms.settle_batch([{"action": "stop"}]) # 收轮结算:授予段兑付 +3 → 战斗段先手 999 清场 → 胜利上抛
check(settlement2.battle_result == "victory", "金币局战斗终局(先手 999 收束)")
var gain_ok := false
for row in scan["rows"]:
if String(row.get("effect_id", "")) == "gp_gain" and row.get("origin_cell", []) == dig_arr and int(row.get("value", 0)) == 3:
gain_ok = true
check(gain_ok, "金币袋兑付行在案(金币袋 +3 → 战斗内缓冲,origin 指回来源格)")
check(int(settlement2.escrow) >= 3, "escrow 累计缓冲 ≥3(实测 %d——战斗域中间态,不入钱包不入增益容器)" % int(settlement2.escrow))
var gp_d: int = int(scan["fin"].get("p", {}).get("gp_dropped", -999))
check(gp_d - int(settlement2.escrow) >= 2 and gp_d - int(settlement2.escrow) <= 4,
"格面兑付计入 gp_dropped(%d − escrow %d ∈ [2,4]——战斗末一次到账,随上抛即弃)" % [gp_d, int(settlement2.escrow)])
# ② 破财(gp_loss)直验:兑付入口直调(weight 0 不入池——字典负向行单测通道,负向字典冒烟前例)
var rng_l = _fresh_rng(20260921)
var settle_l = load("res://core/battle_orchestrator.gd").new(_db, rng_l)
var fin_l := {}
settle_l.battle_finished.connect(func(p): fin_l["p"] = p)
check(settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}),
"破财直验战斗启动")
for i in range(3):
settle_l._grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1},
"negative", "mine_hit", Vector2i(0, 0), 1)
check(int(settle_l.escrow) == -6, "破财 ×3 → escrow −6(负向缓冲记账,零钱包触达)")
settle_l.minesweeper.settle_batch([{"action": "stop"}])
var gp_l: int = int(fin_l.get("p", {}).get("gp_dropped", 999))
check(gp_l + 6 >= 2 and gp_l + 6 <= 4, "破财压制 gp_dropped 负值上抛(%d + 6 ∈ [2,4]——掷定不受破财污染)" % gp_l)
# ③ 钱包不透支(clamp ≥0):runner 驱动 L1 首战 + 战斗中注入破财 → gp_dropped 负值 → 钱包钳制归 0
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(4242))
runner.set_hero_override(49850, 0) # atk 2 → 999(快速终局;hp 基线)
var ev_box := {"gains": [], "min_gp": 999999}
runner.wallet_changed.connect(func(ev, gp_val, extra):
ev_box["gains"].append({"event": String(ev), "gp": int(gp_val), "amount": int(extra.get("amount", 0))})
ev_box["min_gp"] = mini(ev_box["min_gp"], int(gp_val)))
check(runner.start_level(1, 4242), "clamp 场景:L1 启动(冒险钱包开局 0)")
var b3 = runner.current_battle()
var fin3 := {}
b3.battle_finished.connect(func(p): fin3["p"] = p)
for i in range(3):
b3._grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1},
"negative", "mine_hit", Vector2i(0, 0), 1)
var guard3 := 12
while runner.current_battle() == b3 and not fin3.has("p") and guard3 > 0:
guard3 -= 1
b3.minesweeper.settle_batch([{"action": "stop"}])
var gp3: int = int(fin3.get("p", {}).get("gp_dropped", 999))
check(gp3 < 0, "破财压制入账载荷为负(gp_dropped=%d)" % gp3)
var gain3: Array = []
for ev in ev_box["gains"]:
if ev["event"] == "gain":
gain3.append(ev)
check(gain3.size() == 1 and int(gain3[0]["amount"]) == gp3 and int(gain3[0]["gp"]) == 0,
"破财入账恰一次且投影读数 0(amount=%d,clamp 后 gp=0)" % gp3)
check(runner.gp == 0 and int(ev_box["min_gp"]) >= 0, "钱包全程不透支(gp=%d,事件投影最小读数 %d ≥ 0)" % [runner.gp, int(ev_box["min_gp"])])
# 板真相直读找金币袋挖掘目标:未开安全格 + gp 型资源身份 + 存在可达已开邻格(_find_flag_target 同款口径)
func _find_gp_dig(minesweeper, settlement) -> Dictionary:
var board = minesweeper.current_board()
var revealed := {}
for c in minesweeper.revealed_cells():
revealed[c] = true
for y in board.height:
for x in board.width:
var c := Vector2i(x, y)
if revealed.has(c) or board.is_mine(c):
continue
var ident = settlement.query_reward_identity(c)
if ident == null or String(ident.get("cell_type", "")) != "gp":
continue
for n in board.neighbors8(c):
if revealed.has(n) and (minesweeper.compute_move_path(n).size() > 0 or n == minesweeper.hero_pos()):
return {"dig_cell": c, "hero_cell": n}
return {}
# ---- ③ 商店房三收口 ----
func _smoke_shop_room() -> void:
print("--- 商店房三收口(现价/加算区/余额不足拒/离开回选路/收束清零)---")
var autoplay = load("res://smoke/level_autoplay.gd")
# 行进到商店房(min 路径:0→1→3(恢复)→6→8)——种子扫描保证进店余额 ≥ 三件首购总价 16(6+4+6)
var shop_run := {}
for s in range(42, 92):
var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(s))
runner.set_hero_override(900, 400) # 行进段快速通关档(购买断言与百分比轴正交——加算区单独断言)
runner.start_level(2, s, {}, 0) # 固定模板 0——商店经济断言按现行图 GP 账本
var st := _battle_st("normal")
var guard := 40
while String(runner.level_result) == "" and int(runner.current_node_id()) != 8 and guard > 0:
guard -= 1
if runner.awaiting_choice():
var ids: Array = runner.next_candidate_ids()
ids.sort()
runner.pick_next_node(int(ids[0]))
continue
var t := String(runner.current_node().get("type", ""))
if t == "reward" or t == "rest":
runner.continue_after_node()
continue
if t == "shop":
break
var b = runner.current_battle()
if b == null:
break
var fin := {}
b.battle_finished.connect(func(p): fin["p"] = p)
var g2 := 80
while runner.current_battle() == b and not fin.has("p") and g2 > 0:
g2 -= 1
autoplay._autoplay_battle(runner, b, st, null)
if String(runner.level_result) == "" and int(runner.current_node_id()) == 8 and runner.gp >= 16:
shop_run = {"seed": s, "runner": runner}
break
check(not shop_run.is_empty(), "商店房到达种子可达(seed=%s——进店 GP ≥ 16)" % str(shop_run.get("seed", -1)))
if shop_run.is_empty():
return
var runner = shop_run["runner"]
check(String(runner.current_node().get("type", "")) == "shop" and int(runner.current_node_id()) == 8,
"L2 行进到商店房(节点 8,next=[9]——Boss 前必经位)")
check(not runner.awaiting_choice() and runner.next_candidate_ids().is_empty(),
"到店开店态(覆盖层数据源就位——离开前不出选路口,零自动推进)")
# 现价 = 基价 + price_level_bonus(L2)=2 + 步长 × 已购档数
var prods: Array = runner.shop_products()
check(prods.size() == 3, "三商品全陈列(atk_up/hp_up/armor_up——无刷新无摇号)")
var by_id := _shop_products_by_id(prods)
check(int(by_id["atk_up"]["price"]) == 6 and int(by_id["hp_up"]["price"]) == 4 and int(by_id["armor_up"]["price"]) == 6,
"L2 首购现价 = 基价 + 关级加值 2(atk_up 6 / hp_up 4 / armor_up 6)")
# 购买 atk_up:钱包 −6 · 加算区 atk +1 · 二购现价 8
var gp0: int = runner.gp
check(runner.buy_product("atk_up"), "购买 atk_up 受理(现价 6)")
check(runner.gp == gp0 - 6, "钱包扣现价 6(%d → %d)" % [gp0, runner.gp])
check(int(runner.run_ledger.bonus.get("atk", -1)) == 1 and int(runner.run_ledger.bonus.get("max_hp", -1)) == 0 and int(runner.run_ledger.bonus.get("armor_class", -1)) == 0, # 迭代 27 T3——加算区住 RunLedger
"统一冒险加算区 atk +1(bonus={atk,max_hp,armor_class} 账本)")
by_id = _shop_products_by_id(runner.shop_products())
check(int(by_id["atk_up"]["price"]) == 8 and int(by_id["atk_up"]["purchased"]) == 1,
"atk_up 二购现价 = 4+2+2=8(步长 2 × 已购 1 档,现价现算)")
# 行进段百分比归零(购买/生效断言按加算区裸值口径——调试浮窗百分比后乘;hp 快照钳到新口径上限防 spawn 越界,测试通道)
runner.set_hero_override(0, 0)
runner.hero_hp = mini(runner.hero_hp, runner.effective_max_hp())
# hp_up:上限与当前生命同步 +3(封顶新上限)
var hp_before: int = runner.hero_hp
var mhp_before: int = runner.effective_max_hp()
check(runner.buy_product("hp_up"), "购买 hp_up 受理(现价 4)")
check(runner.gp == gp0 - 6 - 4, "钱包扣 4(%d)" % runner.gp)
check(runner.effective_max_hp() == mhp_before + 3, "生命上限 +3(%d → %d——加算区 max_hp 轴)" % [mhp_before, runner.effective_max_hp()])
check(runner.hero_hp == mini(hp_before + 3, runner.effective_max_hp()), "当前生命同步 +3 封顶新上限(%d → %d)" % [hp_before, runner.hero_hp])
# armor_up:AC 10+1
check(runner.buy_product("armor_up"), "购买 armor_up 受理(现价 6)")
check(runner.effective_ac() == int(_db.hero.armor_class) + 1, "AC = 10+1(armor_class 加算区轴,无百分比通道)")
# 余额不足购买被拒(零副作用):hp_up 连购至不可负担后兜底拒绝
var guard_drain := 20
var hp_buys := 1 # 夹具前段已购 1 笔(购买 hp_up 受理断言)——总数 = 前段 + 本循环
while guard_drain > 0:
guard_drain -= 1
var hp_price: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"])
if runner.gp < hp_price:
break
if not runner.buy_product("hp_up"):
break
hp_buys += 1
var gp_dry: int = runner.gp
var tier_dry: int = runner.purchased_tier("hp_up")
var hp_dry: int = int(_shop_products_by_id(runner.shop_products())["hp_up"]["price"])
check(not runner.buy_product("hp_up") and runner.gp == gp_dry and runner.purchased_tier("hp_up") == tier_dry,
"余额不足购买被拒(gp=%d < 现价 %d——零扣费零档位变动)" % [gp_dry, hp_dry])
# 离开 → 回选路口(候选 [9])
check(runner.continue_after_node() and runner.awaiting_choice() and runner.next_candidate_ids() == [9],
"离开商店 → 回选路口(候选 [9]——零自动推进收口)")
# 下一战参数 = 基础表值 + 加算区(百分比已归零——加算先乘后乘序的前半在案)
check(runner.effective_atk() == int(_db.hero.atk) + 1, "主角攻击生效值 = 2+1(基础表值 + 加算区)")
check(runner.pick_next_node(9) and runner.current_battle() != null, "选 Boss(节点 9)开战")
var snap: Dictionary = runner.current_battle().presentation_snapshot()
# max_hp 期望按实际购买笔数推导(迭代14① T4 断言基线复核——棋盘换代改变战局 GP 流,可购笔数
# 随经济漂移;断言语义=「加算区笔数×3 全额入战斗开始参数」,不再锚死恰好 1 笔)
check(int(snap["hero"].get("atk", -1)) == int(_db.hero.atk) + 1 and int(snap["hero"].get("max_hp", -1)) == int(_db.hero.max_hp) + 3 * hp_buys,
"战斗开始参数 = 基础表值 + 加算区(atk %d = 2+1 / max_hp %d = %d+%d×3)" % [int(snap["hero"].get("atk", -1)), int(snap["hero"].get("max_hp", -1)), int(_db.hero.max_hp), hp_buys])
# 冒险收束(Boss 胜或败北——基线裸装口径任一结局)→ 钱包清零
var fin_end := {}
runner.level_finished.connect(func(p): fin_end["p"] = p)
var g3 := 60
while not fin_end.has("p") and g3 > 0:
g3 -= 1
var b_end = runner.current_battle()
if b_end == null:
break
autoplay._autoplay_battle(runner, b_end, _battle_st("normal"), null)
check(fin_end.has("p") and String(fin_end["p"].get("result", "")) in ["victory", "defeat"],
"冒险收束(Boss 战终局:%s)" % String(fin_end.get("p", {}).get("result", "")))
check(runner.gp == 0, "冒险收束钱包清零(局内货币生命周期终点——不入存档不入摘要)")
func _shop_products_by_id(prods: Array) -> Dictionary:
var out := {}
for p in prods:
out[String(p["product_id"])] = p
return out
# ---- ④ 商店房图不变式(恰 1 商店 · 必经位 · 跨表行集迁移)----
func _smoke_shop_graph() -> void:
print("--- 商店房图不变式(恰 1 商店房 · 全路径必经 · 跨表行集迁移)---")
for L in [2, 3]:
var row := {}
for r in _db.level.rows:
if int(r.level_index) == L:
row = r
var shops: Array = []
var boss_id := -1
var max_id := -1
for n in _level_active_nodes(L):
var t := String(n.get("type", ""))
if t == "shop":
shops.append(int(n.get("node_depth", -1)))
if t == "boss":
boss_id = int(n.get("node_depth", -1))
max_id = maxi(max_id, int(n.get("node_depth", -1)))
check(shops.size() == 1 and int(shops[0]) == 8, "L%d 商店房恰 1 且 node_depth=8(next=[9])" % L)
check(boss_id == 9 and boss_id == max_id, "L%d Boss = 9 = 全图最大编号(商店房批重编号 8→9)" % L)
var paths: Array = _all_entry_boss_paths(_level_active_nodes(L))
var via_shop := true
for p in paths:
if not p.has(8):
via_shop = false
check(paths.size() >= 2 and via_shop, "L%d 全部 %d 条入口→Boss 路径穿商店房 8(必经位——DFS 枚举)" % [L, paths.size()])
# 跨表校验:(·,9) Boss 行精确存在 · (·,8) 无战斗行残留(陈旧行清理确认)
var bg_keys := {}
for row in _db.board_gen.rows:
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var en_keys := {}
for row in _db.enemy.rows:
en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
for L in [2, 3]:
check(bg_keys.has("%d_9" % L) and en_keys.has("%d_9" % L), "(%d,9) Boss 行在 board_gen + enemy 精确存在" % L)
check(not bg_keys.has("%d_8" % L) and not en_keys.has("%d_8" % L), "(%d,8) 商店房无板/行集残留(陈旧行已清理)" % L)
check(bg_keys.has("1_4") and en_keys.has("1_4") and not bg_keys.has("1_8") and not en_keys.has("1_8"),
"L1 行集零改动(单链 Boss (1,4)——教学期最小修订面)")
# ---- ⑤ 商店共享池(M-4 迭代 11 第②批 T5——三选一同构/乘法价/跨渠道共账/切边/断点扩键)----
func _smoke_shop_pool() -> void:
print("--- 商店共享池(抽3买1全重抽 · 乘法定价 ceil · 池空硬闸 · 切边零入店)---")
var autoplay = load("res://smoke/level_autoplay.gd")
var shelf_kinds: Array = []
for row in _db.shop_pool_rows():
shelf_kinds.append(String(row.get("kind_id", "")))
# ① 池模式场景可达(_adv_drive_to_shop 复用——L1 全程+L2 推进到商店房;pool 模式进店即抽架)
var scen := {}
var first_shelf_a: Array = []
var first_shelf_b: Array = []
for s in range(42, 92):
var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s))
a8.set_hero_override(900, 400)
if not a8.start_adventure(s, {"first_script": true, "shop_mode": "pool"}):
continue
if not _adv_drive_to_shop(a8, _adv_st(), autoplay):
continue
scen = {"a": a8, "s": s, "r": a8.runner()}
first_shelf_a = (a8.runner().shop_shelf() as Array).duplicate(true)
break
check(not scen.is_empty(), "池模式商店房场景可达(seed=%s)" % str(scen.get("s", -1)))
if not scen.is_empty():
# 同种子再跑一遍采第二份首架(确定性对拍——断点货架续玩=确定性重放承载的实证面)
var a8b = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(int(scen["s"])))
a8b.set_hero_override(900, 400)
if a8b.start_adventure(int(scen["s"]), {"first_script": true, "shop_mode": "pool"}) \
and _adv_drive_to_shop(a8b, _adv_st(), autoplay):
first_shelf_b = (a8b.runner().shop_shelf() as Array).duplicate(true)
check(first_shelf_a.size() == 3, "进店抽 3(货架 %d 卡——同构交互)" % first_shelf_a.size())
# MC-1 商店独立刷新(P-C2+Q2 裁——基础免费 0;GP 刷新 ceil(20×1.8^n) provisional;重抽同源)
if not scen.is_empty():
var r8 = scen["r"]
var gp0: int = int(r8.gp)
var cost0: int = r8.shop_refresh_cost()
check(cost0 == int(_db.adventure.shop_refresh_cost_base), "刷新首价=基价(%d)" % cost0)
check(r8.shop_refresh_free_left() == 0, "基础免费 0 次(Q2——天赋增益位后续添加)")
var shelf_pre: Array = (r8.shop_shelf() as Array).duplicate(true)
check(r8.shop_refresh(), "GP 刷新受理(gp=%d cost=%d)" % [gp0, cost0])
check(int(r8.gp) == gp0 - cost0, "GP 刷新扣费(%d→%d)" % [gp0, int(r8.gp)])
check(int(r8.shop_refresh_cost()) == int(ceil(float(cost0) * float(_db.adventure.shop_refresh_cost_curve))), "刷新爬价 ×curve(%d)" % int(r8.shop_refresh_cost()))
var same_shelf := first_shelf_a.size() == first_shelf_b.size()
if same_shelf:
for i in first_shelf_a.size():
if String(first_shelf_a[i].get("kind_id", "")) != String(first_shelf_b[i].get("kind_id", "")) \
or int(first_shelf_a[i].get("price", -1)) != int(first_shelf_b[i].get("price", -1)):
same_shelf = false
break
check(same_shelf, "同种子同货架逐位(确定性重放承载——断点货架续玩语义)")
if scen.is_empty():
return
var a8 = scen["a"]
var r8 = scen["r"]
# ② 货架投影形制:kind 词表封闭 + 现价公式推导(ceil(base×b^(关序−1)×A^0)——核心现算,呈现零复算)
var l2 := int(r8.level_row.level_index)
for c in r8.shop_shelf():
# T6 商店保底后 relic 卡合法入架(保底占位)——词表断言放行 relic;价格断言分叉
# (数值卡走 shop_pool_row 推导 / relic 卡走 15 固定锚——同公式不同基价)
var ckind := String(c.get("kind_id", ""))
var is_relic := ckind == "relic"
check(is_relic or shelf_kinds.has(ckind), "货架 kind 在封闭词表或保底 relic 卡(%s)" % ckind)
var expect_p: int
if is_relic:
expect_p = int(ceil(15.0 * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0))
else:
var prow: Dictionary = _db.shop_pool_row(ckind)
expect_p = int(ceil(float(int(prow.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * 1.0))
check(int(c.get("price", -1)) == expect_p,
"首购现价 = ceil(基价×b^(关序−1))(%s: %d = %d 推导)" % [ckind, int(c.get("price", -1)), expect_p])
# ③ 买 1 全重抽:出账+加算区+账本三落点 + 现价 A 链递增(买后 visit 计数=1;TP0——消费在架令牌本体)
var buy_c: Dictionary = {}
for c in r8.shop_shelf():
if int(c.get("price", 1 << 30)) <= int(r8.gp):
buy_c = c
break
check(not buy_c.is_empty(), "可购卡在架(GP %d——种子扫描保证首购空间)" % int(r8.gp))
if not buy_c.is_empty():
var kind := String(buy_c.get("kind_id", ""))
var tier := String(buy_c.get("tier", ""))
var val_map := {"attack": "atk", "max_hp": "max_hp", "armor": "armor_class", "ap": "ap"}
var gp0: int = int(r8.gp)
var taken0: int = int(a8._curio_taken.get(kind, 0))
var val0: int = int(a8.run_ledger.bonus.get(String(val_map[kind]), -999))
check(r8.buy_pool_item(kind), "购买受理(pool 口——%s|%s)" % [kind, tier])
check(int(r8.gp) == gp0 - int(buy_c.get("price", 0)), "钱包扣现价(%d → %d)" % [gp0, int(r8.gp)])
check(int(a8.run_ledger.bonus.get(String(val_map[kind]), -999)) == val0 + int(_db.adventure.curio_value(kind, tier)),
"加算区 +令牌档位件值(池件=奖励件本体——%s|%s %+d)" % [kind, tier, int(_db.adventure.curio_value(kind, tier))])
check(int(a8._curio_taken.get(kind, 0)) == taken0 + 1, "共享池账本 -1(跨渠道单点——taken %d → %d)" % [taken0, int(a8._curio_taken.get(kind, 0))])
# A 链:同visit 第二购现价 = ceil(base×b^(l2−1)×A^1)(货架已全重抽——新卡新价同公式)
var second: Dictionary = {}
for c in r8.shop_shelf():
if int(c.get("price", 1 << 30)) <= int(r8.gp):
second = c
break
if not second.is_empty():
var prow2: Dictionary = _db.shop_pool_row(String(second.get("kind_id", "")))
var expect_p2: int = int(ceil(float(int(prow2.get("base_price", 0))) * pow(_db.shop_price_b(), float(l2 - 1)) * _db.shop_price_a()))
check(int(second.get("price", -1)) == expect_p2,
"A 链爬价 = ceil(base×b^(关序−1)×A^1)(进店第 2 购——%d)" % expect_p2)
check(r8.buy_pool_item(String(second.get("kind_id", ""))), "同 visit 第二购受理(A 链软闸在价不在限)")
# ④ 切边零入店(白-box 重抽 40 次——GP 袋/规则改写型特性永不在架;重抽消费 shop_draw 签,场景尾不回写主时间线)
var cut_seen := false
for i in 40:
r8._shop_draw_shelf()
for c in r8.shop_shelf():
if String(c.get("kind_id", "")) == "gp" or String(c.get("kind_id", "")) == "treasure_item":
cut_seen = true
check(not cut_seen, "切边件零入店(40 次重抽零 gp/aux——负交易切边+三轴 doctrine)")
# ⑤ 池空硬闸(库存尽剔出牌库——TP0:white-box 耗尽 attack 令牌后货架零 attack + 消费口拒绝)
for di8 in range(a8._curio_deck.size() - 1, -1, -1):
if String((a8._curio_deck[di8] as Dictionary).get("kind", "")) == "attack":
a8._curio_deck.remove_at(di8)
var attack_seen := false
for i in 40:
r8._shop_draw_shelf()
if (r8.shop_shelf() as Array).any(func(c): return String(c.get("kind_id", "")) == "attack"):
attack_seen = true
break
check(not attack_seen, "库存尽剔牌库(attack 令牌耗尽——池消耗即上限跨渠道共库 reward→shop 向)")
var stock_before: int = a8.pool_stock("attack")
check(not a8._consume_pool_purchase("attack", "common") and a8.pool_stock("attack") == stock_before,
"池空消费拒绝(handler 零副作用——买光即枯竭硬闸)")
# ⑥ 跨渠道共库 shop→reward 向:回填一枚 attack 令牌 → 消费口受理 → 奖励牌库同步摘牌
a8._curio_deck.append({"kind": "attack", "tier": "common"})
check(a8._consume_pool_purchase("attack", "common"), "商店消费口受理(_apply_curio_pick 同口——真单点库)")
var reward_attack_seen := false
for t6 in a8._draw_curio_candidates():
if String((t6 as Dictionary).get("kind", "")) == "attack":
reward_attack_seen = true
break
check(not reward_attack_seen,
"商店购买进奖励牌库摘牌(买一件=奖励面少一件——shop→reward 向共库)")
# ⑦ 断点扩键(P2 甲——save v4 域内:shop_shelf/shop_visit_buys 入快照;离开店=空架合法态)
var sm = load("res://core/save_manager.gd").new("user://_smoke_shop_pool_save.json")
sm.load_from_disk()
a8.set_save_manager(sm)
check(r8.continue_after_node() and r8.awaiting_choice(), "离开商店 → 回选路口(货架态清零落档——零自动推进收口)")
var bp: Dictionary = sm.breakpoint_snapshot()
check(bp.has("shop_shelf") and (bp["shop_shelf"] as Array).is_empty(),
"断点扩键 shop_shelf 在案(离开店=空架合法态——P2 甲扩列)")
check(bp.has("shop_visit_buys") and int(bp["shop_visit_buys"]) == 0,
"断点扩键 shop_visit_buys 在案(离开店清零——A 链输入不入档语义)")
sm.clear_breakpoint()
# ⑧ 终局池读数(run_adventure pool 面——商店与奖励跨渠道消耗后的终局余量出讫)
var st_pool: Dictionary = autoplay.run_adventure(_db, 42, 0, 0, false, null, "normal", "min", {}, {}, {"mode": "pool"})
var ps: Dictionary = st_pool.get("pool_stock", {})
check(ps.size() == 6, "终局池读数六键出讫(run_adventure pool 面——四池种+T5 门票+T6 heal 行,读数只锚一次载体)")
var ps_ok := true
for k in ps.keys():
var quota := 0
for tier in _db.adventure.TIERS:
quota += int(_db.adventure.curio_quota(String(k), tier))
if int(ps[k]) < 0 or int(ps[k]) > quota:
ps_ok = false
check(ps_ok, "终局池余量在域 [0,配额](%s)" % str(ps))
# 入口→Boss 全路径枚举(DFS 迭代栈——图合法性校验保证单入口无环,路径有限)
func _all_entry_boss_paths(nodes: Array) -> Array:
var by_id := {}
var inbound := {}
for n in nodes:
by_id[int(n.get("node_depth", -1))] = n
for n in nodes:
for tid in n.get("next", []):
inbound[int(tid)] = true
var entry := -1
for n in nodes:
if not inbound.has(int(n.get("node_depth", -1))):
entry = int(n.get("node_depth", -1))
var paths: Array = []
var stack: Array = [[entry]]
while not stack.is_empty():
var path: Array = stack.pop_back()
var cur: Dictionary = by_id[path[path.size() - 1]]
var outs: Array = cur.get("next", [])
if outs.is_empty():
paths.append(path)
continue
for t in outs:
var np := path.duplicate()
np.append(int(t))
stack.append(np)
return paths
# ---- ⑤ 格面资源九型 + gp_drop 流(黄金向量)----
func _smoke_nine_types_gp_stream() -> void:
print("--- 格面资源九型 + gp_drop 流(奖励字典 18 格 + 黄金向量)---")
check(RewardTable.CELL_TYPES.size() == 10 and RewardTable.CELL_TYPES.has("gp") and RewardTable.CELL_TYPES.has("treasure_item"),
"格面资源十型封闭(第九型 gp 九型批 2026-09-20 / 第十型 treasure_item 秘宝内容批 2026-09-29——词表 v1.2)")
var cell_rows := 0
var pos_weight := 0
var gp_pos := -1
var gp_neg := -1
for row in _db.reward.rows:
if String(row.get("pool_tag", "")) == "cell":
cell_rows += 1
if String(row.get("polarity", "")) == "positive":
pos_weight += int(row.get("weight", 0))
if String(row.get("reward_id", "")) == "gp_coin":
gp_pos = int(row.weight)
if String(row.get("reward_id", "")) == "gp_lose":
gp_neg = int(row.weight)
check(cell_rows == 18, "奖励字典 18 行(九型×正负 18 格——aux 挖格投放四行已退役 2026-10-01 用户拍板甲:战斗/挖格不掉道具,秘宝唯一来路=三选一共享池〔节点/通关〕;清图槽独立批尾不计)")
# 雷格负向直伤当量(迭代 8 卡⑧——真人反馈「三雷 3/4 血无后果感」投值:三行各抬 +3 档,负向≈正向 2 倍定价)
var mine_power := {"heal_backlash": 6, "pierce_snare": 8, "magic_recoil": 8} # 数值修复批 2026-10-01 再抬档(旧 4/5/5,见 numfix 报告)
for row in _db.reward.rows:
var rid := String(row.get("reward_id", ""))
if mine_power.has(rid):
check(int(row.get("power", -1)) == int(mine_power[rid]),
"雷格负向直伤 %s power=%d(卡⑧投值)" % [rid, int(mine_power[rid])])
# 正向权重结构校验(配置推导,2026-09-23 重构):正向 cell 行权重 ≥1(必在池)、负向行权重恒 0(不入池)、
# 合计即配置自身的和(改表自动跟随,不再硬编码合计值)。
var pos_all_in_pool := true
var neg_all_zero := true
for row in _db.reward.rows:
if String(row.get("pool_tag", "")) != "cell":
continue
if String(row.get("polarity", "")) == "positive" and int(row.get("weight", 0)) < 1:
pos_all_in_pool = false
if String(row.get("polarity", "")) == "negative" and int(row.get("weight", 0)) != 0:
neg_all_zero = false
check(pos_all_in_pool and neg_all_zero,
"正向 cell 行权重 ≥1 全在池 / 负向行权重恒 0 不入池(正向合计 %d——配置推导)" % pos_weight)
check(gp_pos == 2 and gp_neg == 0, "gp 型两行(金币袋 gp_gain 正向 weight 2 / 破财 gp_loss 负向 weight 0 不入池)")
var rng = _fresh_rng(42)
check(rng.counter() == 0, "derive_at 纯函数不推计数器(统一流全局计数器恒 0——并流后纯函数语义不变,gp_drop 专用向量随分盐作废、统一向量中央断言)")
# ---- 元进度·MetaTable 表结构(元进度实现批 T1——行值全 provisional 归元进度设计批配读数;
# conversion 覆盖恰三,Boss 两档 first ≥ repeat;forge_products 行族已退役)----
func _smoke_meta() -> void:
print("--- 元进度·MetaTable 表结构 ---")
var meta_tbl = _db.meta
check(meta_tbl != null and meta_tbl.validate().is_empty(),
"MetaTable 非空 + validate 零错(三行族词表封闭/唯一/连续/域界/first≥repeat/ratio 全过)")
check(meta_tbl.boss_keystone.size() == 3
and meta_tbl.conversion.size() == 3 and meta_tbl.attune_refresh_upgrade.size() == 2,
"MetaTable 行族规模 3/3/2(Boss/换算恰三/Y 升级两档——forge_products 迭代 21 退役、workshop_items 迭代 22 退役)")
var attrs_ok := true
for a in ["max_hp", "atk", "armor_class"]:
attrs_ok = attrs_ok and not _db.meta_conversion_row(a).is_empty()
check(attrs_ok, "conversion attr 词表封闭恰全(三属性轴——meta_* 访问器可达)")
var boss_ok := true
for li in [1, 2, 3]:
var brow: Dictionary = _db.meta_boss_keystone_row(li)
boss_ok = boss_ok and not brow.is_empty() and int(brow.get("first_kill", 0)) >= int(brow.get("repeat_kill", -1))
check(boss_ok, "boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)")
_meta_pricing_and_buy()
_meta_settle_and_workshop()
_meta_injection()
_smoke_forge_axes()
_status_panel_projection()
_career_overlay_projection()
# ---- 锻造工坊·两根类型轴所有权移交(迭代 23 T2——V5 前半:hero.tres 两轴删除+锻造配置默认值)----
# 后半(build_adventure_params 注入默认值/选中覆盖)随 T4 注入接线同批断言;写口/解锁面归 T3。
func _smoke_forge_axes() -> void:
print("--- 锻造工坊·类型轴所有权移交(T2)---")
# V5:hero.tres 静态表两轴已删(全表加载零违例——config_db_test 同款闸;此处钉字段不在位)
check(not ("attack_type" in _db.hero) and not ("armor_type" in _db.hero),
"hero.tres 无 attack_type/armor_type 字段(契约 2——静态表两轴删除)〔锻造工坊批 V5〕")
# 锻造配置默认值 = normal/light(常驻持有——与柜台解锁状态解耦,未点亮也照常可读)
check(_db.forge_default_attack_type() == "normal" and _db.forge_default_armor_type() == "light",
"锻造配置默认值 attack_type=normal / armor_type=light(常驻持有)〔锻造工坊批 V5〕")
# 单位快照回退面:spawn 三参形态(零注入)= 锻造配置默认值(行为连续——原 hero.tres normal/light)
var uc = load("res://core/unit_container.gd").new(_db)
check(uc.spawn(1, 0, int(_db.hero.max_hp)), "spawn 受理(三参形态——两轴缺省回退面)")
check(String(uc.hero_unit().get("attack_type", "")) == "normal"
and String(uc.hero_unit().get("armor_type", "")) == "light",
"主角单位快照两轴 = 锻造配置默认值 normal/light(零注入回退——行为连续)〔锻造工坊批 V5〕")
# ---- 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)
check(mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 1
and sv2.keystone_balance() == 80,
"长剑锻造首购:扣 20 钥石、等级 0→1(Q1——第一级=获得装备=首个 +1)〔锻造工坊批 V2〕")
var sv2r = _meta_new_save(_META_SMOKE_PATHS[13])
check(sv2r.forge_route_level("sword") == 1 and sv2r.keystone_balance() == 80,
"购买持久(reload 后等级与余额不变——即时写盘)〔锻造工坊批 V2〕")
# V2②:二级=30(1 级位);六路线独立(买长剑不动轻甲)
check(mp2.forge_buy_level("sword") and sv2.forge_route_level("sword") == 2
and sv2.keystone_balance() == 50 and sv2.forge_route_level("light") == 0,
"长剑二级购:扣 30、等级→2;轻甲独立零扰动(六路线各练各的)〔锻造工坊批 V2〕")
# 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()
check(not mp2d.forge_buy_level("sword") and sv2d.keystone_balance() == 0
and sv2d.forge_routes_snapshot() == fr_dry,
"余额不足购买拒绝且零副作用(0 余额——三域零变化)〔锻造工坊批 V2〕")
# V2④:已满级拒购(cap 闸);未解锁拒购
sv2.set_forge_route_level("sword", 5)
check(not mp2.forge_buy_level("sword") and not mp2d.forge_buy_level("spear"),
"已满级拒购/未解锁拒购(cap 闸+解锁闸——零副作用)〔锻造工坊批 V2〕")
# V3① 通道一:练满前一档(长剑 cap)→ 长枪/法杖派生解锁(零收费);轻甲未满→甲线仍锁
check(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"),
"通道一:长剑练满→长枪/法杖派生解锁;轻甲未练满→重甲/魔法护甲仍锁〔锻造工坊批 V3〕")
check(mp2.forge_buy_level("spear") and sv2.forge_route_level("spear") == 1
and sv2.keystone_balance() == 30,
"派生解锁后长枪可购(首购 20——余额 50−20)〔锻造工坊批 V3〕")
# 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)
check(not mp3.forge_route_unlocked("ward") and mp3.forge_buy_unlock("ward")
and sv3.keystone_balance() == 300 and mp3.forge_route_unlocked("ward"),
"通道二:魔法护甲一次性解锁价 200 直购开(轻甲未练满也可买)〔锻造工坊批 V3〕")
# V3③ 直购后练满不重复收(练满通道=派生零收费;已解锁再直购=拒绝)
sv3.set_forge_route_level("light", 5)
check(mp3.forge_route_unlocked("ward") and not mp3.forge_buy_unlock("ward")
and sv3.keystone_balance() == 300,
"直购后练满不重复收(已解锁=拒绝零收费——两通道互不影响)〔锻造工坊批 V3〕")
# V3④ 不设串联门槛(法杖直购开——长枪零状态不阻;dry 档充值走直购)
sv2d.add_keystone(500)
check(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"),
"不设串联门槛(法杖直购开——长枪零状态不阻)〔锻造工坊批 V3〕")
# V4:任一购买/解锁只扣钥石——GP 侧零触碰(meta 域无 GP 键=域界扫描同款;此处钉余额差=当级价)
var ks4: int = sv3.keystone_balance()
check(mp3.forge_buy_level("ward") and sv3.keystone_balance() == ks4 - 20
and sv3.forge_route_level("ward") == 1,
"两币分工:购买只扣钥石(余额差=当级价 20——GP 零触碰、无材料计数)〔锻造工坊批 V4〕")
# V6①:营地切换=写存档域+免费(余额零动);旧路线练度保留(只不生效)
var ks6: int = sv3.keystone_balance()
check(mp3.forge_select_armor("ward") and sv3.forge_armor() == "ward"
and sv3.keystone_balance() == ks6,
"选中切换免费(余额零动——写存档域即盘)〔锻造工坊批 V6〕")
check(sv3.forge_route_level("light") == 5,
"切换后旧路线练度保留(轻甲 5 级不丢——回家待命只不生效)〔锻造工坊批 V6〕")
# V6②:未解锁路线拒选零副作用(dry 档:长枪未解锁)
check(not mp2d.forge_select_weapon("spear") and sv2d.forge_weapon() == "sword",
"未解锁路线拒选零副作用〔锻造工坊批 V6〕")
# V6③:类型轴读数随选中走(切换后读 ward;未解锁选中态防御回退默认值)
check(mp3.hero_armor_type() == "ward" and mp2d.hero_attack_type() == "normal"
and mp2d.hero_armor_type() == "light",
"类型轴当前生效值随选中走(ward;未解锁档回退默认 normal/light)〔锻造工坊批 V5/V6〕")
# ---- 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)
check(String(bp_params.get("attack_type", "")) == "normal"
and String(bp_params.get("armor_type", "")) == "light",
"build_adventure_params 注入默认值 normal/light(树钥匙未点亮——新档初始持有态)〔锻造工坊批 V5〕")
sv4.add_keystone(1000)
check(mp4.forge_buy_unlock("spear") and mp4.forge_buy_level("spear") and mp4.forge_select_weapon("spear"),
"长枪直购解锁+首购+选中(注入面夹具——200+20 钥石)")
var bp_sel: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv4, mp4)
check(String(bp_sel.get("attack_type", "")) == "pierce"
and String(bp_sel.get("armor_type", "")) == "light",
"build_adventure_params 读存档选中覆盖(选中长枪→pierce)〔锻造工坊批 V5〕")
# V7:hero_override 公式对拍=树 flat+锻造路线加值(旧路线练度待命不生效;切回即生效)
check(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"),
"hero_override=锻造路线加值(选中长枪 Lv1→atk +1;甲 0 级→AC +0;树零分量;无 hit 键)〔锻造工坊批 V7〕")
sv4.set_forge_route_level("sword", 3) # 长剑练度待命(未选中)
check(int(mp4.hero_override().get("atk", -1)) == 1,
"换当前武器→攻击加值随切换(长剑 3 级待命不生效——只认选中)〔锻造工坊批 V7〕")
check(mp4.forge_select_weapon("sword") and int(mp4.hero_override().get("atk", -1)) == 3,
"切回长剑→加值=3 级量(旧路线练度保留且生效)〔锻造工坊批 V7〕")
# 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)
check(mp5.forge_buy_unlock("heavy") and mp5.forge_buy_level("heavy") and mp5.forge_select_armor("heavy"),
"重甲直购解锁+首购+选中(三层 AC 夹具)")
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)
check(adr5.start_adventure(77, p5), "注入参数冒险启动(两轴+override 同面)")
var r5 = adr5.runner()
check(int(r5.effective_ac()) == 12 + 1,
"入战 AC 三层:甲型基础 12(重甲)+路线加值 1+局内 0〔锻造工坊批 V7〕")
check(adr5.hero_attack_type == "normal" and adr5.hero_armor_type == "heavy",
"参数面快照入 runner(attack_type=normal/armor_type=heavy)〔锻造工坊批 V6〕")
# V6 锁死:开冒险后改存档选中→参数面/注入值不变(快照冻结;light=初始持有已解锁可切)
check(mp5.forge_select_armor("light") and adr5.hero_armor_type == "heavy"
and int(r5.effective_ac()) == 12 + 1,
"冒险开始后锁死:改存档选中不影响参数面快照与生效值(契约 8)〔锻造工坊批 V6〕")
# 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
check(pierce_rows >= 1,
"enc 两轴直达 spawn 英雄快照(hero_attack 行携带 pierce 键——注入链末端)〔锻造工坊批 V5〕")
# spawn 显式注入面(三参旧形态零注入已在上方覆盖——此处钉显式值)
var uc2 = load("res://core/unit_container.gd").new(_db)
check(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",
"spawn 显式两轴注入(magic/heavy 直入英雄快照)〔锻造工坊批 V5〕")
# 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
check(not tree_atk_ac,
"树零攻防来源复核(12 行 effects 键集无 atk/armor_class——契约 1)〔锻造工坊批 V10〕")
# ---- 状态面板只读投影一致性(迭代 17 卡③——R4 数据只读同源:状态面板展示值逐项 == MetaProgression
# 访问器读数;投影拼装与 main._status_data 同构(同访问器族同序)。只读性连接巡检与零动效
# 断言归 ui_struct_probe 呈现侧,本段只管数据面)----
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()}
var po = load("res://presentation/views/status_panel.gd").new()
po.open_status(data)
check(po.visible, "状态面板开层可见(open_status 直开——P4 甲零动效路径)")
check(String(po._balance_badge.value_label.text) == "钥石 %d" % mp.keystone_balance(),
"余额徽投影 = keystone_balance() 读数(%s)" % po._balance_badge.value_label.text)
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
check(ws_ok, "秘宝匣 12 行投影 = treasure_shop_status() 逐项(名称/钥石价与条件进度/解锁态——首件解锁夹具在列)〔秘宝批 V19 呈现数据源〕")
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)
check(tree_txt == "尚未购买任何节点(天赋树在营地主柜台)" or tree_txt.begins_with("已购 %d 项:" % owned),
"天赋树概览聚合文案与已购节点数一致(零已购=空态句)")
po.visible = false
po.free()
# ---- 生涯页只读投影一致性(迭代 18 卡④——汇总段六类九项逐行 == SaveManager 计数访问器/
# MetaProgression 访问器读数;投影拼装与 main._career_data 同构(同访问器族同序)。
# 只读性连接巡检与零动效断言归 ui_struct_probe 呈现侧,本段只管数据面+旧档载入显示
# (v5 样档升 v6——完成定义 6「计数域缺省补零、页正常显示」))----
func _career_overlay_projection() -> void:
print("--- 生涯页·只读投影一致性(隔离档 smoke_meta_career/career_v5)---")
var sv = _meta_new_save(_META_SMOKE_PATHS[9])
var mp = load("res://core/meta_progression.gd").new(_db, sv)
# 已玩态夹具:两局收束(一通一止)+ 钥石非零——投影可辨非全零白板
# (账号成长态三投影已删——2026-10-12 用户裁:生涯页不统计天赋树与账号成长态,只统计终身计数)
sv.add_keystone(45)
sv.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 3,
"stopped_at": 0, "keystone_total": 0, "master_seed": 21})
sv.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": 0, "master_seed": 22})
sv.write_to_disk()
# 投影拼装(main._career_data 同构)+ 视图喂入
var data := {"total": sv.adventure_total(), "cleared": sv.adventure_cleared(),
"clears": [], "boss_first_kills": 0, "boss_first_total": _db.level_count(),
"boss_kills": sv.boss_kill_count(), "keystone": mp.keystone_balance()}
for i in _db.level_count():
var li: int = i + 1
data["clears"].append({"display_name": String(_db.level_row(li).get("display_name", "")),
"count": sv.clear_count(li)})
if sv.boss_first_kill_marked(li):
data["boss_first_kills"] += 1
var co = load("res://presentation/views/career_overlay.gd").new()
co.open_career(data)
check(co.visible, "生涯页开层可见(open_career 直开——零动效路径)")
check(String(co._total_label.text) == "2 局",
"总局数投影 = adventure_total() 读数(%s)" % co._total_label.text)
check(String(co._cleared_label.text) == "1 局(三关全通)",
"通关局数投影 = adventure_cleared() 读数(%s)" % co._cleared_label.text)
var expect_clears: Array[String] = []
for row in (data["clears"] as Array):
expect_clears.append("%s ×%d" % [String(row.get("display_name", "")), int(row.get("count", 0))])
check(String(co._clears_label.text) == " · ".join(expect_clears),
"各关通关次数投影 = clear_count() 逐关读数(%s)" % co._clears_label.text)
check(String(co._boss_first_label.text) == "3/3 关" and String(co._boss_kills_label.text) == "4 次",
"Boss 首杀/总杀投影 = 首杀标记计数 + boss_kill_count()(%s / %s)" % [co._boss_first_label.text, co._boss_kills_label.text])
check(String(co._keystone_badge.value_label.text) == "钥石 %d" % mp.keystone_balance(),
"钥石余额徽投影 = keystone_balance() 读数(%s)" % co._keystone_badge.value_label.text)
co.visible = false
co.free()
# 旧档载入显示(完成定义 6):v5 现役档形态(计数域两键不存在)升 v6 → 生涯页正常呈现缺省值
var v5 = load("res://core/save_manager.gd").new(_META_SMOKE_PATHS[10])
var fv5c := FileAccess.open(_META_SMOKE_PATHS[10], FileAccess.WRITE)
fv5c.store_string(JSON.stringify({"version": 5, "meta": {"keystone_balance": 3}}))
fv5c.close()
check(v5.load_from_disk() and v5.adventure_total() == 0 and v5.adventure_cleared() == 0,
"v5 现役档升 v6 载入成功(计数域缺省补零)")
var mp_v5 = load("res://core/meta_progression.gd").new(_db, v5)
var data_v5 := {"total": v5.adventure_total(), "cleared": v5.adventure_cleared(),
"clears": [], "boss_first_kills": 0, "boss_first_total": _db.level_count(),
"boss_kills": v5.boss_kill_count(), "keystone": mp_v5.keystone_balance()}
for i in _db.level_count():
data_v5["clears"].append({"display_name": String(_db.level_row(i + 1).get("display_name", "")),
"count": v5.clear_count(i + 1)})
var co_v5 = load("res://presentation/views/career_overlay.gd").new()
co_v5.open_career(data_v5)
check(co_v5.visible and String(co_v5._total_label.text) == "0 局" \
and String(co_v5._cleared_label.text) == "0 局(三关全通)" \
and String(co_v5._keystone_badge.value_label.text) == "钥石 3",
"旧档载入后生涯页正常显示(v5 升 v6:总局数/通关局数显示 0,既有钥石余额原样呈现)")
co_v5.visible = false
co_v5.free()
# 冒烟隔离存档名册(段首清陈旧 + 段尾清理——契约:测试不污染 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
# ---- 元进度 ②树购买面(迭代 21 T2——购买受理/持久/余额不足拒绝/未知节点拒绝;
# 几何定价段随属性锻造柜台退役消亡,行价=表值 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 后持久(树全钥石单币——2026-10-07 用户裁甲)
sv.add_keystone(1000)
var bal0: int = sv.keystone_balance()
var price0: int = int(_db.tree_node("sp_hp").get("price", 0))
check(mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1 and sv.keystone_balance() == bal0 - price0,
"树购买受理(Lv 0→1、余额 %d−%d)" % [bal0, price0])
var sv_r = _meta_new_save(_META_SMOKE_PATHS[0])
check(sv_r.tree_level("sp_hp") == 1 and sv_r.keystone_balance() == bal0 - price0, "购买持久(reload 后级与余额不变)")
# ③ 余额不足 / 未知节点: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()
check(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,
"余额不足 false 零副作用(0 余额购买拒绝——三域零变化〔天赋树批 V3〕)")
check(not mp_dry.tree_buy("no_such_node"), "未知节点 false 零副作用(词表外出域)")
# ---- 元进度 ④注入可见 + 白板成立(验收 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)
# 树生命淬炼注入(迭代 21 T4 换代公式:基础值+树淬炼 flat+锻造路线加值位预留 0——2 级 = +10;
# 注入走调试口径直置等级〔两分法——购买链已在 _smoke_tree 覆盖,此处隔离行门槛干扰〕)
sv.set_tree_level("sp_hp", 2)
check(int(mp.hero_override().get("max_hp", -1)) == 2 * int(_db.tree_node("sp_hp").get("effects", {}).get("max_hp", 0)),
"生命淬炼 2 级 → hero_override().max_hp == Σ 节点 effects×等级(验收4 换代〔天赋树批 V12〕)")
check(int(mp.hero_override().get("atk", -1)) == 0 and int(mp.hero_override().get("armor_class", -1)) == 0,
"攻/AC 树零来源(契约 9——树表零 atk/armor_class 行,override 二轴恒 0+预留位 0)")
check(not mp.hero_override().has("hit"),
"命中线在账未注入(契约 6——override 无 hit 字段〔天赋树批 V13〕)")
var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
check(adr.start_adventure(920251, {"hero_override": mp.hero_override()}), "注入参数冒险启动(start_adventure params 通道)")
var r = adr.runner()
var ov: Dictionary = mp.hero_override()
check(int(r.effective_atk()) == int(_db.hero.atk) + int(ov.get("atk", 0)),
"首战 hero_atk = 表值 + 永久强化 flat(begin_battle 同表达式——下一局能用到强化)")
check(int(r.effective_max_hp()) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0))
and int(adr.ledger["hero_hp"]) == int(_db.hero.max_hp) + int(ov.get("max_hp", 0)),
"首战 hero_max_hp 与开局生命账本 = 表值 + 注入档")
# 白板:同种子下 空参数 = 零强化参数 = 纯表值(逐位一致——注入通道默认值漏改在此转红)
var adr_w = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
check(adr_w.start_adventure(920251), "白板冒险启动(无 params 实参——B1 空参数路径)")
var sv_white = _meta_new_save(_META_SMOKE_PATHS[5])
var adr_z = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(920251))
check(adr_z.start_adventure(920251, {"hero_override": load("res://core/meta_progression.gd").new(_db, sv_white).hero_override()}),
"零强化参数冒险启动(新档 hero_override 全零)")
var rw = adr_w.runner()
var rz = adr_z.runner()
check(int(rw.effective_atk()) == int(_db.hero.atk) and int(rw.effective_max_hp()) == int(_db.hero.max_hp)
and int(rz.effective_atk()) == int(rw.effective_atk()) and int(rz.effective_max_hp()) == int(rw.effective_max_hp())
and int(rz.effective_ac()) == int(rw.effective_ac()),
"白板成立:空参数 = 零强化参数 = 纯表值(atk/max_hp/ac 三轴逐位一致——验收 2/3)")
check(int(rw.effective_ac()) == int(_db.hero.armor_class) and int(r.effective_ac()) == int(_db.hero.armor_class),
"ac 轴:白板恒表值、未换强化时注入零位移")
_meta_smoke_cleanup()
# ---- 元进度 ⑥Boss 两档 ⑦钥石不丢 ⑧工坊解锁 ⑩摘要副本与 cap(合成摘要驱动——settle 纯账面零跑局)----
func _meta_settle_and_workshop() -> void:
print("--- 元进度·收束入账与工坊(隔离档 smoke_meta_settle/workshop/cap)---")
var sv = _meta_new_save(_META_SMOKE_PATHS[2])
var mp = load("res://core/meta_progression.gd").new(_db, sv)
var row1: Dictionary = _db.meta_boss_keystone_row(1)
var row2: Dictionary = _db.meta_boss_keystone_row(2)
# ⑥ 逐杀掉落收束入账(迭代 10——掉落已在冒险内逐杀入账〔summary.keystone_total 含全部〕,
# keystone_drops 明细随摘要归档;首杀标记仍由 apply 逐通关关次置位)
var d1: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": int(row1.get("first_kill", 0)),
"keystone_drops": [{"level": 1, "first": true, "amount": int(row1.get("first_kill", 0))}],
"master_seed": 11})
var drop1: Dictionary = d1["boss_drops"][0]
check(bool(drop1.get("first", false)) and int(drop1.get("amount", 0)) == int(row1.get("first_kill", -1))
and int(drop1.get("level", 0)) == 1, "新档首通 L1:boss_drops[0] {first:true, amount=first_kill}(验收5——明细随摘要归档)")
check(int(d1.get("total", 0)) == int(row1.get("first_kill", 0)), "明细 total = keystone_total(逐杀掉落全源——无二次派生加账)")
check(sv.keystone_balance() == int(row1.get("first_kill", 0)), "余额 = 累计入账(逐杀掉落合计——无重复加账)")
# ⑥ 再通转复杀档(首杀标记置位后小额恒定不递减——标记面在 apply,金额面在逐杀裁决)
var d2: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": int(row1.get("repeat_kill", 0)),
"keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))}],
"master_seed": 12})
var drop2: Dictionary = d2["boss_drops"][0]
check(not bool(drop2.get("first", true)) and int(drop2.get("amount", 0)) == int(row1.get("repeat_kill", -1)),
"再通 L1:{first:false, amount=repeat_kill}(首杀标记置位后转小额)")
var d3: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": int(row1.get("repeat_kill", 0)),
"keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))}],
"master_seed": 13})
check(not bool(d3["boss_drops"][0].get("first", true)) and int(d3["boss_drops"][0].get("amount", 0)) == int(row1.get("repeat_kill", -1)),
"重复杀第三轮恒定不递减(验收5——无衰减曲线)")
# 混合归因:通关两关 → L1 重复 + L2 首杀(明细随摘要逐笔归档)
var d4: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 2,
"stopped_at": 0, "keystone_total": int(row1.get("repeat_kill", 0)) + int(row2.get("first_kill", 0)),
"keystone_drops": [{"level": 1, "first": false, "amount": int(row1.get("repeat_kill", 0))},
{"level": 2, "first": true, "amount": int(row2.get("first_kill", 0))}],
"master_seed": 14})
check(not bool(d4["boss_drops"][0].get("first", true)) and bool(d4["boss_drops"][1].get("first", false))
and int(d4["boss_drops"][1].get("amount", 0)) == int(row2.get("first_kill", -1)),
"两关通关逐关归因:L1 重复档 + L2 首杀档(first_kill 金额)")
# ⑦ 败北零通关摘要:余额只增不减(零入账)、clear_counts 不变
var bal_before: int = sv.keystone_balance()
var cc1_before: int = sv.clear_count(1)
var dz: Dictionary = mp.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0,
"stopped_at": 1, "keystone_total": 0, "master_seed": 15})
check(dz["boss_drops"].is_empty() and sv.keystone_balance() == bal_before and sv.clear_count(1) == cc1_before,
"败北零通关摘要:零入账零标记(钥石只增不减——验收1;败北局 clear_counts 不变)")
# ⑧ 秘宝解锁服务(迭代 22 T2——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)
check(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"),
"条件件误购拒绝(购买件专属——契约 8 分配面)〔秘宝批 V3 反例〕")
check(not mp_u.buy_treasure_unlock("twin_loader") and sv_u.keystone_balance() == 10,
"余额不足购买拒绝且零副作用(余额 10 < 价 25)〔秘宝批 V3〕")
check(mp_u.buy_treasure_unlock("morning_order") and sv_u.item_unlocked("morning_order")
and sv_u.keystone_balance() == 0,
"钥石购买解锁:余额足→解锁态落档+余额扣减正确(价 10)〔秘宝批 V3〕")
check(not mp_u.buy_treasure_unlock("morning_order"),
"已解锁重复购买拒绝〔秘宝批 V3〕")
check(mp_u.refresh_treasure_unlocks().is_empty() and not sv_u.item_unlocked("ranged_pick"),
"条件未达不解锁(远程镐=通关 1 次)〔秘宝批 V4〕")
sv_u._data["meta"]["adventure_cleared"] = 1 # 直置存档生涯计数(调试注入口径)
check((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(),
"远程镐=adventure_cleared≥1 达标恰解锁一次+重复刷新幂等(C6:通关=打通整局冒险)〔秘宝批 V4〕")
sv_u._data["meta"]["boss_kill_count"] = 3
check((mp_u.refresh_treasure_unlocks() as Array).has("refresh_license"),
"刷新执照=boss_kill_count≥3(阈值读 unlock_requires 行值不硬编)〔秘宝批 V4〕")
sv_u._data["meta"]["adventure_cleared"] = 3
check(not sv_u.item_unlocked("sky_eye"), "天眼单条件不足不解锁(AND 语义——cleared 3 达 boss 4 未达)〔秘宝批 V4〕")
sv_u._data["meta"]["boss_kill_count"] = 4
check((mp_u.refresh_treasure_unlocks() as Array).has("sky_eye"),
"天眼双条件 AND(通关≥3 且 Boss≥4)达标解锁〔秘宝批 V4〕")
sv_u._data["meta"]["vision_used"] = 10
check((mp_u.refresh_treasure_unlocks() as Array).has("beacon_network"),
"信标网络=真视类工具累计使用≥10(行为计数新域——use_treasure 受理成功累计)〔秘宝批 V4〕")
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
check(r_n == 5, "解锁态持久投影一致(reload 面同源读数;5 件=购买 1+条件 4)〔秘宝批 V4〕")
# ⑩ recent_summaries:元素含 boss_drops 副本、最新在前、cap 10
var rs: Array = sv.recent_summaries()
check(rs.size() >= 5 and String(rs[0].get("result", "")) != "" and rs[0].has("boss_drops")
and (rs[1] as Dictionary).has("keystone_drops")
and int(rs[0].get("master_seed", -1)) == 15, "摘要最新在前 + 元素含掉落明细副本(复盘可归因——验收5)")
var sv_c = _meta_new_save(_META_SMOKE_PATHS[4])
var mp_c = load("res://core/meta_progression.gd").new(_db, sv_c)
for i in 12:
mp_c.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 0,
"stopped_at": 1, "keystone_total": 0, "master_seed": i})
check(sv_c.recent_summaries().size() == 10 and int(sv_c.recent_summaries()[0].get("master_seed", -1)) == 11,
"摘要 cap 10 且最新在前(12 连 settle 截断后 seed 11 居首)")
# ⑪ 同调位账本(迭代 11 第①批 T4——装配持久/满位禁选/Y 升级语义;写口 = MetaProgression 同调段)
var sv_a = _meta_new_save(_META_SMOKE_PATHS[6])
var mp_a = load("res://core/meta_progression.gd").new(_db, sv_a)
check(Attunement.slots(_db, sv_a) == 0 and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 0,
"白板档同调位求值 = 0(0 起步裁决)且 Y=0(未购无刷新能力——P1 甲初始 0 同构)")
check(mp_a.toggle_attunement("morning_order") == false,
"满位禁选(Slots 0 时装配拒绝;兼未解锁拒绝前置——直置解锁后下方装配)〔秘宝批 V5〕")
sv_a.add_keystone(200)
sv_a.unlock_item("morning_order")
sv_a.unlock_item("twin_loader")
check(mp_a.buy_attune_refresh() and sv_a.attune_refresh_level() == 1
and int(mp_a.attune_camp_status()["refresh"]["charges"]) == 1,
"Y 升级购买入账(Lv1 → 每冒险 +1 次刷新)")
mp_a.settle_adventure({"result": "failed", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": 0, "master_seed": 21})
check(Attunement.slots(_db, sv_a) == 0 and sv_a.boss_kill_count() == 1,
"通关 1 关 → Slots 恒 0(旧「按击杀 Boss 次数自动涨」条件已删——槽位唯一来源=树点亮)且 Boss 击杀总数=1(计数域保留)〔天赋树批 V14〕")
sv_a.set_tree_level("att_socket1", 1)
check(Attunement.slots(_db, sv_a) == 1,
"树上点亮同调位 1 → Slots=1(槽位收编树节点——Q2 裁甲)")
check(not mp_a.toggle_attunement("sky_eye"),
"稀有件入普通格拒绝(件档 rank 3 > 装入位序 1——契约 2 档位格校验)〔秘宝批 V5〕")
check(mp_a.toggle_attunement("morning_order") and mp_a.toggle_attunement("twin_loader") == false,
"装配持久写入 + 满位禁选(Slots 1 装第二件拒绝)〔秘宝批 V5 换主语〕")
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)
check((mp_a2.attune_camp_status()["equipped"] as Array).has("morning_order"),
"装配关系读回持久(reload 后投影一致——写新件 id)〔秘宝批 V15〕")
# ⑫ 充能重置 / 刷新双计数器(迭代 11 第①批;clamp white-box 断言随 attunement_growth 谓词表
# 退役摘除——迭代 21 T5,V20 摘除清单)
var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12))
check(a12.start_adventure(12, {"attunement": ["incense_stick", "sky_eye"]}),
"充能重置场景:装配冒险启动(seed 12)〔秘宝批 V8 换主语〕")
check(int(a12.run_ledger.attune_charges.get("incense_stick", -1)) == 1 and int(a12.run_ledger.attune_charges.get("sky_eye", -1)) == 1, # 迭代 27 T3——充能账住 RunLedger
"充能账本按装备 active_charged 件重置(新目录表值:线香 battle 级 1 次/天眼 level 级 1 次——周期重置点归 V8 逐形态面)")
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": {}}
check(a12.refresh_curio() and a12._attune_refresh_x == 0 and a12._attune_refresh_y == 2,
"刷新双计数器——X 优先消耗(x1y2 → x0y2)")
check(a12.refresh_curio() and a12.refresh_curio() and a12._attune_refresh_y == 0
and a12.refresh_curio() == false,
"刷新双计数器——X 耗尽转 Y 池、池竭拒绝(x0y2 → y0 → 第四次 false 零副作用)")
a12._reset_refresh_x()
check(a12._attune_refresh_x == 0 and a12.attune_refresh_left() == 0,
"刷新 X 每碰三选一重置(不结转;新目录无 bonus_refresh 行源——X 机制保留恒 0 空转,存量加仓走 _refresh_stock=刷新练习 V8)〔秘宝批换代〕")
# ⑬ 主菜单开局传参面(迭代 19 卡⓪①③——开新/续玩两入口同调三键随开局下传防回归:真实入口此前
# 三键恒空(缺陷①),冒烟夹具直呼 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()
check(rules_e.has("posture_first_strike") and mp_e.attune_refresh_charges() == 2,
"开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 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"})
check((p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array),
"主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)")
check(int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges(),
"主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)")
check((p_new.get("tree_rules", []) as Array) == rules_e,
"主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)")
check(bool(p_new.get("first_script", false)) and String(p_new.get("shop_mode", "")) == "pool",
"主菜单开新冒险参数面:入口特有键(first_script/shop_mode)随 extra 原样并入")
var p_resume: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e,
{"treasure_unlocked": {}})
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,
"主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)")
# 尾裁微批b(2026-10-08 卡⓪受理闭环)——续玩路径 tree_rules 受理断言:resume_adventure 此前
# 只收 attunement/attune_refresh_y 两键(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": []}
check(adr_res.resume_adventure(bp_min, p_resume),
"继续上局受理面:resume_adventure 受理三键参数面(尾裁微批b 受理闭环——受理不再吃白板)")
check((adr_res._tree_rules as Array) == rules_e,
"继续上局受理面:tree_rules 随参数面进 runner._tree_rules(消费面 set_tree_rules 下传现成)")
_meta_smoke_cleanup()
# ---- 天赋树冒烟(迭代 21 T1/T2——v0.8 12 节点换代:schema/购买面/Fork 一节点双态/彩蛋特化)----
func _smoke_tree() -> void:
print("--- 天赋树(12 节点 · 购买面 · Fork 双态 · 彩蛋觉醒)---")
# 〔天赋树批 V1〕结构换代:12 行 + schema 零违例
check(_db.meta_tree != null and (_db.meta_tree.rows as Array).size() == 12,
"meta_tree 已加载(12 节点=脊柱 2〔生命/命中淬炼一线一节点 cap4〕+锻造 2〔钥匙+彩蛋——彩蛋含在锻造 2 内非第 13 行〕+钥石 2〔秘宝匣/刷新升级〕+同调 6〔socket 3+Fork 3 双态〕——v0.8 终态)〔天赋树批 V1〕")
check(_db.meta_tree.validate().is_empty(),
"MetaTreeTable.validate 零违例(脉/类型/币词表出域即拒/Fork 三组每组恰一且持 a/b 双效果键/彩蛋 ≤3/effects 键封闭/零 archetype)")
# 彩蛋行硬要求(契约 9 红线落点):effects 键集 ⊆ {max_hp, hit}——旧表 :402/:405 atk/armor_class 分量换代件
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
check(ley_ok, "easter_ley effects 键集 ⊆ {max_hp, hit}(攻/护分量已摘除——契约 9)〔天赋树批 V8 落点〕")
# 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]
check(not t2.validate().is_empty(), "schema 反例:vein 出域被拒")
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]
check(not t2.validate().is_empty(), "schema 反例:fork_group 出域(fg_mastery 已退役组)被拒")
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]
check(not t2.validate().is_empty(), "schema 反例:effects.archetype 加载致命(🔴 层级纪律护栏面)〔天赋树批 V4〕")
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]
check(not t2.validate().is_empty(), "schema 反例:requires 悬空被拒")
# 购买面(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)
check(mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 1, "树购买受理(脊柱生命 L1——树全钥石单币)")
check(not mp.tree_can_buy("sp_hp"),
"行门槛:第 2 级不可购(总等级 1 < 4——一线一节点主语,买第 N 级需全树总投入 ≥4×(N−1))〔天赋树批 V2〕")
check(mp.tree_buy("sp_hit") and mp.tree_buy("forge_workshop") and mp.tree_buy("att_socket1"),
"命中 L1/锻造钥匙(free 无前置=初始零购零币可点亮)/同调位 1(根节点无前置)受理(总等级 1→4)")
check(int(_db.tree_node("forge_workshop").get("price", -1)) == 0, "锻造工坊钥匙零币(free)")
check(mp.tree_can_buy("sp_hp"), "行门槛恰达开放(总等级 4 ≥ 4×1)")
check(mp.tree_buy("sp_hp") and sv.tree_level("sp_hp") == 2, "第 2 级购买受理(门槛恰达当刻开放)")
# Fork 一节点双态(V9)
sv.add_keystone(100)
check(mp.tree_buy_fork("fork_posture") and sv.tree_level("fork_posture") == 1 and sv.fork_side("fg_posture") == "a",
"Fork 购买=解锁+默认 A 态(一节点双态——单行承载)")
check(not mp.tree_can_buy("fork_posture"), "已解锁 Fork 重复购买拒绝(cap 1)")
check(not mp.tree_toggle_fork("fg_posture", "a"), "同态重复切换零操作(false)")
check(not mp.tree_toggle_fork("fg_sense", "b"), "未解锁 Fork 切换拒绝(解锁唯一入口=购买面)")
var bal_pre_toggle: int = sv.keystone_balance()
check(mp.tree_toggle_fork("fg_posture", "b") and sv.fork_side("fg_posture") == "b",
"零币切态 b(购买解锁后 A/B 可切)")
check(int(sv.keystone_balance()) == bal_pre_toggle, "切态零消费(钥石余额差分不动)")
check((mp.tree_rule_keys() as Array).has("posture_bulwark") and not (mp.tree_rule_keys() as Array).has("posture_first_strike"),
"规则键投影=激活态效果键(B 态稳健拓荒——tree_rule_keys 随切态换键集)")
check(mp.tree_toggle_fork("fg_posture", "a") and (mp.tree_rule_keys() as Array).has("posture_first_strike"),
"切回 A 态键集随换(六键词表在册——V11 校验面归单元段)")
# 彩蛋特化觉醒(V8)
check(not mp.tree_can_buy("easter_ley"), "彩蛋不经购买(手动购亮=红——觉醒唯一入口=特化判定)")
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
check(sv.tree_level("easter_ley") == 1, "两线拉满+同调位全解锁 → 地脉共鸣自动点亮(零币特化判定)")
var bal_now: int = sv.keystone_balance()
mp.tree_buy("sp_hp")
check(int(sv.keystone_balance()) == bal_now, "觉醒零消费(钥石余额不动)")
_meta_smoke_cleanup()
# ---- 天赋树六键行为冒烟(迭代 21 T3——V10:开局姿态窗/危险直觉限次/财富之道聚散/守侧失误补偿;
# 引擎直构 + 编排授予段直注(_grant_seg._judge_flag=结算授予段旗判定单口——迭代27 T2 随段迁址))----
func _smoke_tree_rules() -> void:
print("--- 天赋树六键行为(V10:窗/限次/聚散/守侧)---")
# 开局姿态组:每关首轮窗(battle_index 事实——引擎直构)
var fx = load("res://core/effect_engine.gd").new(null)
fx.rebuild([], [], ["posture_first_strike", "posture_bulwark"])
check((fx.dispatch("round_start", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1,
"先手压制:每关首战首轮窗命中(battle_index=1 且 round=1 → grant_ap queue)〔天赋树批 V10〕")
check((fx.dispatch("round_start", {"battle_index": 1, "round_index": 2}, Callable()) as Array).is_empty()
and (fx.dispatch("round_start", {"battle_index": 2, "round_index": 1}, Callable()) as Array).is_empty(),
"先手压制:首轮窗外地不触发(第二轮/第二关首战首轮——「每战首轮」旧语义换代件)")
check((fx.dispatch("damage_taken", {"battle_index": 1, "round_index": 1}, Callable()) as Array).size() == 1
and (fx.dispatch("damage_taken", {"battle_index": 2, "round_index": 1}, Callable()) as Array).is_empty(),
"稳健拓荒:首轮窗内受击出盾、窗外不出(shield damage_taken 同窗条件)")
# 危险直觉·锋:限次闸(引擎直构——mine_hit 挂点现成,编排收集 halve_pct 系既有 damage_mult 路径)
var fz = load("res://core/effect_engine.gd").new(null)
fz.rebuild([], [], ["sense_sharp"])
var got_mult := false
var got_add := false
for h in (fz.dispatch("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
check(got_mult and got_add, "锋:踩雷减半命中+限次记账(counter 0<2 闸开——damage_mult+counter_add 双行同预评估态)")
fz.counter_add("tree_sense_sharp_used", 2) # 直调置数(V10 注入口径——置关卡域计数后走事件路径)
check((fz.dispatch("mine_hit", {}, Callable()) as Array).is_empty(), "锋:限次耗尽全额(counter 2 不<2 闸关——每关限次 provisional 2)")
var exported: Dictionary = fz.counters_export()
var fz2 = load("res://core/effect_engine.gd").new(null)
fz2.rebuild([], [], ["sense_sharp"])
fz2.counters_seed(exported)
check((fz2.dispatch("mine_hit", {}, Callable()) as Array).is_empty(),
"锋:限次跨战持久(adventure 域 counters_export/seed——断点保留同源,不新增存档域不升 v7)")
# 守侧五场景(契约 12):编排 rig——begin_battle 携 sense_guard,授予段 _grant_seg._judge_flag 直注
var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777001))
check(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, "voucher_insurance": 0, "node_type": "battle"}),
"守侧 rig:战斗编排启动(tree_rules=sense_guard 携带)")
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
check(safe.x >= 0, "守侧 rig:安全格定位(非雷格=标错负样本)")
check(int(orch.action_economy.pending_ap_delta()) == 0, "守侧 rig:首轮后 queue 干净(开局 posture queue 已兑现)")
# 场景①:当回合新插旗、结算判标错且旗仍在场 → queue +1(本轮 AP 零变化=queue 承载、下轮 notify_round_start 兑现)
orch._grant_seg._judge_flag({"cell": safe, "hit": false}, 1)
check(int(orch.action_economy.pending_ap_delta()) == 1,
"守①:当回合标错且旗在场 → 下轮 AP+1(queue 排队——本轮 AP 逐位零变化)")
# 场景③④:之前回合标错重判(旗在场状态)与同格重复——去重闸挡回,最多返还一次
orch._grant_seg._judge_flag({"cell": safe, "hit": false}, 2)
orch._grant_seg._judge_flag({"cell": safe, "hit": false}, 3)
check(int(orch.action_economy.pending_ap_delta()) == 1,
"守③④:旧旗重判零触发+同格最多一次(_flag_miss_seen 去重闸=「当回合标错动作」判别器)")
# 场景②负样本:标中旗(hit=true 正向兑付路径)零触发
orch._grant_seg._judge_flag({"cell": safe, "hit": true}, 4)
check(int(orch.action_economy.pending_ap_delta()) == 1, "守②负样本:标中/被挖旗零触发(标错事件只在 miss 分支发射——发射面过滤承载)")
# 场景⑤:末轮判出→无下一轮=queue 不兑现(编排器每战新建——队列实例随战亡,合法作废态)
var orch2 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777002))
check(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, "voucher_insurance": 0, "node_type": "battle"}),
"守⑤ rig:次战编排启动(新战新队列实例)")
check(int(orch2.action_economy.pending_ap_delta()) == 0,
"守⑤:末轮判出不返(queue 只在 notify_round_start 兑现、战亡即弃——无跨战泄漏)")
# 按关重置清零点(adventure_runner._start_current_level)
var ar = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(777003))
ar.run_ledger.adventure_counters = {"tree_sense_sharp_used": 2, "tree_sense_guard_used": 1, "unrelated_key": 9} # 迭代 27 T3——计数账住 RunLedger
ar._reset_tree_limited_counters()
check(not ar.run_ledger.adventure_counters.has("tree_sense_sharp_used") and not ar.run_ledger.adventure_counters.has("tree_sense_guard_used")
and int(ar.run_ledger.adventure_counters.get("unrelated_key", 0)) == 9,
"限次按关重置:_start_current_level 清零点恰两键(他键零扰动——生命周期四态闭合)")
# 财富之道·散:商品标价折价(level_runner 计价单点)
var lr = load("res://core/level_runner.gd").new(_db, _fresh_rng(777004))
lr.set_tree_rules([])
check(int(lr._spend_discounted(100)) == 100, "散未激活:商品标价原价(无 economy_spend 键)")
lr.set_tree_rules(["economy_spend"])
check(int(lr._spend_discounted(100)) == 80 and int(lr._spend_discounted(1)) == 1,
"散激活:商品购买价 ×80%(provisional 折率——floor 偏向玩家、下限 1;刷新价不折=另一计价点零触碰)")
# 财富之道·聚:战斗掉落乘算·入账前(编排 _roll_gp_drop——同种子配对对拍 1.5×)
var orch3 = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777005))
check(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, "voucher_insurance": 0, "node_type": "battle"}),
"聚 rig:基线编排启动(无增益侧)")
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))
check(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, "voucher_insurance": 0, "node_type": "battle"}),
"聚 rig:增益编排启动(tree_rules=economy_hoard)")
orch4.encounter["tree_rules"] = ["economy_hoard"]
var gp_hoard: int = orch4._roll_gp_drop()
check(gp_hoard == int(round(float(gp_base) * 1.5)) and gp_hoard >= gp_base,
"聚:战斗掉落乘算 ×1.5(provisional——同种子配对 %d→%d,入账前=结算框显示实得)")
_meta_smoke_cleanup()
# ---- 战斗日志视图·行格式化呈现层同步闸(2026-09-20 真人试玩卡断 bug 回归:八型批伏击退役拆除
# 伤害行 attach 字段后本视图拆行分支漏网未同步——敌方首笔攻击行带 baseline 无 attach,
# _format_row Invalid access 使结算段中途夭折、界面卡死。行协议变更必须同步呈现层〔战斗结算 §行协议〕,
# 本段把现行行形态逐型喂进真实视图:拆行残留与行字段错位在此转红)----
func _smoke_battle_log_view() -> void:
print("--- 战斗日志视图行格式化(行协议 9 类呈现层同步闸)---")
var view = load("res://presentation/views/battle_log_view.gd").new(_db)
# 敌方行动伤害行(现行形态:baseline 在场、attach 已出协议——修复前此行即 Invalid access)
view.append_row({"type": "damage", "display_name": "地精 撕咬", "value": 2, "target": "主角",
"baseline": 3, "roll": 15, "ac": 12, "tier": 100,
"source_class": "enemy_action", "round_index": 2})
var enemy_txt: String = view.parsed_text()
check(enemy_txt.contains("撕咬") and enemy_txt.contains("〔掷 15 vs 护甲 12"), "敌方伤害行格式化(baseline 行不读退役 attach)")
check(not enemy_txt.contains("伏击") and not enemy_txt.contains("基线"), "退役伏击/基线拆行文案不再出现")
# 主角攻击行·单分量模型(充能退役后 payload 只带实际攻击类型一键——修复前 pierce/magic 型即崩)
view.append_row({"type": "damage", "display_name": "主角攻击", "value": 4, "pierce": 4,
"roll": 20, "ac": 13, "tier": 100, "crit": true, "killed": true,
"source_class": "hero_attack", "round_index": 2})
var hero_txt: String = view.parsed_text()
check(hero_txt.contains("暴击×") and hero_txt.contains("⚔击杀") and hero_txt.contains("■"), "主角穿刺型暴击行金框(单分量 payload 不触发三分量拆行)")
check(not hero_txt.contains(" + 穿刺 "), "退役三分量拆行文案不再出现")
# 未生效行聚合(pattern 3 聚合纪律——同键两行呈现 ×2 不逐行刷屏)
view.append_row({"type": "reward_ineffective", "display_name": "增益未生效(满层)", "batch_key": "attack_up", "source_class": "safe_cell", "round_index": 1})
view.append_row({"type": "reward_ineffective", "display_name": "增益未生效(满层)", "batch_key": "attack_up", "source_class": "safe_cell", "round_index": 1})
view.append_row({"type": "heal", "display_name": "急救包", "value": 3, "target": "主角", "source_class": "safe_cell", "round_index": 1})
var agg_txt: String = view.parsed_text()
check(agg_txt.contains("⚠") and agg_txt.contains("×2"), "未生效行聚合计数 ×2 呈现")
# 其余行型逐型喂入:格式化中途夭折 = 解析文本零增长(脚本错误静默吞断言的盲区由此转为可见 FAIL)
var rest := {
"effect": {"type": "effect", "display_name": "攻击强化", "value": 1, "pool_value": 2, "effect_id": "attack_up", "source_class": "flag_hit", "round_index": 1},
"flag_miss": {"type": "flag_miss", "display_name": "标旗未中", "source_class": "flag_miss", "round_index": 1},
"cast": {"type": "cast", "display_name": "卓尔精灵 施放 毒雾", "source_class": "enemy_action", "round_index": 3},
"decay": {"type": "decay", "value": 1, "round_index": 3},
"verdict": {"type": "verdict", "display_name": "下一轮次", "value": 0, "round_index": 3},
"highlight": {"type": "highlight", "display_name": "完美排雷!清图奖励 ×2", "value": 2, "source_class": "clear_reward", "round_index": 4},
}
for row_type in rest:
var before: int = view.parsed_text().length()
view.append_row(rest[row_type])
check(view.parsed_text().length() > before, "行型 %s 格式化落地(解析文本增长)" % String(row_type))
# ---- UI 全局主题冒烟(迭代 25 UI 全局主题批 2026-10-13——需求卡片 §7 验收清单 V1-V6)----
# V1 挂载覆盖 / V2 滑轨两段修复 / V3 预盖件运行时证词 / V4 换代退役零残留 /
# V5 回归(本冒烟全绿即回归网)/ V6 中文字体防御(挂主题后 default_font 顶掉 custom_font 也不丢字形)
func _smoke_global_theme() -> void:
print("--- UI 全局主题(main_theme.tres 挂载与词条断言)---")
var th := ThemeDB.get_project_theme()
check(th != null, "V1 项目主题挂载(project.godot gui/theme/custom → main_theme.tres)")
if th == null:
return
# V1:类型集覆盖 §4「做」清单全部类型(含 PopupMenu 弹层预盖)
var need_types := ["VScrollBar", "HScrollBar", "ScrollContainer", "HSlider", "VSlider",
"TooltipPanel", "TooltipLabel", "LineEdit", "SpinBox", "HSeparator", "VSeparator",
"CheckBox", "CheckButton", "OptionButton", "PopupMenu"]
var type_set := th.get_type_list()
for t in need_types:
check(type_set.has(t), "V1 主题类型覆盖 %s" % t)
# V1 红线:Button 类型词条零接触(五态矩阵归 06 第一层第二条)
check(not type_set.has("Button"), "V1 红线 Button 类型词条零接触")
# V1 红线:focus 族词条一律零定义(扫本主题自有条目,不走默认主题链)
var focus_hits := ""
for t in type_set:
for n in (th.get_stylebox_list(t) + th.get_icon_list(t) + th.get_color_list(t)
+ th.get_font_list(t) + th.get_font_size_list(t) + th.get_constant_list(t)):
if "focus" in String(n):
focus_hits += "%s/%s " % [t, n]
check(focus_hits == "", "V1 红线 focus 族词条零定义%s" % ("" if focus_hits == "" else "(违例:" + focus_hits + ")"))
# V1:关键词条抽查在位(实例走主题链解析)
var vsb := VScrollBar.new()
check(vsb.has_theme_stylebox("scroll") and vsb.has_theme_stylebox("grabber")
and vsb.has_theme_stylebox("grabber_highlight") and vsb.has_theme_stylebox("grabber_pressed"),
"V1 滚动条槽/柄三态词条在位")
check(not th.has_stylebox("scroll_focus", "VScrollBar") and not th.has_stylebox("scroll_focus", "HScrollBar"),
"V1 滚动条 scroll_focus 未定义(红线)")
var hsl := HSlider.new()
check(hsl.has_theme_stylebox("slider") and hsl.has_theme_stylebox("grabber_area")
and hsl.has_theme_stylebox("grabber_area_highlight")
and hsl.has_theme_icon("grabber") and hsl.has_theme_icon("grabber_highlight")
and hsl.has_theme_icon("grabber_disabled"), "V1 滑轨槽/填充/柄词条在位")
var le := LineEdit.new()
check(le.has_theme_stylebox("normal") and le.has_theme_color("font_color")
and le.has_theme_color("font_placeholder_color"), "V1 LineEdit 词条在位(含占位色 font_placeholder_color)")
var sp := SpinBox.new()
check(sp.has_theme_icon("updown"), "V1 SpinBox 箭头词条在位(updown 合并槽)")
var hsep := HSeparator.new()
var vsep := VSeparator.new()
check(hsep.has_theme_stylebox("separator") and vsep.has_theme_stylebox("separator"), "V1 分隔线词条在位")
var tpanel := PanelContainer.new()
var tlabel := Label.new()
check(tpanel.has_theme_stylebox("panel"), "V1 TooltipPanel 词条在位")
check(tlabel.has_theme_color("font_color") and tlabel.has_theme_font_size("font_size"), "V1 TooltipLabel 词条在位")
# V2:滑轨修复——已填充段 BG_ROOT 对槽暖石 ≈5.5:1(冻结值锚定)
var fill_sb: StyleBox = hsl.get_theme_stylebox("grabber_area")
var track_sb: StyleBox = hsl.get_theme_stylebox("slider")
if fill_sb is StyleBoxFlat and track_sb is StyleBoxFlat:
var fill_c: Color = (fill_sb as StyleBoxFlat).bg_color
var track_c: Color = (track_sb as StyleBoxFlat).bg_color
check(fill_c.is_equal_approx(Color8(58, 46, 34)) and track_c.is_equal_approx(Color8(184, 165, 130)),
"V2 冻结值锚定(已填充段=BG_ROOT #3A2E22,槽=暖石 #B8A582)")
var ratio := _theme_contrast(fill_c, track_c)
check(ratio >= 4.5, "V2 滑轨两段对比 %.2f:1 ≥4.5:1(拖到中点一眼可辨)" % ratio)
else:
check(false, "V2 滑轨 stylebox 类型均为 StyleBoxFlat")
# V3:预盖件运行时证词(临时实例化读词条——无实役控件的在役证据)
var cbx := CheckBox.new()
check(cbx.get_theme_icon("checked") != null and cbx.get_theme_icon("unchecked") != null
and cbx.get_theme_icon("checked_disabled") != null and cbx.get_theme_icon("unchecked_disabled") != null,
"V3 CheckBox 四态图标非空")
var cbt := CheckButton.new()
check(cbt.get_theme_icon("checked") != null and cbt.get_theme_icon("unchecked") != null
and cbt.get_theme_icon("checked_disabled") != null and cbt.get_theme_icon("unchecked_disabled") != null,
"V3 CheckButton 四态图标非空(与 CheckBox 同套 checked/unchecked 名,4.7 源码实证)")
var pm := PopupMenu.new()
check(pm.has_theme_stylebox("panel") and pm.has_theme_stylebox("hover")
and pm.has_theme_font_size("font_size"), "V3 PopupMenu 词条存在(弹层预盖)")
var ob := OptionButton.new()
check(ob.has_theme_stylebox("normal") and ob.has_theme_stylebox("hover")
and ob.has_theme_stylebox("pressed") and ob.has_theme_color("font_color"),
"V3 OptionButton 词条存在(预盖)")
# V4:换代退役——源码零残留(剥注释行后扫;ui_kit 两函数+挂载行+滑轨工厂)
for path in ["res://presentation/views/ui_kit.gd", "res://presentation/ui_assembler.gd",
"res://presentation/views/settings_overlay.gd", "res://presentation/views/debug_overlay.gd"]:
var src: String = FileAccess.get_file_as_string(path)
var bad := ""
for ln in src.split("\n", false):
var s := ln.strip_edges()
if s.begins_with("#"):
continue
if "styled_slider" in s or "tooltip_theme" in s or "_tip_theme" in s or "overlay.theme" in s:
bad += s + " "
check(bad == "", "V4 退役实现零残留(%s)%s" % [path, "" if bad == "" else "违例:" + bad])
# V6:中文字体防御——default_font 非空且 Label 实取字体非空
check(th.default_font != null, "V6 主题 default_font 非空(挂主题顶掉 custom_font 也不丢中文字形)")
var lbl := Label.new()
lbl.text = "测试"
check(lbl.get_theme_font("font") != null, "V6 Label 实取字体非空(中文字形防御实证)")
vsb.free(); hsl.free(); le.free(); sp.free(); hsep.free(); vsep.free()
tpanel.free(); tlabel.free(); cbx.free(); cbt.free(); pm.free(); ob.free(); lbl.free()
# ---- 按钮五态矩阵冒烟(迭代 26 T1——四工厂产物逐实例 override 断言;V1/V2)----
func _smoke_button_five_states() -> void:
print("--- UI 按钮五态矩阵(四工厂五态样式盒)---")
var uikit_s = load("res://presentation/views/ui_kit.gd")
var states := ["normal", "hover", "pressed", "disabled", "focus"]
# 普通钮(verb_button 同走 button() 打底——同断言覆盖)
var b = uikit_s.button("测试")
for st in states:
check(b.has_theme_stylebox_override(st), "V1 普通钮 %s 样式盒非空" % st)
var n_sb: StyleBox = b.get_theme_stylebox("normal")
var h_sb: StyleBox = b.get_theme_stylebox("hover")
var p_sb: StyleBox = b.get_theme_stylebox("pressed")
var d_sb: StyleBox = b.get_theme_stylebox("disabled")
var f_sb: StyleBox = b.get_theme_stylebox("focus")
check(n_sb is StyleBoxTexture and h_sb is StyleBoxTexture and d_sb is StyleBoxTexture,
"V1 普通钮 normal/hover/disabled 均为石纹九宫格贴图样式盒")
# 悬停=乙案:modulate 提亮(≠常态 WHITE——乙案判据)
var h_mod: Color = (h_sb as StyleBoxTexture).modulate_color
check(h_mod != Color.WHITE and h_mod.r > 1.0, "V1 普通钮 hover=modulate 提亮(乙案,异于常态 WHITE)")
# 禁用=甲案:本族底+α0.45 幽灵化
var d_mod: Color = (d_sb as StyleBoxTexture).modulate_color
check(is_equal_approx(d_mod.a, 0.45) and d_mod.r == 1.0 and d_mod.g == 1.0,
"V1 普通钮 disabled=本族底 α0.45 幽灵化(甲案)")
# 错配修正判据:disabled≠pressed(旧「禁用=按压图」)
check(d_sb != p_sb, "V1 普通钮 disabled≠pressed(旧错配已修正)")
# 焦点=甲案:INK 2px 内缩 1px 环、无底(叠加态不遮钮底)
check(f_sb is StyleBoxFlat, "V1 普通钮 focus 样式盒为扁平环(StyleBoxFlat)")
var fsb := f_sb as StyleBoxFlat
check(fsb.border_color == uikit_s.INK, "V1 焦点环色=INK(与 font_focus_color 同源)")
check(is_equal_approx(fsb.border_width_left, 2.0) and is_equal_approx(fsb.border_width_right, 2.0)
and is_equal_approx(fsb.border_width_top, 2.0) and is_equal_approx(fsb.border_width_bottom, 2.0),
"V1 焦点环宽=2px")
check(is_equal_approx(fsb.expand_margin_left, -1.0) and is_equal_approx(fsb.expand_margin_top, -1.0)
and is_equal_approx(fsb.expand_margin_right, -1.0) and is_equal_approx(fsb.expand_margin_bottom, -1.0),
"V1 焦点环内缩=1px")
check(not fsb.draw_center and fsb.bg_color.a < 0.001, "V1 焦点环无底(叠加态不遮钮底)")
b.free()
# 主钮:五态非空+本族平涂(pressed/disabled 非石纹)+disabled≠pressed+hover 提亮档承现行
var pb = uikit_s.primary_button("测试")
for st in states:
check(pb.has_theme_stylebox_override(st), "V1 主钮 %s 样式盒非空" % st)
var ppn: StyleBox = pb.get_theme_stylebox("pressed")
var pdd: StyleBox = pb.get_theme_stylebox("disabled")
var phv: StyleBox = pb.get_theme_stylebox("hover")
check(ppn is StyleBoxFlat and pdd is StyleBoxFlat, "V1 主钮 pressed/disabled=本族平涂(非石纹)")
check((ppn as StyleBoxFlat).bg_color == Color8(214, 172, 88), "V1 主钮 pressed=烛光黄深档(现行值不动)")
check(is_equal_approx((pdd as StyleBoxFlat).bg_color.a, 0.45), "V1 主钮 disabled=本族底 α0.45 幽灵化")
check(pdd != ppn, "V1 主钮 disabled≠pressed")
check((phv as StyleBoxFlat).bg_color == uikit_s.CANDLE.lightened(0.06), "V1 主钮 hover=提亮档(承现行不动)")
pb.free()
# 危险钮:补本族 pressed(深档)+disabled 幽灵化+focus;pressed/disabled 非石纹
var db = uikit_s.danger_button("测试")
for st in states:
check(db.has_theme_stylebox_override(st), "V1 危险钮 %s 样式盒非空" % st)
var dpr: StyleBox = db.get_theme_stylebox("pressed")
var dds: StyleBox = db.get_theme_stylebox("disabled")
check(dpr is StyleBoxFlat and dds is StyleBoxFlat, "V1 危险钮 pressed/disabled=本族平涂(非石纹,旧错配已修正)")
check((dpr as StyleBoxFlat).bg_color.r < 180.0 / 255.0, "V1 危险钮 pressed=深红深档(暗于常态)")
check(is_equal_approx((dds as StyleBoxFlat).bg_color.a, 0.45), "V1 危险钮 disabled=本族底 α0.45 幽灵化")
check(dds != dpr, "V1 危险钮 disabled≠pressed")
db.free()
# V2 主题红线维持(Button 类型词条零接触+focus 族词条零定义)——与 _smoke_global_theme 同判据复述
var th := ThemeDB.get_project_theme()
if th != null:
check(not th.get_type_list().has("Button"), "V2 红线维持 Button 类型词条零接触")
var focus_hits := ""
for t in th.get_type_list():
for nm in (th.get_stylebox_list(t) + th.get_icon_list(t) + th.get_color_list(t)
+ th.get_font_list(t) + th.get_font_size_list(t) + th.get_constant_list(t)):
if "focus" in String(nm):
focus_hits += "%s/%s " % [t, nm]
check(focus_hits == "", "V2 红线维持 focus 族词条零定义%s" % ("" if focus_hits == "" else "(违例:" + focus_hits + ")"))
# ---- 全局纹理过滤冒烟(迭代 26 T2——工程设置 Nearest+平滑渐变件 LINEAR 白名单;V3)----
func _smoke_texture_filter() -> void:
print("--- UI 全局纹理过滤(Nearest+白名单三件)---")
var v = ProjectSettings.get_setting("rendering/textures/canvas_textures/default_texture_filter")
check(v != null and int(v) == 0, "V3 工程设置 default_texture_filter=0(Nearest,翻转面=立绘/瓦片/九宫格 crisp)")
var uikit_s = load("res://presentation/views/ui_kit.gd")
var vg = uikit_s.vignette_rect()
check(vg.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR, "V3 白名单在位:暗角 vignette 显式 LINEAR")
vg.free()
var bm = uikit_s.beams_rect()
check(bm.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR, "V3 白名单在位:光柱 beams 显式 LINEAR")
# T4 光柱连续 alpha 结构断言(观感归目检):带心峰值 0.05/带缘连续过渡/纵向衰减上亮下暗
var bimg: Image = bm.texture.get_image()
var max_a := 0.0
var mid_a := 0
var top_sum := 0.0
var bot_sum := 0.0
for y in 560:
for x in 560:
var pa := bimg.get_pixel(x, y).a
max_a = maxf(max_a, pa)
if pa > 0.001 and pa < 0.049:
mid_a += 1
if y < 280:
top_sum += pa
else:
bot_sum += pa
check(max_a >= 12.0 / 255.0 and max_a <= 0.05,
"T4 光柱带心峰值 α≈0.05(8bit 截断量化下限 12/255,实测 %.5f)" % max_a)
check(mid_a > 100, "T4 光柱带内连续 alpha 过渡像素 %d 个(旧零过渡硬边已修)" % mid_a)
check(top_sum > bot_sum * 2.0, "T4 光柱纵向衰减生效(上半亮度 %.0f > 下半 %.0f 的 2 倍——上亮下暗)" % [top_sum, bot_sum])
bm.free()
var gb = uikit_s.gradient_base(Color.BLACK, Color.WHITE)
check(gb.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR, "V3 白名单在位:地图渐变 gradient_base 显式 LINEAR")
gb.free()
# 既有显式 LINEAR 两处不丢(源码锚定——脚影/状态图标)
var bw_src: String = FileAccess.get_file_as_string("res://presentation/views/board_world.gd")
check(bw_src.contains("shadow.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR"),
"V3 脚影显式 LINEAR 不丢(board_world.gd)")
var ss_src: String = FileAccess.get_file_as_string("res://presentation/views/status_slot_row.gd")
check(ss_src.contains("icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR"),
"V3 状态图标显式 LINEAR 不丢(status_slot_row.gd)")
# ---- <4.5:1 文字位换墨冒烟(迭代 26 T3——站桩徽标两族亮档+RAP 行深石墨;V4)----
func _smoke_contrast_inks() -> void:
print("--- UI 残余 <4.5:1 文字位换墨(V4 对比度锚定)---")
var uikit_s = load("res://presentation/views/ui_kit.gd")
var stage: Color = uikit_s.STAGE_BG
var r_order := _theme_contrast(uikit_s.GOLD_BRIGHT, stage)
check(r_order >= 4.5, "V4 顺位徽标/重甲徽标 GOLD_BRIGHT 对舞台底 %.2f:1 ≥4.5:1" % r_order)
var r_ward := _theme_contrast(uikit_s.HERO_BLUE_BRIGHT, stage)
check(r_ward >= 4.5, "V4 魔法护甲徽标 HERO_BLUE_BRIGHT 对舞台底 %.2f:1 ≥4.5:1" % r_ward)
var r_rap := _theme_contrast(uikit_s.STONE_DEEP, uikit_s.PANEL_DATA)
check(r_rap >= 4.5, "V4 RAP 反馈行 STONE_DEEP 对数据板 %.2f:1 ≥4.5:1" % r_rap)
# 消费点源码锚定:新墨在位、旧裸用点清零、语义岗常量不动
var sb_src: String = FileAccess.get_file_as_string("res://presentation/views/standee_body.gd")
check(sb_src.contains("HORIZONTAL_ALIGNMENT_LEFT, -1, 13, UiKit.GOLD_BRIGHT"),
"V4 顺位徽标①②③ 墨=GOLD_BRIGHT(13px 行原位)")
check(sb_src.contains("col = UiKit.HERO_BLUE_BRIGHT"), "V4 魔法护甲徽标墨=HERO_BLUE_BRIGHT")
check(sb_src.contains("col = UiKit.GOLD_BRIGHT"), "V4 重甲徽标墨=GOLD_BRIGHT")
check(sb_src.contains("if _intent_col == UiKit.GOLD_DEEP:"),
"V4 GOLD_DEEP 语义岗不动(standee:199 意图动画判据)")
var ap_src: String = FileAccess.get_file_as_string("res://presentation/views/ap_bar_view.gd")
check(ap_src.contains('UiKit.label("",UiKit.FS_CAPTION, false, UiKit.STONE_DEEP)'),
"V4 RAP 反馈行墨=STONE_DEEP(原位行锚定)")
check(not ap_src.contains("STONE_FAINT"), "V4 RAP 反馈行旧墨 STONE_FAINT 清零(含注记面)")
# ---- 元素细节余②③冒烟(迭代 26 T5——BCard 图标槽明度差降档+商店居中+营地四栏等高)----
func _smoke_element_details() -> void:
print("--- UI 元素细节余②③(图标槽降档+布局修)---")
var uikit_s = load("res://presentation/views/ui_kit.gd")
# ②白图标 ≥3:1 图形对比硬约束(代拍板6)+降档方向锚定
var r_white := _theme_contrast(Color.WHITE, uikit_s.SLOT_BACKDROP_SOFT)
check(r_white >= 3.0, "T5② BCard 图标槽白图标对比 %.2f:1 ≥3:1(硬约束)" % r_white)
var r_new := _theme_contrast(uikit_s.SLOT_BACKDROP_SOFT, uikit_s.PANEL_DATA)
var r_old := _theme_contrast(uikit_s.SLOT_BACKDROP, uikit_s.PANEL_DATA)
check(r_new < r_old, "T5② 明度差降档生效(槽对卡面 %.2f:1 < 旧 %.2f:1)" % [r_new, r_old])
var uk_src: String = FileAccess.get_file_as_string("res://presentation/views/ui_kit.gd")
check(uk_src.contains("UiKit.panel_flat(SLOT_BACKDROP_SOFT, PANEL_BORDER, 0, 1)"),
"T5② BCard 图标槽落柔和档(SLOT_BACKDROP_SOFT)")
var ss_src: String = FileAccess.get_file_as_string("res://presentation/views/status_slot_row.gd")
check(ss_src.contains("UiKit.SLOT_BACKDROP"), "T5② B 区状态图标槽仍用深档(SLOT_BACKDROP,未误伤)")
# ③布局修源码锚定
var shop_src: String = FileAccess.get_file_as_string("res://presentation/views/shop_overlay_view.gd")
check(shop_src.contains("_cards_box.alignment = BoxContainer.ALIGNMENT_CENTER"),
"T5③ 商店卡片区垂直居中(ALIGNMENT_CENTER)")
var camp_src: String = FileAccess.get_file_as_string("res://presentation/views/camp_overlay_view.gd")
var camp_min := camp_src.count("custom_minimum_size = Vector2(0, 336)")
check(camp_min == 3, "T5③ 营地三 holder 统一 min 高 336(实测 %d 处)" % camp_min)
var camp_fill := camp_src.count("size_flags_vertical = Control.SIZE_FILL")
check(camp_fill == 4, "T5③ 营地四栏面板纵向 FILL 等高(实测 %d 处)" % camp_fill)
check(camp_src.count("SIZE_SHRINK_CENTER") >= 3, "T5③ 三处锁文垂直居中在位")
# ---- 诊断转储钮退役冒烟(迭代 26 T5b——角落钮退出玩家画面+转储入口迁调试浮窗;V6)----
func _smoke_diag_button_retired() -> void:
print("--- UI 诊断转储钮退役(V6:玩家画面清零+浮窗可达)---")
var main_src: String = FileAccess.get_file_as_string("res://presentation/main.gd")
check(not main_src.contains("build_diag_button"), "V6 主区无 build_diag_button 调用残留(旧实现同批清理)")
check(main_src.contains("debug_overlay.diagnose_requested.connect(_on_diagnose_pressed)"),
"V6 转储入口连接随迁(组合根接浮窗 diagnose_requested 信号)")
check(main_src.contains("func _on_diagnose_pressed()"), "V6 转储编排承接函数在位(dump_diagnosis 卡死取证保留)")
var asm_src: String = FileAccess.get_file_as_string("res://presentation/ui_assembler.gd")
check(not asm_src.contains("build_diag_button"), "V6 build_diag_button 工厂已退役(ui_assembler 零残留)")
var dbg_src: String = FileAccess.get_file_as_string("res://presentation/views/debug_overlay.gd")
check(dbg_src.contains("signal diagnose_requested"), "V6 调试浮窗 diagnose_requested 信号在位")
check(dbg_src.contains("_emit_diagnose_requested"), "V6 浮窗转储钮外发方法在位(纯视图边界)")
check(dbg_src.contains('UiKit.button("转储")'), "V6 浮窗诊断转储钮在位(玩家画面角落钮清零后唯一入口)")
# ---- 存档序列化冒烟(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") # 树域写入(属性锻造域已退役——forge_levels 域清退换代件)
w.set_forge_route_level("spear", 3) # 锻造工坊域写入(迭代 23 T5——等级+直购解锁+选中标记)
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})
check(w.keystone_balance() == 10 and w.clear_count(1) == 1 and w.clear_count(2) == 1 and w.clear_count(3) == 0,
"apply 原语入账(钥石 7+3、通关次数 1/1/0)")
check(w.boss_first_kill_marked(1) and w.boss_first_kill_marked(2) and not w.boss_first_kill_marked(3),
"首杀标记随通关关卡置位(1/2 有、3 无)")
check(w.adventure_total() == 1 and w.adventure_cleared() == 0,
"生涯终身计数随 apply 自增恰一(中止局:总局数 1、通关局数 0——迭代 18 卡④)")
check(w.write_to_disk(), "写盘成功")
var r = sm_script.new(paths[0])
check(r.load_from_disk(), "读回成功")
check(r.keystone_balance() == 10, "钥石余额往返相等(10)")
check(r.tree_level("sp_hp") == 2 and r.tree_level("sp_hit") == 1 and r.fork_side("fg_posture") == "b",
"树域往返相等(tree_nodes/fork_sides——迭代 21 换代承载)")
check(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",
"锻造工坊域往返相等(levels/unlocked/weapon/armor——迭代 23 T5)〔锻造工坊批 V8〕")
check(r.item_unlocked("probe_compass") and not r.item_unlocked("rope"), "道具解锁往返相等")
check(r.clear_count(1) == 1 and r.clear_count(2) == 1 and r.clear_count(3) == 0, "通关次数表往返相等")
check(r.boss_first_kill_marked(1) and r.boss_first_kill_marked(2) and not r.boss_first_kill_marked(3),
"首杀标记往返相等")
check(r.adventure_total() == 1 and r.adventure_cleared() == 0, "生涯终身计数往返相等(1/0)")
var s0: Dictionary = r.recent_summaries()[0]
check(r.recent_summaries().size() == 1 and int(s0.get("master_seed", -1)) == 99 and int(s0.get("keystone_total", -1)) == 3,
"冒险摘要往返相等(1 条,seed 99)")
# ② 新档合法:不存在路径 load → true + 全零默认值
var f2 = sm_script.new(paths[1])
check(f2.load_from_disk() and f2.keystone_balance() == 0 and f2.tree_level("sp_hp") == 0
and not f2.item_unlocked("anything") and f2.clear_count(1) == 0 and f2.recent_summaries().is_empty(),
"新档合法态(load true + 全零默认)")
check(f2.adventure_total() == 0 and f2.adventure_cleared() == 0,
"新档生涯终身计数缺省全零(迭代 18 卡④两新域——白板存档完整可玩)")
# ③ spend 拒绝:余额不足 false 且零副作用(钥石只增不减——消费除外)
var f3 = sm_script.new(paths[2])
f3.add_keystone(5)
check(not f3.spend_keystone(8) and f3.keystone_balance() == 5, "余额 5 花 8 拒绝且余额不动")
check(f3.spend_keystone(3) and f3.keystone_balance() == 2, "余额内消费成功(5→2)")
# ⑤ 高版本拒读:load false、原文件字节不动、后续写盘拒绝且文件仍字节不动(契约 4)
var hv_text := "{\"version\": 99, \"meta\": {\"keystone_balance\": 1}}"
var fhv := FileAccess.open(paths[3], FileAccess.WRITE)
fhv.store_string(hv_text)
fhv.close()
var f5 = sm_script.new(paths[3])
check(not f5.load_from_disk(), "高版本拒读(load false)")
check(FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text, "拒读后原文件字节不动")
check(not f5.write_to_disk() and FileAccess.open(paths[3], FileAccess.READ).get_as_text() == hv_text,
"拒读态写盘拒绝且文件仍字节不动")
# ⑥ 损坏兜底:非 JSON 字节 → 默认数据 + .corrupt.bak(不静默丢档)
var junk := "not json {{{"
var fj := FileAccess.open(paths[4], FileAccess.WRITE)
fj.store_string(junk)
fj.close()
var f6 = sm_script.new(paths[4])
check(f6.load_from_disk() and f6.keystone_balance() == 0 and f6.recent_summaries().is_empty(),
"损坏档兜底(默认数据继续)")
check(FileAccess.file_exists(paths[4] + ".corrupt.bak"), "坏档备份 .corrupt.bak 存在")
check(FileAccess.open(paths[4] + ".corrupt.bak", FileAccess.READ).get_as_text() == junk, "备份内容 = 原坏档字节")
# ⑦ 迁移链:register_migration 测试钩 → 手工 v0 旧结构档 → 链式升级 v1
var mig := func(d: Dictionary) -> Dictionary:
return {"version": 1, "meta": {"keystone_balance": int(d.get("keystone_old", 0))}}
var f7 = sm_script.new(paths[5])
f7.register_migration(0, mig)
var fm := FileAccess.open(paths[5], FileAccess.WRITE)
fm.store_string(JSON.stringify({"version": 0, "keystone_old": 12}))
fm.close()
check(f7.load_from_disk() and f7.keystone_balance() == 12, "v0 旧档经迁移链入账(keystone_old 12 → 余额 12)")
check(f7.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[5], FileAccess.READ).get_as_text()).get("version", -1)) == 7,
"迁移链 0→1(测试钩)→7(生产) 写盘 version == 7(v7=锻造工坊域,迭代 23 T5——v6=生涯终身计数域迭代 18 卡④)")
# ⑧ 域界干净:meta 永久进度域不含局内资源键(行动点/RAP/GP/道具库存/生命账本不入档——契约 2;
# 迭代 8 卡①修订:扫描收窄到 meta 域——断点域=冒险进行中瞬态快照,生命/GP 按拍板面入域豁免)
var disk_parsed = JSON.parse_string(FileAccess.open(paths[0], FileAccess.READ).get_as_text())
var meta_text := JSON.stringify((disk_parsed as Dictionary).get("meta", {}))
var banned := ["\"ap\"", "\"rap\"", "\"gp\"", "\"inventory\"", "\"hero_hp\""]
var leaked := []
for b in banned:
if meta_text.contains(b):
leaked.append(b)
check(leaked.is_empty(), "域界干净——meta 域无局内资源键(发现 %s)" % str(leaked))
# ⑩ 摘要 cap:12 份 → 保留 10、最新在前
var f10 = sm_script.new(paths[7])
for i in range(12):
f10.apply_adventure_summary({"result": "completed", "cleared": true, "levels_cleared": 0,
"stopped_at": 0, "keystone_total": 0, "master_seed": i})
var arr: Array = f10.recent_summaries()
check(arr.size() == 10, "摘要 cap 10(12 份 → 10 条)")
check(int(arr[0].get("master_seed", -1)) == 11 and int(arr[9].get("master_seed", -1)) == 2,
"最新在前(头 seed 11 / 尾 seed 2)")
# ⑨ 摘要集成(冒险结构批产物驱动):run_adventure 真局摘要 → apply 原语 → 域读数 → 写盘读回保持
var autoplay = load("res://smoke/level_autoplay.gd")
var st9: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
var f9 = sm_script.new(paths[8])
f9.apply_adventure_summary(st9.get("summary", {}))
var ks_run := 0
for li in range(1, int(_db.level.rows.size()) + 1):
ks_run += int(_db.meta_boss_keystone_row(li).get("first_kill", 0))
check(String(st9.get("result", "")) == "completed", "摘要集成:冒险驱动满配通关(seed 42)")
check(f9.keystone_balance() == ks_run, "集成钥石余额 = ΣBoss 首杀行值(%d——逐杀掉落全源)" % f9.keystone_balance())
check(f9.clear_count(1) == 1 and f9.clear_count(2) == 1 and f9.clear_count(3) == 1, "集成通关次数表全 1")
check(f9.boss_first_kill_marked(1) and f9.boss_first_kill_marked(2) and f9.boss_first_kill_marked(3),
"集成三关首杀标记齐")
check(f9.recent_summaries().size() == 1 and int(f9.recent_summaries()[0].get("master_seed", -1)) == 42,
"集成 recent 1 条且 seed 42")
var st9b: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
f9.apply_adventure_summary(st9b.get("summary", {}))
check(f9.clear_count(1) == 2 and f9.clear_count(2) == 2 and f9.clear_count(3) == 2, "第二局后通关次数全 2")
check(f9.keystone_balance() == 2 * ks_run, "第二局后余额翻倍(2 × Σ首杀——模拟驱动恒首杀口径)")
check(f9.recent_summaries().size() == 2, "集成 recent 长度 2")
check(f9.write_to_disk(), "集成写盘成功")
var r9 = sm_script.new(paths[8])
check(r9.load_from_disk() and r9.keystone_balance() == 2 * ks_run and r9.clear_count(3) == 2
and r9.recent_summaries().size() == 2 and r9.boss_first_kill_marked(3),
"集成写盘读回全量保持")
# ⑪ 断点域(迭代 8 卡①):七字段写读往返逐位一致+附随件+值拷贝出户+清除+旧档迁移合法空态
var fbp = sm_script.new(paths[9])
check(not fbp.has_breakpoint(), "断点:新档无断点(合法空态——「继续上局」隐藏)")
fbp.set_breakpoint({
"level_index": 2, "node_id": 5, "hero_hp": 13,
"bonus": {"atk": 2, "max_hp": 5, "armor_class": 1},
"gp": 17, "shop_purchased": {"hp_small": 2, "atk_small": 1},
"template_pick": 3,
"master_seed": 424242, "rng_counter": 38, "keystone": 6,
"visited_ids": [0, 2, 5],
"aux_items": [{"item_id": "aux_scout", "source_class": "reward_node",
"origin_cell": [3, 4], "round_index": 2, "used": false}],
})
check(fbp.has_breakpoint() and fbp.write_to_disk(), "断点:写入+写盘成功")
var rb = sm_script.new(paths[9])
check(rb.load_from_disk() and rb.has_breakpoint(), "断点:读回在档")
var bp: Dictionary = rb.breakpoint_snapshot()
check(int(bp.get("level_index", -1)) == 2 and int(bp.get("node_id", -1)) == 5
and int(bp.get("hero_hp", -1)) == 13, "断点七字段往返:关卡序号/节点位/生命")
var bp_bonus: Dictionary = bp.get("bonus", {})
check(int(bp_bonus.get("atk", -1)) == 2 and int(bp_bonus.get("max_hp", -1)) == 5
and int(bp_bonus.get("armor_class", -1)) == 1, "断点七字段往返:属性加成区")
check(int(bp.get("gp", -1)) == 17, "断点七字段往返:GP 钱包")
var bp_shop: Dictionary = bp.get("shop_purchased", {})
check(int(bp_shop.get("hp_small", -1)) == 2 and int(bp_shop.get("atk_small", -1)) == 1,
"断点七字段往返:商店已购档")
check(int(bp.get("template_pick", -1)) == 3, "断点七字段往返:已抽模板")
check(int(bp.get("master_seed", -1)) == 424242 and int(bp.get("rng_counter", -1)) == 38
and int(bp.get("keystone", -1)) == 6, "断点附随件往返:主种子/计数器/钥石累计")
var bp_visited: Array = bp.get("visited_ids", [])
check(bp_visited.size() == 3 and int(bp_visited[0]) == 0 and int(bp_visited[2]) == 5,
"断点附随件往返:已到节点序")
var bp_treasure: Array = bp.get("aux_items", [])
check(bp_treasure.size() == 1 and String(bp_treasure[0].get("item_id", "")) == "aux_scout",
"断点附随件往返:道具库存")
bp["hero_hp"] = 999
check(int((rb.breakpoint_snapshot() as Dictionary).get("hero_hp", -1)) == 13,
"断点读面值拷贝(改读面不渗档)")
rb.clear_breakpoint()
check(not rb.has_breakpoint() and rb.write_to_disk(), "断点:清除+写盘(三结局收束口径)")
var rc = sm_script.new(paths[9])
check(rc.load_from_disk() and not rc.has_breakpoint(), "断点:清除后读回无断点")
var fo = sm_script.new(paths[9])
var fold := FileAccess.open(paths[9], FileAccess.WRITE)
fold.store_string("{\"version\": 1, \"meta\": {\"keystone_balance\": 4}}")
fold.close()
check(fo.load_from_disk() and fo.keystone_balance() == 4 and not fo.has_breakpoint(),
"v1 旧档迁移 v7:断点域补空(合法空态,无断点域不炸)")
check(fo.write_to_disk() and int(JSON.parse_string(FileAccess.open(paths[9], FileAccess.READ).get_as_text()).get("version", -1)) == 7,
"v1 旧档迁移后写盘 version == 7(v1→v7 链式——锻造工坊域 2026-10-12)")
# ⑫ v2 旧档→v3 兼容(迭代 10 g 裁——semver 向后兼容:旧断点零丢失、新代码可续玩)
var fv2 = sm_script.new(paths[10])
var fv2f := FileAccess.open(paths[10], FileAccess.WRITE)
fv2f.store_string(JSON.stringify({"version": 2, "meta": {"keystone_balance": 7},
"breakpoint": {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5,
"bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {},
"template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
"visited_ids": []}}))
fv2f.close()
check(fv2.load_from_disk() and fv2.has_breakpoint() and fv2.keystone_balance() == 7,
"v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)")
check(int((fv2.breakpoint_snapshot() as Dictionary).get("master_seed", 0)) == 777,
"v2 旧断点字段零丢失(向后兼容——breakpoint 内容不因 schema 升版重写)")
var adr_v2 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(777))
check(adr_v2.resume_adventure(fv2.breakpoint_snapshot(), {"hero_override": {}}),
"v2 旧断点续玩受理(缺 bonus 附随件走常规重建路径——g 裁 semver 口径)")
# ⑬ 生涯终身计数域(迭代 18 卡④):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()
check(f13.load_from_disk() and f13.adventure_total() == 0 and f13.adventure_cleared() == 0
and f13.keystone_balance() == 9,
"v5 旧档升 v7:计数域缺省补零且既有余额原样(迁移只增字段不改值——v5→v6→v7 链式)")
check(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,
"升档幂等:v7 档写盘再载零漂移(重复迁移零副作用——锻造工坊域同批补齐)")
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})
check(f13b.adventure_total() == 1 and f13b.adventure_cleared() == 1,
"收束自增恰一·通关局(三关全通 cleared=true:总局数+1 且通关局数+1)")
f13b.apply_adventure_summary({"result": "abandoned", "cleared": false, "levels_cleared": 1,
"stopped_at": 2, "keystone_total": 0, "master_seed": 2})
check(f13b.adventure_total() == 2 and f13b.adventure_cleared() == 1,
"收束自增恰一·中止局(放弃局:仅总局数+1,通关局数不动)")
# ⑮ 旧档破坏性废弃(迭代 21 T5——契约 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()
check(f15.load_from_disk() and f15.keystone_balance() == 33
and f15.tree_nodes().is_empty() and f15.fork_sides().is_empty(),
"换代前结构存档:树域废弃重置(旧节点 id/fork 侧/forge_levels 旧域残留清退)、钥石余额零折算零变动")
var mp16 = load("res://core/meta_progression.gd").new(_db, f15)
f15.add_keystone(50)
check(mp16.tree_buy("sp_hp") and f15.tree_level("sp_hp") == 1, "废弃重置后后续购买正常受理(无残留脏节点)")
# ⑯ 锻造工坊域 forge_routes(迭代 23 T5——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()
check(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,
"v6 旧档升 v7:既有域逐位不动(钥石/树/生涯计数零折算——迁移只增不改)〔锻造工坊批 V8〕")
check(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",
"v6 旧档缺键补齐=初始持有态(六路线 0 级/直购清单空/选中=sword+light)〔锻造工坊批 V8〕")
var fr_keys: Array = (f16.forge_routes_snapshot() as Dictionary).keys()
fr_keys.sort()
check(fr_keys == ["armor", "levels", "unlocked", "weapon"],
"forge_routes 域键集恰四项(无特效域——满级特效本期不做,契约 7)〔锻造工坊批 V8/V11〕")
var lv_keys: Array = ((f16.forge_routes_snapshot() as Dictionary).get("levels", {}) as Dictionary).keys()
lv_keys.sort()
check(lv_keys == ["heavy", "light", "spear", "staff", "sword", "ward"],
"levels 六路线键集恰全(sword/spear/staff/light/heavy/ward)〔锻造工坊批 V8〕")
# 值拷贝出户(改快照不渗档——本类契约)
var snap: Dictionary = f16.forge_routes_snapshot()
(snap.get("levels", {}) as Dictionary)["sword"] = 99
check(f16.forge_route_level("sword") == 0, "forge_routes_snapshot 值拷贝出户(改快照不渗档)〔锻造工坊批 V8〕")
# ⑭ 收束链入账(迭代 18 卡④ T2——真实收束挂点全链恰一:主流程 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})
check(f13b.adventure_total() == 3 and f13b.adventure_cleared() == 1,
"收束链自增·中止局(settle_adventure 真实挂点:总局数+1、通关局数不动)")
mp13.settle_adventure({"result": "completed", "cleared": true, "levels_cleared": 3,
"stopped_at": 0, "keystone_total": 0, "master_seed": 4})
check(f13b.adventure_total() == 4 and f13b.adventure_cleared() == 2,
"收束链自增·通关局(三关全通:总局数+1 且通关局数+1——全链恰一)")
# 段尾清理(测试文件绝不残留)
for p in stale:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)