T6 探针基建收口段: 三族样板收单点(probe_ui_util树寻址contains/begins_with参数化+isolate_save七份隔离样板/battle_driver五份战斗驱动)+level_autoplay双战斗循环合并_decide_act+test_kit收13套件_check样板(断言数逐套件零丢失对照表在报告)+BAD_DIR夹具共享+smoke_all五件(:953公有面clamped_pool/_battle_st七袋/check_silent并入quiet参/_pct转发删/load改preload);探针小件=main句柄Node类型注/魔法等待常量化/画布常数同源锚注/死参verbose删;红线守住=ui_input case④b与click循环体零触碰/拍板点三件未退役

This commit is contained in:
DKChan committed 2026-10-02 08:46:43 +08:00
1 parent 0691a188fd
commit 315e46038c
32 files changed
+587 -764

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 战斗驱动循环单点(T6 样板收口批 2026-10-02)——smoke 族探针共用的「挖掘至停手」整轮驱动。
# 策略 = AutoPlayer.choose_dig(O4 单一实现):挖最近可挖格(异格先移位)/ 无可挖或相位离 Active 停手。
# 语义锚(monte_carlo 原型逐位保持):guard 400 步防挂死;移位后相位/轮次离场即 break;
# 轮末仍 Active 且未换轮 → settle_batch stop。smoke_all._run_battle/_auto_play_round 为冒烟本体
# 不经本面(含回归断言——2026-10-02 修复批加,禁并入);fx_shot_probe 为带墙钟抓帧的异步变体亦不在列。
extends RefCounted
const AutoPlayer := preload("res://smoke/auto_player.gd")
const GUARD_ACTIONS := 400 # 单轮动作步防挂死上限(被收口探针原值逐一同值)
# 驱动一整轮挖掘。still_running = 调用方战斗活性谓词(缺省恒真):
# monte_carlo / skill_rhythm 传 `func(): return out.result == ""`(battle_finished 已发的轮内不再动手),
# damage_probe 关卡驱动传 runner 绑定 + battle_result 谓词,battle 级探针传空即可。
static func drive_round(settlement, still_running: Callable = Callable()) -> void:
var start_round: int = settlement.round_index
var minesweeper = settlement.minesweeper
var guard := GUARD_ACTIONS
while _alive(still_running) and settlement.action_economy.phase == "Active" \
and settlement.round_index == start_round and guard > 0:
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 settlement.action_economy.phase != "Active" or settlement.round_index != start_round:
break
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if _alive(still_running) and settlement.action_economy.phase == "Active" and settlement.round_index == start_round:
minesweeper.settle_batch([{"action": "stop"}])
static func _alive(still_running: Callable) -> bool:
return still_running.is_null() or bool(still_running.call())
+1
View File
@@ -0,0 +1 @@
uid://dbattledrv13r
+8 -34
View File
@@ -12,7 +12,7 @@
# Godot_console.exe --headless --path . --script res://smoke/damage_probe.gd -- level 42
extends SceneTree
const AutoPlayer := preload("res://smoke/auto_player.gd")
const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
var _checks := 0
var _fails := 0
@@ -67,7 +67,7 @@ func _probe_boss(db, seed_val: int) -> void:
print("--- Run A:fresh 池(零挖掘停手轮——无授予,攻击链裸验)---")
var run_a := _run_boss(db, seed_val, false)
_print_rounds(db, math, run_a, true)
_print_rounds(db, math, run_a)
var a_ok := _assert_rounds(db, math, run_a, true)
check(a_ok.fresh_and_full, "A1 攻击链健康(fresh 池·掷点口径):非蓄力拍 d_tick = max(下限, 掷点档位基线+附加)(×%d 拍)——H2 攻击链缺陷否证" % a_ok.n_attack)
check(a_ok.arith_ok, "A2 池算术完整:全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % a_ok.n_attack)
@@ -77,7 +77,7 @@ func _probe_boss(db, seed_val: int) -> void:
print("--- Run B:自动挖掘(授予照常发生——当前数据承伤行为实测)---")
var run_b := _run_boss(db, seed_val, true)
_print_rounds(db, math, run_b, true)
_print_rounds(db, math, run_b)
var b_ok := _assert_rounds(db, math, run_b, false)
check(b_ok.arith_ok, "B1 池算术完整(含授予轮):全部非蓄力拍 d_tick == 掷点口径期望(×%d 拍)" % b_ok.n_attack)
check(b_ok.wall_tick_positive, "B2 负池轮不归零(伤害下限):hero_def<0 的轮 d_tick 恒 ≥ damage_floor(×%d 轮,最深实测池 %d)" % [b_ok.n_wall, b_ok.deepest])
@@ -98,28 +98,16 @@ func _run_boss(db, seed_val: int, dig: bool) -> Dictionary:
var rounds := 0
while settlement.battle_result == "" and rounds < 40:
rounds += 1
var start_round: int = settlement.round_index
if dig:
var minesweeper = settlement.minesweeper
var guard := 400
while settlement.action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0:
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 settlement.action_economy.phase == "Active":
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if settlement.action_economy.phase == "Active" and settlement.round_index == start_round:
minesweeper.settle_batch([{"action": "stop"}])
BattleDriver.drive_round(settlement)
else:
settlement.minesweeper.settle_batch([{"action": "stop"}])
return cap
# 逐轮:授予行汇总 → 重建战斗段时点池值(effect_pool_map 表驱动归属)→ 敌行动行逐行打印
func _print_rounds(db, math, run: Dictionary, verbose: bool) -> void:
#(T6 收口批:原形参 verbose 为死参——打印行为无档位差异,随批删除)
func _print_rounds(db, math, run: Dictionary) -> void:
var groups := _round_groups(run.rows)
var beats := _played_beats(run, db)
var rounds: Array = groups.keys()
@@ -356,22 +344,8 @@ func _drive_battle(runner, settlement, cap: Dictionary) -> void:
var round_guard := 60
while cap["result"] == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
round_guard -= 1
var start_round: int = settlement.round_index
var minesweeper = settlement.minesweeper
var action_guard := 400
while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \
and settlement.round_index == start_round and action_guard > 0:
action_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 settlement.action_economy.phase != "Active" or settlement.round_index != start_round:
break
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" and settlement.round_index == start_round:
minesweeper.settle_batch([{"action": "stop"}])
BattleDriver.drive_round(settlement, func() -> bool:
return runner.current_battle() == settlement and settlement.battle_result == "")
# 战斗判定摘要:round-1 victory 的战斗必须能从结算行分辨「首轮全灭」vs「空场」——
+2 -16
View File
@@ -5,7 +5,7 @@
# 运行:Godot_console.exe --headless --path . --script res://smoke/dump_battle.gd
extends SceneTree
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现
const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
func _initialize() -> void:
var db = load("res://core/config_db.gd").new()
@@ -28,21 +28,7 @@ func _initialize() -> void:
var rounds := 0
while settlement.battle_result == "" and rounds < 40:
rounds += 1
var start_round: int = settlement.round_index
var minesweeper = settlement.minesweeper
var action_economy = settlement.action_economy
var guard := 400
while action_economy.phase == "Active" and settlement.round_index == start_round and guard > 0:
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":
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if action_economy.phase == "Active" and settlement.round_index == start_round:
minesweeper.settle_batch([{"action": "stop"}])
BattleDriver.drive_round(settlement) # T6 收口:原内联循环语义同型(移动期免 AP——移动不换轮,移位后重查 break 分支为防御性等价)
print("战斗结果: %s(%d 轮)" % [settlement.battle_result, rounds])
print("=== 结算步骤流(%d 行)===" % stream.size())
for row in stream:
+16 -24
View File
@@ -6,7 +6,16 @@
# 渲染在(本探针 PNG)」双证的第二半边。PNG 落输出目录(artwork/qa-fx-*/,素材纪律不入 git)。
extends Node
var main
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
# ── 演出采样窗口口径(拍板点:headless/窗口化帧率不定,帧时刻以墙钟近似锚定;改窗口 = 改抓帧
# 时刻观测面,非断言语义。窗口值与原字面量逐一同值——T6 收口批仅提具名。) ──
const T_DIG_FLIGHT := 0.12 # 挖格行到达/弹道在飞窗口(settle_batch 回执后停留)
const T_SETTLE := 0.35 # 结算排播窗口(行到达后二连抓)
const T_ROUND_TICK := 0.25 # 停手轮末(敌方攻击弹道/受击闪白 0.2-1.6s 流过)连抓 6 帧的采样步距
const T_FINAL := 0.6 # 收尾帧前静置
var main: Node
var out_dir := ""
var _problems: Array = []
@@ -19,17 +28,10 @@ func _ready() -> void:
main = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
for p0 in ["user://fx_shot_probe.json", "user://fx_shot_probe.json.tmp", "user://fx_shot_probe_settings.cfg"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0)
var iso_c = load("res://core/save_manager.gd").new("user://fx_shot_probe.json")
iso_c.load_from_disk()
main.save_manager = iso_c
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c)
main.settings_store = load("res://presentation/settings_store.gd").new("user://fx_shot_probe_settings.cfg")
ProbeUiUtil.isolate_save(main, "fx_shot_probe", false, true, false)
for i in 8:
await get_tree().process_frame # UI 装配完成等足帧(canvas_item 空引用窗口)
var lvbtn: Button = _find_button_by_sub(main._start_overlay, "开始冒险")
var lvbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "开始冒险", "contains")
if lvbtn == null:
_fail("开始冒险按钮缺失")
return
@@ -68,9 +70,9 @@ func _ready() -> void:
if out.is_empty() or not bool(out[0].get("accepted", false)):
continue
dug += 1
await get_tree().create_timer(0.12).timeout
await get_tree().create_timer(T_DIG_FLIGHT).timeout
await _snap("fx_dig%d" % dug) # 行到达/弹道在飞窗口
await get_tree().create_timer(0.35).timeout
await get_tree().create_timer(T_SETTLE).timeout
await _snap("fx_settle%d" % dug) # 结算排播窗口
continue
# 无相邻可挖 → 走向有未开安全格邻居的已揭开格(挖掘期移动免费)
@@ -90,12 +92,12 @@ func _ready() -> void:
# 停手 → 轮末结算(敌方攻击弹道/受击闪白/结算排播在 0.2-1.6s 窗口流过)——连抓 6 帧
rules.settle_batch([{"action": "stop"}])
for i in 6:
await get_tree().create_timer(0.25).timeout
await get_tree().create_timer(T_ROUND_TICK).timeout
await _snap("fx_round%d" % (i + 1))
if dug == 0:
_fail("无安全格可挖(首板全雷不可信)")
return
await get_tree().create_timer(0.6).timeout
await get_tree().create_timer(T_FINAL).timeout
await _snap("fx_final")
if _problems.is_empty():
print("[fx_shot_probe] PASS — 演出帧 %d 张落 %s(挖格 %d)" % [dug * 2 + 1, out_dir, dug])
@@ -115,16 +117,6 @@ func _snap(tag: String) -> void:
img.save_png("%s/%s.png" % [out_dir, tag])
func _find_button_by_sub(root: Node, needle: String) -> Button:
if root is Button and String(root.text).contains(needle):
return root
for c in root.get_children():
var r := _find_button_by_sub(c, needle)
if r != null:
return r
return null
func _fail(msg: String) -> void:
_problems.append(msg)
push_error("[fx_shot_probe] FAIL — " + msg)
+18 -49
View File
@@ -396,13 +396,17 @@ static func _on_level_finished(p: Dictionary, st: Dictionary, logger) -> void:
static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void:
if String(st.get("policy", "normal")) == "perfect":
_autoplay_battle_perfect(runner, settlement, st, logger)
return
# 策略档经 _decide_act 单点分派(T6 样板收口批 2026-10-02——原 _autoplay_battle/_autoplay_battle_perfect
# 双循环逐行相同仅差 act 来源行,合并单循环;三档决策语义逐位不变,见 _decide_act)。
# 普通策略档 v3(2026-10-01 用户重定义):完美策略 + 受控失误注入——v2「新手级单步推理+盲挖
# 兜底」画像被真人实证推翻(零加成真人首通三关踩 23 雷 vs v2 普通档 L2 条件通关率 10.6%)。
# v3 语义 = 会玩的普通玩家:推理与完美档同源(subset 解算/标旗/卡死刷新),失误只注入两手——
# 标雷误挖(NORMAL_MINE_SLIP_P)+ 安全挖跳过(NORMAL_SKIP_DIG_P)。连续拒绝 ≥3 = 编排违约防御。
# 完美策略档(2026-09-14):单步决策循环——move/dig/flag 经 排雷玩法 settle_batch 提交,
# refresh 经 settlement.player_request_refresh(战斗结算 核心编排:行动经济 扣 RAP + 退役 + 当场换新板),
# stop 出内环走统一停手提交。连续拒绝 ≥3 = 编排违约防御(fail-fast 语义,停手防死循环)。
# 2026-10-01 上界修正:选路改目标价值评分(_route_pick)+ 商店贪心买齐(_autoplay_shop)——
# 「完美」须是元游戏意义上的上界(会购物会补给),不再只是扫雷子游戏上界。
var round_guard := 60
while st.result == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
round_guard -= 1
@@ -413,11 +417,7 @@ static func _autoplay_battle(runner, settlement, st: Dictionary, logger) -> void
while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \
and settlement.round_index == start_round and action_guard > 0 and rejects < 3:
action_guard -= 1
var act: Dictionary
if bool(st.get("adv", false)):
act = _apply_normal_slips(AutoPlayer.perfect_action(settlement.minesweeper, settlement), settlement, st) # v3:完美+失误
else:
act = AutoPlayer.novice_action(settlement.minesweeper) # 单关机制夹具:v2 新手画像保持
var act: Dictionary = _decide_act(st, settlement)
var kind := String(act.get("kind", "stop"))
if kind == "stop":
break
@@ -487,47 +487,16 @@ static func _gamble_action(plan: Dictionary, settlement) -> Dictionary:
return {"kind": "move", "cell": plan.hero_cell}
# 完美策略档战斗驱动(2026-09-14):单步决策循环——move/dig/flag 经 排雷玩法 settle_batch 提交,
# refresh 经 settlement.player_request_refresh(战斗结算 核心编排:行动经济 扣 RAP + 退役 + 当场换新板),
# stop 出内环走统一停手提交。连续拒绝 ≥3 = 编排违约防御(fail-fast 语义,停手防死循环)。
# 2026-10-01 上界修正:选路改目标价值评分(_route_pick)+ 商店贪心买齐(_autoplay_shop)——
# 「完美」须是元游戏意义上的上界(会购物会补给),不再只是扫雷子游戏上界。
static func _autoplay_battle_perfect(runner, settlement, st: Dictionary, logger) -> void:
var round_guard := 60
while st.result == "" and runner.current_battle() == settlement and settlement.battle_result == "" and round_guard > 0:
round_guard -= 1
st.rounds += 1
var start_round: int = settlement.round_index
var action_guard := 400
var rejects := 0
while runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" \
and settlement.round_index == start_round and action_guard > 0 and rejects < 3:
action_guard -= 1
var act := AutoPlayer.perfect_action(settlement.minesweeper, settlement)
var kind := String(act.get("kind", "stop"))
if kind == "stop":
break
var ok := false
if kind == "move":
ok = _submit_intent(settlement, "move", act.get("cell"), logger, start_round)
elif kind == "dig":
ok = _submit_intent(settlement, "dig", act.get("cell"), logger, start_round)
elif kind == "flag":
ok = _submit_intent(settlement, "flag_place", act.get("cell"), logger, start_round)
elif kind == "refresh":
ok = bool(settlement.player_request_refresh())
if logger != null:
logger.log_action("refresh", Vector2i(-1, -1), ok, {"round": start_round})
if ok:
if kind == "refresh":
st.refreshes = int(st.refreshes) + 1
rejects = 0
else:
rejects += 1
if runner.current_battle() == settlement and settlement.battle_result == "" and settlement.action_economy.phase == "Active" and settlement.round_index == start_round:
var out_s: Array = settlement.minesweeper.settle_batch([{"action": "stop"}])
if logger != null:
logger.log_action("stop", Vector2i(-1, -1), _accepted(out_s), {"round": start_round})
# 战斗决策单行分派(T6 双循环合并抽取——原两循环唯一差异行):
# perfect 档 = 完美策略单步决策(上界口径);
# normal 档 v3 = 完美+受控失误注入(冒险测量面 adv=true);单关机制夹具保持 v2 新手画像(adv 缺省)。
# 决策函数纯读板面/编排面(零 RNG 消费——失误流独走 st.prng,见 _apply_normal_slips)。
static func _decide_act(st: Dictionary, settlement) -> Dictionary:
if String(st.get("policy", "normal")) == "perfect":
return AutoPlayer.perfect_action(settlement.minesweeper, settlement)
if bool(st.get("adv", false)):
return _apply_normal_slips(AutoPlayer.perfect_action(settlement.minesweeper, settlement), settlement, st) # v3:完美+失误
return AutoPlayer.novice_action(settlement.minesweeper) # 单关机制夹具:v2 新手画像保持
static func _submit_intent(settlement, action: String, cell, logger, round_index: int) -> bool:
+3 -21
View File
@@ -5,7 +5,7 @@
# 运行:Godot_console.exe --headless --path . --script res://smoke/monte_carlo.gd
extends SceneTree
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口(批注 2026-09-15:原文件缺失 preload——HEAD 起即解析失败,本批顺手修复)
const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
const N := 30
const SEED0 := 42
@@ -43,7 +43,7 @@ func _run_encounter(db, L: int) -> void:
else:
timeouts += 1
print("=== L%d · %d 种子(败 %d / 超时 %d)===" % [L, N, losses, timeouts])
print(" 胜利轮数 均值 %.2f 中位 %d p10 %.1f p90 %.1f 分布 %s" % [_mean(rounds_v), _median(rounds_v), _pct(rounds_v, 0.1), _pct(rounds_v, 0.9), _hist(rounds_v)])
print(" 胜利轮数 均值 %.2f 中位 %d p10 %.1f p90 %.1f 分布 %s" % [_mean(rounds_v), _median(rounds_v), _pct_f(rounds_v, 0.1), _pct_f(rounds_v, 0.9), _hist(rounds_v)])
print(" 每轮承伤 均值 %.2f 中位 %.1f p90 %.1f" % [_mean(dmg_per_round), _median_f(dmg_per_round), _pct_f(dmg_per_round, 0.9)])
print(" 整场承伤 均值 %.1f 中位 %.0f(主角 max_hp=%d)" % [_mean(dmg_total), _median_f(dmg_total), int(db.hero.max_hp)])
print(" 开安全格 均值 %.1f 中位 %d" % [_mean(safe_opened), _median(safe_opened)])
@@ -72,21 +72,7 @@ func _run_battle(db, seed_val: int, L: int) -> Dictionary:
while out.result == "" and guard_rounds > 0:
guard_rounds -= 1
out.rounds += 1
var start_round: int = settlement.round_index
var minesweeper = settlement.minesweeper
var guard := 400
while settlement.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 settlement.action_economy.phase != "Active" or settlement.round_index != start_round:
break
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if settlement.action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "":
minesweeper.settle_batch([{"action": "stop"}])
BattleDriver.drive_round(settlement, func() -> bool: return out.result == "")
if out.result == "":
out.result = "timeout"
return out
@@ -114,10 +100,6 @@ func _median_f(a: Array) -> float:
return float(b[b.size() / 2])
func _pct(a: Array, p: float) -> float:
return _pct_f(a, p)
func _pct_f(a: Array, p: float) -> float:
if a.is_empty():
return 0.0
+112
View File
@@ -0,0 +1,112 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# 探针共用面:树寻址助手 + 存档/设置/元进度隔离样板单点(T6 样板收口批 2026-10-02)。
# 收口范围:ui_probe / ui_click_probe / shot_probe / fx_shot_probe 四探针的 7 处树寻址样板
# 与 7 处隔离样板(各探针隔离文件名后缀 tag 保持不变)。
# 用法:探针侧 `const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd")` 后静态调用
#(探针载体含 --script 场景两种,均无全局标识符——禁 class_name 裸引用)。
extends RefCounted
const SaveManagerScript := preload("res://core/save_manager.gd")
const MetaProgressionScript := preload("res://core/meta_progression.gd")
const SettingsStoreScript := preload("res://presentation/settings_store.gd")
# ── 树寻址(递归只读——按钮/标签句柄未存组合根成员的探针专用面) ──────────────
# 匹配模式参数化(历史两种语义,禁混用禁静默改变):
# mode="prefix" = text.begins_with(needle)(ui_probe / ui_click_probe 历史语义)
# mode="contains" = text.contains(needle)(shot_probe / fx_shot_probe 历史语义)
# 调用点替换时逐一核对原语义后选参。
static func find_button(root: Node, needle: String, mode: String = "prefix") -> Button:
if root == null:
return null
if root is Button and _text_match(String(root.text), needle, mode):
return root
for child in root.get_children():
var found := find_button(child, needle, mode)
if found != null:
return found
return null
# CheckButton 同款寻址(ui_click_probe 历史语义 = contains——设置面旧入口退役断言用)
static func find_check_button(root: Node, needle: String) -> CheckButton:
if root == null:
return null
if root is CheckButton and String(root.text).contains(needle):
return root
for child in root.get_children():
var found := find_check_button(child, needle)
if found != null:
return found
return null
# Label 按文案包含寻址(shot_probe GP 徽章读数锚)
static func find_label(root: Node, text_fragment: String) -> Label:
if root == null:
return null
if root is Label and String(root.text).contains(text_fragment):
return root
for child in root.get_children():
var found := find_label(child, text_fragment)
if found != null:
return found
return null
# 控件实测几何(shot_probe checker 文字框取样坐标源——禁硬编码像素,分辨率档位自适应)
static func label_rect(root: Node, text_fragment: String) -> Dictionary:
if root == null:
return {}
if root is Label and String(root.text).contains(text_fragment):
return {"x": root.global_position.x, "y": root.global_position.y,
"w": root.size.x, "h": root.size.y}
for child in root.get_children():
var r := label_rect(child, text_fragment)
if not r.is_empty():
return r
return {}
# 按脚本名寻址(resource_path contains——shot_probe hero 面板锚)
static func find_by_script(root: Node, script_name: String) -> Node:
if root == null:
return null
var s: Script = root.get_script()
if s != null and String(s.resource_path).contains(script_name):
return root
for child in root.get_children():
var r := find_by_script(child, script_name)
if r != null:
return r
return null
static func _text_match(text: String, needle: String, mode: String) -> bool:
return text.begins_with(needle) if mode == "prefix" else text.contains(needle)
# ── 存档/设置/元进度三面隔离(探针禁碰真实档 user://save.json——SaveManager 路径注入纪律) ──
# tag = 隔离档名(不带扩展名,如 "smoke_input_probe_save"——各探针后缀保持不变);先删后建防陈旧档泄入。
# mark_first_done = 对齐真实档游玩史口径(模板抽签路径不变);with_settings = 连设置面换注
#(隔离 cfg 名 = <tag>_settings.cfg,与被收口探针原文件名逐一同名);refresh_resume = 组合根
# 「继续上局」按钮重刷;swap_save = 是否换注 main.save_manager(营地先行段只换 meta 不换 save_manager)。
# 返回隔离 SaveManager 实例(调用方常需后续直读余额/重读档)。
static func isolate_save(main, tag: String, mark_first_done: bool = false, with_settings: bool = false,
refresh_resume: bool = false, swap_save: bool = true):
var save_path := "user://%s.json" % tag
for p in [save_path, save_path + ".tmp", "user://%s_settings.cfg" % tag]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
var iso = SaveManagerScript.new(save_path)
iso.load_from_disk()
if mark_first_done:
iso.mark_first_adventure_done()
if swap_save:
main.save_manager = iso
main.meta = MetaProgressionScript.new(main.db, iso)
if with_settings:
main.settings_store = SettingsStoreScript.new("user://%s_settings.cfg" % tag)
if refresh_resume:
main._refresh_resume_button()
return iso
+1
View File
@@ -0,0 +1 @@
uid://c1probeuiutil
+14 -71
View File
@@ -9,7 +9,9 @@
# 最小自检 = 四件存在且非零字节(像素方差断言归 tools/shot_checker.py——B 档骨架分工)。
extends Node
var main
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
var main: Node
var out_dir := ""
var _problems: Array = []
var _meta := {} # 控件实测几何(全局 rect——shot_checker 文字框/明度带取样坐标源,禁硬编码像素)
@@ -23,36 +25,28 @@ func _ready() -> void:
main = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
# ── 存档/元进度/设置三面隔离(ui_click_probe 同款——真档零污染) ──
for p0 in ["user://smoke_shot_probe.json", "user://smoke_shot_probe.json.tmp",
"user://smoke_shot_probe_settings.cfg"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0)
var iso_c = load("res://core/save_manager.gd").new("user://smoke_shot_probe.json")
iso_c.load_from_disk()
main.save_manager = iso_c
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c)
main.settings_store = load("res://presentation/settings_store.gd").new("user://smoke_shot_probe_settings.cfg")
# ── 存档/元进度/设置三面隔离(ui_click_probe 同款——真档零污染;历史语义 = contains 寻址) ──
ProbeUiUtil.isolate_save(main, "smoke_shot_probe", false, true, false)
await get_tree().process_frame
# ① 菜单(启动态)
_meta["menu_sub"] = _find_label_rect(main._start_overlay, "改造批")
_meta["menu_help"] = _find_label_rect(main._start_overlay, "玩法:")
_meta["menu_title"] = _find_label_rect(main._start_overlay, "Dungeon Sweeper")
_meta["menu_sub"] = ProbeUiUtil.label_rect(main._start_overlay, "改造批")
_meta["menu_help"] = ProbeUiUtil.label_rect(main._start_overlay, "玩法:")
_meta["menu_title"] = ProbeUiUtil.label_rect(main._start_overlay, "Dungeon Sweeper")
await _snap("menu")
# ② 营地
var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地")
var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "contains")
if campbtn == null:
_problems.append("营地入口缺失(开始层 campbtn)")
else:
campbtn.pressed.emit()
await get_tree().process_frame
await _snap("camp")
var close_btn: Button = _find_button_by_prefix(main.camp_overlay, "关闭营地")
var close_btn: Button = ProbeUiUtil.find_button(main.camp_overlay, "关闭营地", "contains")
if close_btn != null:
close_btn.pressed.emit()
await get_tree().process_frame
# ③ 关卡画面(开始冒险→停点出图——冒险开始即出图,停点前移批语义)
var lvbtn: Button = _find_button_by_prefix(main._start_overlay, "开始冒险")
var lvbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "开始冒险", "contains")
if lvbtn == null:
_problems.append("开始冒险按钮缺失(开始层 lvbtn)")
else:
@@ -61,7 +55,7 @@ func _ready() -> void:
await get_tree().process_frame
await _snap("level")
# GP 常驻徽章断言(迭代 9 卡⑪核验——读数=runner.gp 现值,刷新=写路径回执后重查〔open_choices〕)
var gp_label: Label = _find_label_by_text(main.level_screen, "◈ ")
var gp_label: Label = ProbeUiUtil.find_label(main.level_screen, "◈ ")
if gp_label == null or not gp_label.is_visible_in_tree():
_problems.append("GP 徽章不可见(关卡画面顶栏)")
elif main.level_screen.get("_runner") != null:
@@ -75,8 +69,8 @@ func _ready() -> void:
await get_tree().process_frame
await _snap("battle")
# 战斗屏文字框/明度带锚点(全局 rect——checker 取样坐标源)
_meta["battle_log_hint"] = _find_label_rect(main.stage, "结算明细")
_meta["hero_panel"] = _node_rect(_find_by_script(main, "hero_info_panel_view"))
_meta["battle_log_hint"] = ProbeUiUtil.label_rect(main.stage, "结算明细")
_meta["hero_panel"] = _node_rect(ProbeUiUtil.find_by_script(main, "hero_info_panel_view"))
_meta["stage_view"] = _node_rect(main.stage)
_meta["board_zone"] = _node_rect(main.board_zone)
# draw calls 真机测量(迭代 9 卡⑦——基线 270→目标 <200;窗口化渲染帧读数,
@@ -116,59 +110,8 @@ func _snap(key: String) -> void:
print("[shot_probe] saved %s (%dx%d)" % [key, img.get_width(), img.get_height()])
func _find_button_by_prefix(root: Node, prefix: String) -> Button:
if root == null:
return null
if root is Button and String(root.text).contains(prefix):
return root
for c in root.get_children():
var r := _find_button_by_prefix(c, prefix)
if r != null:
return r
return null
# 控件实测几何采集(checker 断言坐标源——禁硬编码像素,分辨率档位自适应)
func _find_label_rect(root: Node, text_fragment: String) -> Dictionary:
if root == null:
return {}
if root is Label and String(root.text).contains(text_fragment):
return {"x": root.global_position.x, "y": root.global_position.y,
"w": root.size.x, "h": root.size.y}
for c in root.get_children():
var r := _find_label_rect(c, text_fragment)
if not r.is_empty():
return r
return {}
func _find_label_by_text(root: Node, text_fragment: String) -> Label:
if root == null:
return null
if root is Label and String(root.text).contains(text_fragment):
return root
for c in root.get_children():
var r := _find_label_by_text(c, text_fragment)
if r != null:
return r
return null
func _node_rect(n: Node) -> Dictionary:
if n == null or not (n is CanvasItem):
return {}
return {"x": n.global_position.x, "y": n.global_position.y,
"w": n.size.x, "h": n.size.y}
func _find_by_script(root: Node, script_name: String) -> Node:
if root == null:
return null
var s: Script = root.get_script()
if s != null and String(s.resource_path).contains(script_name):
return root
for c in root.get_children():
var r := _find_by_script(c, script_name)
if r != null:
return r
return null
+3 -16
View File
@@ -7,6 +7,8 @@
# 运行:Godot_console.exe --headless --path . --script res://smoke/skill_rhythm_probe.gd
extends SceneTree
const BattleDriver := preload("res://smoke/battle_driver.gd") # T6 收口:挖掘驱动循环单点(策略仍归 AutoPlayer 单一实现)
func _initialize() -> void:
var db = load("res://core/config_db.gd").new()
db.load_tables("res://data/tables", false)
@@ -50,23 +52,8 @@ func _run_battle(db, seed_val: int, L: int, depth: int, casts: Dictionary) -> Di
casts[sid] = casts.get(sid, 0) + 1)
if not settlement.begin_battle({"level_index": L, "node_depth": depth, "hero_hp": int(db.hero.max_hp)}):
return out
var AutoPlayer = load("res://smoke/auto_player.gd")
var guard_rounds := 40
while out.result == "" and guard_rounds > 0:
guard_rounds -= 1
var start_round: int = settlement.round_index
var minesweeper = settlement.minesweeper
var guard := 400
while settlement.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 settlement.action_economy.phase != "Active" or settlement.round_index != start_round:
break
minesweeper.settle_batch([{"action": "dig", "cell": plan.dig_cell}])
if settlement.action_economy.phase == "Active" and settlement.round_index == start_round and out.result == "":
minesweeper.settle_batch([{"action": "stop"}])
BattleDriver.drive_round(settlement, func() -> bool: return out.result == "")
return out
+23 -17
View File
@@ -6,15 +6,20 @@
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) -> void:
func check(cond: bool, label: String, quiet: bool = false) -> void:
_checks += 1
if cond:
print(" [PASS] " + label)
if not quiet:
print(" [PASS] " + label)
elif quiet:
_failures += 1
print(" [FAIL] (silent check)") # 原 check_silent 输出逐位保持
else:
_failures += 1
print(" [FAIL] " + label)
@@ -22,7 +27,7 @@ func check(cond: bool, label: String) -> void:
func _initialize() -> void:
print("=== Dungeon Sweeper 垂直切片冒烟 ===")
var db = load("res://core/config_db.gd").new()
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
check(load_ok, "全部表加载 + 校验通过(含跨表护栏)")
if not load_ok:
@@ -238,7 +243,7 @@ func _smoke_adventure_api() -> void:
# 驱动首战到胜利 → 选路态(非战斗期)→ abandon 受理
var autoplay = load("res://smoke/level_autoplay.gd")
var battle = adr.runner().current_battle()
var st := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}
var st := _battle_st("normal")
var guard := 30
while adr.runner().current_battle() == battle and guard > 0:
guard -= 1
@@ -950,7 +955,8 @@ func _weaken_refresh_probe() -> Dictionary:
settlement._status_effects.apply_effect("weaken", src) # 续时:remaining ← 2,层数不动
for eff in settlement._status_effects.query_active("enemy"):
if String(eff.effect_id) == "weaken":
return {"layers": int(eff.layers), "remaining": int(eff.remaining_settlements), "pool": int(settlement._clamped_pool("enemy_output"))}
# T6 收口批:改走伤害控制器公有算术面 clamped_pool(原编排器 _clamped_pool 薄委托的直呼退役)
return {"layers": int(eff.layers), "remaining": int(eff.remaining_settlements), "pool": int(settlement._damage.clamped_pool("enemy_output"))}
return {}
@@ -1760,7 +1766,7 @@ func _smoke_encounter_roll() -> void:
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_silent(settlement.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}))
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))
@@ -2417,11 +2423,11 @@ func _has_effect_id(rows: Array, effect_id: String) -> bool:
return true
return false
func check_silent(cond: bool) -> void:
_checks += 1
if not cond:
_failures += 1
print(" [FAIL] (silent check)")
# 手搓战斗状态袋单点(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 用户拍板——开轮摇号出招系统) ====================
@@ -2883,7 +2889,7 @@ func _smoke_level_graph() -> void:
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 = {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}
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", "")))
@@ -2908,7 +2914,7 @@ func _smoke_level_graph() -> void:
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 = {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "perfect"}
var g_st = _battle_st("perfect")
var g_first = g_runner.current_battle()
var g_box := {}
g_first.battle_finished.connect(func(p):
@@ -2989,7 +2995,7 @@ func _smoke_level_graph() -> void:
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 = {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "perfect"}
var g3_st = _battle_st("perfect")
var g3_first = g3_runner.current_battle()
var g3_box := {}
g3_first.battle_finished.connect(func(p):
@@ -3035,7 +3041,7 @@ func _smoke_gp_wallet() -> void:
var rows: Array = []
battle.step_row.connect(func(row): rows.append(row))
battle.battle_finished.connect(func(p): fin["p"] = p)
var st := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}
var st := _battle_st("normal")
var guard := 30
while runner.current_battle() == battle and not fin.has("p") and guard > 0:
guard -= 1
@@ -3199,7 +3205,7 @@ func _smoke_shop_room() -> void:
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 := {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}
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
@@ -3294,7 +3300,7 @@ func _smoke_shop_room() -> void:
var b_end = runner.current_battle()
if b_end == null:
break
autoplay._autoplay_battle(runner, b_end, {"result": "", "rounds": 0, "refreshes": 0, "mine_hits": 0, "hero_dmg": 0, "policy": "normal"}, null)
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, "冒险收束钱包清零(局内货币生命周期终点——不入存档不入摘要)")
+20 -61
View File
@@ -11,8 +11,9 @@
extends Node
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4:自动玩家策略单一实现
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
var main
var main: Node
var _last_state := {}
var _stall_frames := 0
var _done := false
@@ -41,14 +42,11 @@ func _ready() -> void:
var t_ui_ready := Time.get_ticks_msec() - t0
# ---- 营地购买合成点击路径(元进度实现批——campbtn/购买/关闭全走真实按钮信号路径;
# 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json);陈旧档段首清理)----
for p0 in ["user://smoke_click_probe_camp.json", "user://smoke_click_probe_camp.json.tmp",
"user://smoke_click_probe_reset.json", "user://smoke_click_probe_reset.json.tmp"]:
for p0 in ["user://smoke_click_probe_reset.json", "user://smoke_click_probe_reset.json.tmp"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0)
var iso_c = load("res://core/save_manager.gd").new("user://smoke_click_probe_camp.json")
iso_c.load_from_disk()
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c)
var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地")
DirAccess.remove_absolute(p0) # 重置档段前清场兜底(营地/设置段不触该档——防陈旧断点泄入后续段)
var iso_c = ProbeUiUtil.isolate_save(main, "smoke_click_probe_camp", false, false, false, false)
var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "prefix")
if campbtn == null:
_problems.append("营地入口按钮缺失(开始层 campbtn)")
else:
@@ -90,7 +88,7 @@ func _ready() -> void:
iso_s.load_settings()
main.settings_store = iso_s
main.settings_overlay.retarget(iso_s)
var settings_btn: Button = _find_button_by_prefix(main._start_overlay, "设置")
var settings_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "设置", "prefix")
if settings_btn == null:
_problems.append("设置入口按钮缺失(开始层 settingsbtn)")
else:
@@ -100,7 +98,7 @@ func _ready() -> void:
_problems.append("设置面未打开(点击路径)")
else:
# 减动效转正双态(卡④ AC③):开=0.5 引擎态即时生效+落盘持久;关=1.0 回常态
var motion_btn: Button = _find_button_by_text(main.settings_overlay, "减动效") # 二轮 D2——开关已换 UiKit toggle 按钮形态
var motion_btn: Button = ProbeUiUtil.find_button(main.settings_overlay, "减动效", "contains") # 二轮 D2——开关已换 UiKit toggle 按钮形态
if motion_btn == null:
_problems.append("设置面减动效开关缺失")
else:
@@ -127,10 +125,10 @@ func _ready() -> void:
if absf(iso_s.volume_linear - 0.3) > 0.001:
_problems.append("音量未入 store(%f ≠ 0.3)" % iso_s.volume_linear)
# 调试入口默认隐藏门控(卡④ AC④——探针环境 DS_DEBUG 未设)
var dbg_btn: Button = _find_button_by_prefix(main._start_overlay, "调试")
var dbg_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "调试", "prefix")
if dbg_btn != null and dbg_btn.visible:
_problems.append("调试入口未隐藏(DS_DEBUG 门控失守——卡④ AC④)")
var settings_close: Button = _find_button_by_prefix(main.settings_overlay, "返回")
var settings_close: Button = ProbeUiUtil.find_button(main.settings_overlay, "返回", "prefix")
if settings_close == null:
_problems.append("设置面返回按钮缺失")
else:
@@ -139,25 +137,17 @@ func _ready() -> void:
if main.settings_overlay.visible or not main._start_overlay.visible:
_problems.append("设置面关闭后未恢复开始层")
# 旧入口退役断言(卡④ AC③——测试调整板不再含减动效开关)
var legacy_motion = _find_checkbutton_by_text(main.tune_panel, "减动效")
var legacy_motion = ProbeUiUtil.find_check_button(main.tune_panel, "减动效")
if legacy_motion != null:
_problems.append("调整板减动效旧入口未退役(设置面唯一入口失守——卡④ AC③)")
# D1 断言(二轮):退出游戏只在一级菜单——开始菜单有、设置面无
if _find_button_by_prefix(main._start_overlay, "退出游戏") == null:
if ProbeUiUtil.find_button(main._start_overlay, "退出游戏", "prefix") == null:
_problems.append("开始菜单缺退出游戏按钮(二轮 D1)")
if _find_button_by_prefix(main.settings_overlay, "退出游戏") != null:
if ProbeUiUtil.find_button(main.settings_overlay, "退出游戏", "prefix") != null:
_problems.append("设置面仍含退出游戏按钮(二轮 D1——只在一级菜单出现)")
# 换回全新零档(营地购买态不泄入后续冒险段——确定性;档案残留下次段首清理兜底)
for p0 in ["user://smoke_click_probe_reset.json", "user://smoke_click_probe_reset.json.tmp"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退
var iso_reset = load("res://core/save_manager.gd").new("user://smoke_click_probe_reset.json")
iso_reset.load_from_disk()
iso_reset.mark_first_adventure_done() # 对齐基线(真实档已有游玩史)——非首次脚本模式=逐关抽签模板
main.save_manager = iso_reset # 迭代 8 卡①——冒险断点写盘同步隔离(禁碰真实档 user://save.json)
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_reset)
main._refresh_resume_button()
var resume_btn0: Button = _find_button_by_prefix(main._start_overlay, "▶ 继续上局")
ProbeUiUtil.isolate_save(main, "smoke_click_probe_reset", true, false, true) # 零档换注 + 继续上局按钮重刷(原三行样板收口)
var resume_btn0: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 继续上局", "prefix")
if resume_btn0 == null:
_problems.append("继续上局按钮缺失(开始层 resumebtn——装配器)")
elif resume_btn0.visible:
@@ -227,7 +217,7 @@ func _ready() -> void:
_problems.append("大奖励屏打开时输入未锁定")
_inter_confirms += 1
if _inter_confirms == 1:
var next_btn: Button = _find_button_by_prefix(main._inter_overlay, "进入下一层")
var next_btn: Button = ProbeUiUtil.find_button(main._inter_overlay, "进入下一层", "prefix")
if next_btn == null:
_problems.append("大奖励屏确认按钮缺失")
else:
@@ -309,11 +299,11 @@ func _ready() -> void:
await get_tree().process_frame
if not main._start_overlay.visible:
_problems.append("断点段:终局返回未回开始菜单")
var resume_btn: Button = _find_button_by_prefix(main._start_overlay, "▶ 继续上局")
var resume_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 继续上局", "prefix")
if resume_btn != null and resume_btn.visible:
_problems.append("断点段:退出清断点后继续上局按钮未隐藏(卡②/T3 AC③)")
# ② 中断→续玩:新冒险达入口停点=断点在档 → 合成回菜单(模拟关游戏重开)→ 按钮点亮 → 点击恢复至停点
var lv_btn: Button = _find_button_by_prefix(main._start_overlay, "▶ 开始冒险")
var lv_btn: Button = ProbeUiUtil.find_button(main._start_overlay, "▶ 开始冒险", "prefix")
if lv_btn == null or resume_btn == null:
_problems.append("断点段:开始冒险/继续上局按钮缺失")
else:
@@ -356,7 +346,7 @@ func _ready() -> void:
await get_tree().process_frame
if not main.confirm_dialog.is_open() or main.adventure != null:
_problems.append("断点段:有断点点开始冒险未弹确认框(二轮 A 失守)")
var cancel_btn: Button = _find_button_by_text(main.confirm_dialog, "取消")
var cancel_btn: Button = ProbeUiUtil.find_button(main.confirm_dialog, "取消", "contains")
if cancel_btn == null:
_problems.append("断点段:确认框取消按钮缺失")
else:
@@ -366,7 +356,7 @@ func _ready() -> void:
_problems.append("断点段:取消未回开始菜单(二轮 A)")
lv_btn.pressed.emit()
await get_tree().process_frame
var confirm_btn: Button = _find_button_by_text(main.confirm_dialog, "开新冒险")
var confirm_btn: Button = ProbeUiUtil.find_button(main.confirm_dialog, "开新冒险", "contains")
if confirm_btn == null:
_problems.append("断点段:确认框开新冒险按钮缺失")
else:
@@ -561,34 +551,3 @@ func _dump_state() -> void:
String(main.runner.current_node().get("type", "")), str(main.bonus_overlay.visible),
str(main._inter_overlay.visible), str(main.shop_overlay.visible),
str(main.runner.next_candidate_ids())])
# 开始层按钮按文案前缀寻址(campbtn 句柄未存组合根成员——树遍历只读;与 ui_probe 同款助手)
func _find_button_by_prefix(root: Node, prefix: String) -> Button:
if root is Button and String(root.text).begins_with(prefix):
return root
for child in root.get_children():
var found := _find_button_by_prefix(child, prefix)
if found != null:
return found
return null
func _find_button_by_text(root: Node, needle: String) -> Button:
if root is Button and String(root.text).contains(needle):
return root
for child in root.get_children():
var found := _find_button_by_text(child, needle)
if found != null:
return found
return null
func _find_checkbutton_by_text(root: Node, needle: String) -> CheckButton:
if root is CheckButton and String(root.text).contains(needle):
return root
for child in root.get_children():
var found := _find_checkbutton_by_text(child, needle)
if found != null:
return found
return null
+8 -11
View File
@@ -13,7 +13,9 @@
# 运行:Godot_console.exe --headless --path . res://smoke/ui_input_probe.tscn
extends Node
var main
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:隔离样板单点
var main: Node
var _problems: Array = []
var _actions: Array = [] # action_requested 收集([cell, kind])
var _chords: Array = [] # chord_attempted 收集
@@ -26,16 +28,9 @@ func _ready() -> void:
add_child(main)
await get_tree().process_frame
# 存档隔离(迭代 8 卡①②——断点写盘面随冒险挂 main.save_manager;探针禁碰真实档 user://save.json,
# 同 ui_click_probe 隔离纪律。首次冒险布尔位置位=对齐真实档游玩史口径,模板抽签路径不变)
for p0 in ["user://smoke_input_probe_save.json", "user://smoke_input_probe_save.json.tmp"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退
var iso = load("res://core/save_manager.gd").new("user://smoke_input_probe_save.json")
iso.load_from_disk()
iso.mark_first_adventure_done()
main.save_manager = iso
main.meta = load("res://core/meta_progression.gd").new(main.db, iso)
main._refresh_resume_button()
# 同 ui_click_probe 隔离纪律。首次冒险布尔位置位=对齐真实档游玩史口径,模板抽签路径不变;
# isolate_save 先删后建——上次运行的残留断点会触发互斥确认早退)
ProbeUiUtil.isolate_save(main, "smoke_input_probe_save", true, false, true)
main._start_adventure_from_menu()
await get_tree().process_frame
# ---- ⑫ 图上命中真实输入(关卡画面实装批 2026-09-28 回归闸):入口停点候选节点中心
@@ -99,6 +94,8 @@ func _ready() -> void:
var maxl2 := 0
for k2 in layer2.keys():
maxl2 = maxi(maxl2, int(layer2[k2]))
# 常数锚(画布拟合探针):70/122/26/8 与 presentation/views/level_map_screen.gd 的 _content_size
# 公式(70 + maxl*122 + NODE_HALF.x + 8)同源复刻——改常数须两侧同步(本探针只读断言层,真源在屏)
var need_x2: float = 70.0 + maxl2 * 122.0 + 26.0 + 8.0
if float(ro2._canvas.custom_minimum_size.x) < need_x2 - 0.5:
_pre_fail("画布拟合不足(min.x=%.0f < 最深内容 %.0f——深层节点不可点回归)" % [ro2._canvas.custom_minimum_size.x, need_x2])
+6 -11
View File
@@ -4,22 +4,17 @@
# 运行:Godot_console.exe --headless --path . res://smoke/ui_layout_probe.tscn
extends Node
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:隔离样板单点
func _ready() -> void:
await get_tree().process_frame # autoload 就绪
var main = load("res://presentation/main.tscn").instantiate()
var main: Node = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
# 存档隔离(迭代 8 卡①②——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律)
for p0 in ["user://smoke_layout_probe_save.json", "user://smoke_layout_probe_save.json.tmp"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退
var iso = load("res://core/save_manager.gd").new("user://smoke_layout_probe_save.json")
iso.load_from_disk()
iso.mark_first_adventure_done()
main.save_manager = iso
main.meta = load("res://core/meta_progression.gd").new(main.db, iso)
main._refresh_resume_button()
# 存档隔离(迭代 8 卡①②——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律;
# isolate_save 先删后建——上次运行的残留断点会触发互斥确认早退)
ProbeUiUtil.isolate_save(main, "smoke_layout_probe_save", true, false, true)
main._start_adventure_from_menu()
for i in range(3):
await get_tree().process_frame
+8 -26
View File
@@ -7,10 +7,12 @@
# 运行:Godot_console.exe --headless --path . res://smoke/ui_probe.tscn
extends Node
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:树寻址/隔离样板单点
func _ready() -> void:
await get_tree().process_frame # autoload 就绪
var main = load("res://presentation/main.tscn").instantiate()
var main: Node = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
var problems: Array = []
@@ -20,11 +22,10 @@ func _ready() -> void:
get_tree().quit(1)
return
# ---- 营地覆盖层(元进度实现批——主菜单入口/开合/余额文案/购买禁用态;
# 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json——SaveManager 路径注入纪律)----
var iso = load("res://core/save_manager.gd").new("user://smoke_ui_probe_camp.json")
iso.load_from_disk()
main.meta = load("res://core/meta_progression.gd").new(main.db, iso)
var campbtn: Button = _find_button_by_prefix(main._start_overlay, "营地")
# 隔离存档换注 main.meta 保确定性(禁碰真实档 user://save.json——SaveManager 路径注入纪律;
# 营地先行段只换 meta 不换 save_manager——冒险段换注零档在前)----
ProbeUiUtil.isolate_save(main, "smoke_ui_probe_camp", false, false, false, false)
var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "prefix")
if campbtn == null:
problems.append("营地入口按钮缺失(开始层 campbtn)")
else:
@@ -52,15 +53,7 @@ func _ready() -> void:
if main.camp_overlay.visible or not main._start_overlay.visible:
problems.append("营地关闭后未恢复开始层(开合往返失守)")
# 换回全新零档(营地段购买态不泄入后续冒险段——确定性;先删后建防陈旧档泄入)
for p in ["user://smoke_ui_probe_camp.json", "user://smoke_ui_probe_camp.json.tmp"]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
var iso_zero = load("res://core/save_manager.gd").new("user://smoke_ui_probe_camp.json")
iso_zero.load_from_disk()
iso_zero.mark_first_adventure_done() # 对齐真实档游玩史口径(模板抽签路径不变)
main.save_manager = iso_zero # 迭代 8 卡①——断点写盘面同步隔离(禁碰真实档)
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_zero)
main._refresh_resume_button()
ProbeUiUtil.isolate_save(main, "smoke_ui_probe_camp", true, false, true)
main._start_adventure_from_menu()
await get_tree().process_frame
await get_tree().process_frame
@@ -244,14 +237,3 @@ func _ready() -> void:
push_error("[ui_probe] FAIL — " + p)
print("[ui_probe] FAIL — " + "; ".join(problems))
get_tree().quit(1)
# 开始层按钮按文案前缀寻址(campbtn 句柄未存组合根成员——树遍历只读;text 前缀匹配首枚按钮)
func _find_button_by_prefix(root: Node, prefix: String) -> Button:
if root is Button and String(root.text).begins_with(prefix):
return root
for child in root.get_children():
var found := _find_button_by_prefix(child, prefix)
if found != null:
return found
return null
+9 -11
View File
@@ -6,10 +6,14 @@
# 运行:Godot_console.exe --headless --path . res://smoke/ui_tune_probe.tscn
extends Node
const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") # T6 收口:隔离样板单点
const SIM_GUARD_FRAMES := 1800 # ⑤ N=10 headless 模拟防挂死帧预算上限(ADR-0004:探针内停滞检测第一道——坏态不等满预算才报,超时即断言失败)
func _ready() -> void:
await get_tree().process_frame
var main = load("res://presentation/main.tscn").instantiate()
var main: Node = load("res://presentation/main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
var problems: Array = []
@@ -37,7 +41,7 @@ func _ready() -> void:
main.tune_panel.set_sliders(100, 0)
main.tune_panel.set_sim_count(10)
main.tune_panel.press_simulate_for_probe()
var guard := 1800 # N=10 headless 模拟防挂死上限(ADR-0004:探针内停滞检测第一道)
var guard := SIM_GUARD_FRAMES
while main.tune_panel.is_running() and guard > 0:
guard -= 1
await get_tree().process_frame
@@ -59,15 +63,9 @@ func _ready() -> void:
problems.append("hero 表被运行时改写(E4 违例:atk=%s hp=%s,期望恒基线 %d/%d)" % [str(main.db.hero.atk), str(main.db.hero.max_hp), base_atk, base_hp])
# ⑧ 单次加成生效回归断言:应用加成 + 启动单场战斗 → 呈现快照 hero 必须是"基线 × 单次加成"
# (攻击 ×2 / 生命 +50%)——旧缺陷(残留值被当基线再乘)在 encounter 参数链上同样被抓获
# 存档隔离(迭代 8 卡①——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律)
for p0 in ["user://smoke_tune_probe_save.json", "user://smoke_tune_probe_save.json.tmp"]:
if FileAccess.file_exists(p0):
DirAccess.remove_absolute(p0) # 段首清场——上次运行的残留断点会触发互斥确认早退
var iso_tune = load("res://core/save_manager.gd").new("user://smoke_tune_probe_save.json")
iso_tune.load_from_disk()
iso_tune.mark_first_adventure_done()
main.save_manager = iso_tune
main.meta = load("res://core/meta_progression.gd").new(main.db, iso_tune)
# 存档隔离(迭代 8 卡①——停点断点写盘随 main.save_manager;探针禁碰真实档,同族探针隔离纪律;
# isolate_save 先删后建——上次运行的残留断点会触发互斥确认早退)
ProbeUiUtil.isolate_save(main, "smoke_tune_probe_save", true, false, false)
main.tune_panel.set_sliders(100, 50)
main.tune_panel.press_apply_for_probe()
main._start_adventure_from_menu() # 完整冒险路径(唯一试玩入口;_start_single 按语义清零滑条 = 恒基线,不承载加成断言)
@@ -16,15 +16,15 @@ const DamageControllerScript := preload("res://core/damage_controller.gd")
const TEST_SEED := 20260928 # 夹具种子(基建值非玩法数值——回放确定性契约的输入)
var _failures := 0
var _checks := 0
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
var _rows: Array = [] # 当前 run 的行协议收集(_on_row 绑定法——避 lambda 多行控制流坑)
func _init() -> void:
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
_check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
Kit.check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
if load_ok:
_test_five_recipes_and_replay(db)
_test_policy_immediate_mode(db)
@@ -33,21 +33,7 @@ func _init() -> void:
_test_hero_death_halt(db)
_test_ac_merge(db)
_test_pool_clamp(db)
if _failures > 0:
push_error("[damage_controller_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[damage_controller_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("damage_controller_test"))
# ---- 夹具 ----
@@ -84,7 +70,7 @@ func _run_recipe_sequence(db) -> Array:
var units = h.units
var ctrl = h.ctrl
if not h.spawn_ok:
_check("前置:units.spawn(1,0) 成功(遭遇行在案)", false)
Kit.check("前置:units.spawn(1,0) 成功(遭遇行在案)", false)
return []
var hero_before: int = int(units.hero_unit().hp)
var enemy_hp_before: Array = []
@@ -122,27 +108,27 @@ func _run_recipe_sequence(db) -> Array:
for id in [t1, t2, t3, t4, t5]:
if ctrl.outcome(int(id)) != "redeemed":
outcomes_redeemed = false
_check("五配方单据 flush 后全部 redeemed(B 模式段界结账)", outcomes_redeemed)
Kit.check("五配方单据 flush 后全部 redeemed(B 模式段界结账)", outcomes_redeemed)
var hero_after: int = int(units.hero_unit().hp)
var r2: Dictionary = _row_by_display_prefix("T2敌方技")
var r5: Dictionary = _row_by_display_prefix("T5反噬")
_check("主角扣血 = 敌方技行值 + 反噬行值(逐行归因闭合)",
Kit.check("主角扣血 = 敌方技行值 + 反噬行值(逐行归因闭合)",
hero_before - hero_after == int(r2.get("value", -1)) + int(r5.get("value", -1)))
_check("敌方技行值 ≥ 下限 + rider(rider 后置不进乘区)", int(r2.get("value", 0)) >= floor_v + floor_v)
_check("反噬行值 = 落地下限(中性无类型表值)", int(r5.get("value", 0)) == floor_v)
Kit.check("敌方技行值 ≥ 下限 + rider(rider 后置不进乘区)", int(r2.get("value", 0)) >= floor_v + floor_v)
Kit.check("反噬行值 = 落地下限(中性无类型表值)", int(r5.get("value", 0)) == floor_v)
var r1: Dictionary = _row_by_display_prefix("T1主角攻击")
var ex1: Dictionary = r1.get("extra", {})
_check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected)
_check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.roll_faces))
Kit.check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected)
Kit.check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.roll_faces))
var tier_ok := false
for dv in h.db.settlement.roll_tier_discounts:
if int(dv) == int(ex1.get("tier", -1)):
tier_ok = true
if int(ex1.get("tier", -1)) == 100:
tier_ok = true
_check("掷点档位 ∈ 表档位值 ∪ 满额", tier_ok)
Kit.check("掷点档位 ∈ 表档位值 ∪ 满额", tier_ok)
var r4: Dictionary = _row_by_display_prefix("T4群伤")
var alive_at_blast: int = int(r4.get("extra", {}).get("count", -1))
@@ -158,10 +144,10 @@ func _run_recipe_sequence(db) -> Array:
var attack_landed: int = int(r1.get("value", 0))
var typed_landed: int = int(_row_by_display_prefix("T3克制直伤").get("value", 0))
if any_enemy_dead:
_check("敌方总扣血 ≥ 攻击+克制+群伤(死亡溢出合法,下界归因)",
Kit.check("敌方总扣血 ≥ 攻击+克制+群伤(死亡溢出合法,下界归因)",
enemy_drop_total >= attack_landed + typed_landed + int(r4.get("value", 0)) * maxi(0, alive_at_blast))
else:
_check("敌方总扣血 = 攻击 + 克制直伤 + 群伤×存活数(逐行归因闭合)",
Kit.check("敌方总扣血 = 攻击 + 克制直伤 + 群伤×存活数(逐行归因闭合)",
enemy_drop_total == attack_landed + typed_landed + int(r4.get("value", 0)) * alive_at_blast)
var protocol_ok := true
@@ -174,7 +160,7 @@ func _run_recipe_sequence(db) -> Array:
protocol_ok = false
if not (r.extra is Dictionary) or not r.extra.has("target"):
protocol_ok = false
_check("行协议公共字段封闭(type/source_class/display/value≥下限/extra.target)", protocol_ok)
Kit.check("行协议公共字段封闭(type/source_class/display/value≥下限/extra.target)", protocol_ok)
# 复现序列(供双跑对拍):逐行 (display, value)
var seq: Array = []
@@ -185,9 +171,9 @@ func _run_recipe_sequence(db) -> Array:
func _test_five_recipes_and_replay(db) -> void:
var run1: Array = _run_recipe_sequence(db)
_check("五配方序列产出 5 行伤害行", run1.size() == 5)
Kit.check("五配方序列产出 5 行伤害行", run1.size() == 5)
var run2: Array = _run_recipe_sequence(db)
_check("同种子双跑行序列逐位一致(回放确定性契约)", run1 == run2)
Kit.check("同种子双跑行序列逐位一致(回放确定性契约)", run1 == run2)
# ---- A 模式(开单即结——回切位) ----
@@ -201,8 +187,8 @@ func _test_policy_immediate_mode(db) -> void:
var floor_v: int = int(h.db.settlement.damage_floor)
var t: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit",
"A模式反噬", "棋盘兑付", floor_v, DamageControllerScript.TARGET_HERO, 1))
_check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed")
_check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.hero.max_hp) - floor_v)
Kit.check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed")
Kit.check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.hero.max_hp) - floor_v)
# ---- 来源敌死亡:队列中未结单据作废(死亡即不动手) ----
@@ -221,8 +207,8 @@ func _test_source_death_void(db) -> void:
var t: int = h.ctrl.submit(ticket)
h.units.apply_damage(src, int(src.max_hp)) # 敌当场倒下 → unit_died → 该敌未结单作废
h.ctrl.flush()
_check("来源敌死亡后其未结单据 voided(死亡即不动手)", h.ctrl.outcome(t) == "voided")
_check("作废单零行零扣血", int(h.units.hero_unit().hp) == hero_before and _rows.is_empty())
Kit.check("来源敌死亡后其未结单据 voided(死亡即不动手)", h.ctrl.outcome(t) == "voided")
Kit.check("作废单零行零扣血", int(h.units.hero_unit().hp) == hero_before and _rows.is_empty())
# ---- requires 前置门:前置单未实际有效兑付 → 后继单静默作废;无存活敌 blast = 合法零兑付 ----
@@ -238,7 +224,7 @@ func _test_requires_gate_and_zero(db) -> void:
var big: int = front_hp * 10
var t_clear: int = h.ctrl.flush_submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward",
"清场群伤", "棋盘兑付", big, DamageControllerScript.TARGET_ALL_ALIVE, 1))
_check("flush_submit 同步开单即结(清图批契约)", h.ctrl.outcome(t_clear) == "redeemed" and h.units.query_alive("enemy").is_empty())
Kit.check("flush_submit 同步开单即结(清图批契约)", h.ctrl.outcome(t_clear) == "redeemed" and h.units.query_alive("enemy").is_empty())
var t_zero: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward",
"空场群伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_ALL_ALIVE, 1))
var gated_ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack",
@@ -246,8 +232,8 @@ func _test_requires_gate_and_zero(db) -> void:
gated_ticket["requires"] = t_zero
var t_gated: int = h.ctrl.submit(gated_ticket)
h.ctrl.flush()
_check("无存活敌 blast = 合法零兑付(zero 非 voided)", h.ctrl.outcome(t_zero) == "zero")
_check("前置单未实际有效 → 后继单 voided(requires 门)", h.ctrl.outcome(t_gated) == "voided")
Kit.check("无存活敌 blast = 合法零兑付(zero 非 voided)", h.ctrl.outcome(t_zero) == "zero")
Kit.check("前置单未实际有效 → 后继单 voided(requires 门)", h.ctrl.outcome(t_gated) == "voided")
# ---- 主角倒下:flush 余单全部作废(_halted 终止) ----
@@ -263,8 +249,8 @@ func _test_hero_death_halt(db) -> void:
var t_after: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit",
"余单", "棋盘兑付", 1, DamageControllerScript.TARGET_HERO, 2))
h.ctrl.flush()
_check("致死单 redeemed 且主角 hp 归零", h.ctrl.outcome(t_kill) == "redeemed" and int(h.units.hero_unit().hp) == 0)
_check("主角倒下后余单 voided(flush 终止)", h.ctrl.outcome(t_after) == "voided")
Kit.check("致死单 redeemed 且主角 hp 归零", h.ctrl.outcome(t_kill) == "redeemed" and int(h.units.hero_unit().hp) == 0)
Kit.check("主角倒下后余单 voided(flush 终止)", h.ctrl.outcome(t_after) == "voided")
# ---- AC 合流:override 入参 / 表值兜底 / ac 型状态叠层(不入池) ----
@@ -275,10 +261,10 @@ func _test_ac_merge(db) -> void:
if not h.spawn_ok:
return
var base_ac: int = int(h.db.hero.armor_class)
_check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac)
Kit.check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac)
var override_v: int = base_ac + int(h.db.settlement.damage_floor)
h.ctrl.set_hero_ac_override(override_v)
_check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v)
Kit.check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v)
# ac 型状态效果叠其上(找 hero 侧 unit=ac 行)
var ac_eff := {}
for row in h.db.status_effects.rows:
@@ -286,12 +272,12 @@ func _test_ac_merge(db) -> void:
ac_eff = row
break
if ac_eff.is_empty():
_check("前置:状态表存在 hero 侧 ac 型行", false)
Kit.check("前置:状态表存在 hero 侧 ac 型行", false)
return
var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0}
h.effects.apply_effect(String(ac_eff.effect_id), src)
var expected: int = override_v + int(ac_eff.magnitude)
_check("ac 型效果叠层入 AC(层数×幅度,不入池)", h.ctrl.defender_ac(h.units.hero_unit()) == expected)
Kit.check("ac 型效果叠层入 AC(层数×幅度,不入池)", h.ctrl.defender_ac(h.units.hero_unit()) == expected)
# ---- 池乘区收口:池内加算 → clamp;ac 型不入池 ----
@@ -303,7 +289,7 @@ func _test_pool_clamp(db) -> void:
return
var lo: int = int(h.db.settlement.pool_clamp_lo)
var hi: int = int(h.db.settlement.pool_clamp_hi)
_check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0)
Kit.check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0)
# 找 hero_offense 池的 percent 型 hero 侧效果行
var pool_eff := {}
for row in h.db.status_effects.rows:
@@ -313,14 +299,14 @@ func _test_pool_clamp(db) -> void:
pool_eff = row
break
if pool_eff.is_empty():
_check("前置:hero_offense 池存在 percent 型 hero 侧行", false)
Kit.check("前置:hero_offense 池存在 percent 型 hero 侧行", false)
return
var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0}
var first_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src)
var expected1: int = clampi(int(pool_eff.magnitude), lo, hi)
_check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense") == expected1)
Kit.check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense") == expected1)
var second_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src)
_check("refresh 政策重复授予受理且层数恒 1(叠层改续时裁决)——池值不变",
Kit.check("refresh 政策重复授予受理且层数恒 1(叠层改续时裁决)——池值不变",
second_ok and h.ctrl.clamped_pool("hero_offense") == expected1)
# ac 型效果不入 percent 池
var ac_eff2 := {}
@@ -331,7 +317,7 @@ func _test_pool_clamp(db) -> void:
if not ac_eff2.is_empty():
var before: int = h.ctrl.clamped_pool("hero_offense")
h.effects.apply_effect(String(ac_eff2.effect_id), src)
_check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before)
Kit.check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before)
# stack 政策效果:全新容器授予至满层 → 再授拒绝(满层拒绝原子——容器零写入)
var stack_eff := {}
for row3 in h.db.status_effects.rows:
@@ -339,7 +325,7 @@ func _test_pool_clamp(db) -> void:
stack_eff = row3
break
if stack_eff.is_empty():
_check("前置:状态表存在 stack 政策行", false)
Kit.check("前置:状态表存在 stack 政策行", false)
return
var effects2 = StatusContainerScript.new(h.db)
effects2.notify_battle_start()
@@ -348,4 +334,4 @@ func _test_pool_clamp(db) -> void:
for i in range(max_n + 1):
if effects2.apply_effect(String(stack_eff.effect_id), src):
granted += 1
_check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n)
Kit.check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n)
@@ -6,36 +6,21 @@ extends SceneTree
const DamageMathScript := preload("res://core/damage_math.gd")
const ConfigDBScript := preload("res://core/config_db.gd")
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
var _failures := 0
var _checks := 0
var db
func _init() -> void:
db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
_check("前置:settlement 表加载零 error", load_ok and db.diagnostics_count("error") == 0)
Kit.check("前置:settlement 表加载零 error", load_ok and db.diagnostics_count("error") == 0)
if load_ok:
_test_pool_damage()
_test_counter_component()
_test_roll_discount_percent()
_test_rolled_component()
if _failures > 0:
push_error("[damage_math_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[damage_math_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("damage_math_test"))
func _floor_val() -> int:
@@ -46,23 +31,23 @@ func _floor_val() -> int:
func _test_pool_damage() -> void:
var d := _floor_val() + 9 # 表值推导的任意正探针量(非玩法行值)
_check("中性双池 (0,0) 原样透传", DamageMathScript.pool_damage(d, 0, 0) == d)
_check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.settlement.pool_clamp_lo)) <= d)
_check("正主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_hi), 0) >= d)
_check("主角池单调不减(+50% vs +49% 探针档)", DamageMathScript.pool_damage(d, 50, 0) >= DamageMathScript.pool_damage(d, 49, 0))
_check("对称性:池值互换结果不变(乘算交换)", DamageMathScript.pool_damage(d, 30, -20) == DamageMathScript.pool_damage(d, -20, 30))
_check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_lo)) >= 0)
Kit.check("中性双池 (0,0) 原样透传", DamageMathScript.pool_damage(d, 0, 0) == d)
Kit.check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.settlement.pool_clamp_lo)) <= d)
Kit.check("正主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_hi), 0) >= d)
Kit.check("主角池单调不减(+50% vs +49% 探针档)", DamageMathScript.pool_damage(d, 50, 0) >= DamageMathScript.pool_damage(d, 49, 0))
Kit.check("对称性:池值互换结果不变(乘算交换)", DamageMathScript.pool_damage(d, 30, -20) == DamageMathScript.pool_damage(d, -20, 30))
Kit.check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_lo)) >= 0)
# ---- 克制分量:空桶不吃修正恒 0;落地下限保底([1,+∞) 全来源)----
func _test_counter_component() -> void:
var floor_v := _floor_val()
_check("空桶(点数≤0)恒 0 不吃修正", DamageMathScript.counter_component(0, floor_v, floor_v) == 0
Kit.check("空桶(点数≤0)恒 0 不吃修正", DamageMathScript.counter_component(0, floor_v, floor_v) == 0
and DamageMathScript.counter_component(-1, 5, floor_v) == 0)
_check("正向修正直加", DamageMathScript.counter_component(floor_v, floor_v, floor_v) == floor_v * 2)
_check("负修正触底保落地伤害下限", DamageMathScript.counter_component(floor_v, -floor_v * 10, floor_v) == floor_v)
_check("下限参数缺省 = 0(无下限语义面)", DamageMathScript.counter_component(floor_v, -floor_v * 10) == 0)
Kit.check("正向修正直加", DamageMathScript.counter_component(floor_v, floor_v, floor_v) == floor_v * 2)
Kit.check("负修正触底保落地伤害下限", DamageMathScript.counter_component(floor_v, -floor_v * 10, floor_v) == floor_v)
Kit.check("下限参数缺省 = 0(无下限语义面)", DamageMathScript.counter_component(floor_v, -floor_v * 10) == 0)
# ---- 掷点折扣查档(纯函数):满阈值恒全额;档位边界/末档语义与表数组一致 ----
@@ -71,22 +56,22 @@ func _test_roll_discount_percent() -> void:
var threshold := 10 # 查档探针阈值(夹具值——档位参数本身全部来自表)
var bounds: Array = db.settlement.roll_tier_bounds
var discounts: Array = db.settlement.roll_tier_discounts
_check("前置:档位数 = 边界数 + 1(查档契约——档数 = 边界数 + 1)", discounts.size() == bounds.size() + 1 and discounts.size() > 0)
_check("掷点 ≥ 阈值恒全额 100", DamageMathScript.roll_discount_percent(threshold, threshold, bounds, discounts) == 100
Kit.check("前置:档位数 = 边界数 + 1(查档契约——档数 = 边界数 + 1)", discounts.size() == bounds.size() + 1 and discounts.size() > 0)
Kit.check("掷点 ≥ 阈值恒全额 100", DamageMathScript.roll_discount_percent(threshold, threshold, bounds, discounts) == 100
and DamageMathScript.roll_discount_percent(threshold + 5, threshold, bounds, discounts) == 100)
# 缺口 = 1:落首档(缺口 ∈ [1, bounds[0]] → discounts[0])
var tier0_expected: int = int(discounts[0])
if int(bounds[0]) >= 1:
_check("缺口 1 落首档", DamageMathScript.roll_discount_percent(threshold - 1, threshold, bounds, discounts) == tier0_expected)
Kit.check("缺口 1 落首档", DamageMathScript.roll_discount_percent(threshold - 1, threshold, bounds, discounts) == tier0_expected)
# 缺口超末边界:落末档
var last_expected: int = int(discounts[-1])
_check("缺口超末边界落末档", DamageMathScript.roll_discount_percent(threshold - int(bounds[-1]) - 1, threshold, bounds, discounts) == last_expected)
Kit.check("缺口超末边界落末档", DamageMathScript.roll_discount_percent(threshold - int(bounds[-1]) - 1, threshold, bounds, discounts) == last_expected)
# 档位值降序(重档折扣更深)——表自检
var descending := true
for i in range(discounts.size() - 1):
if int(discounts[i]) < int(discounts[i + 1]):
descending = false
_check("档位值随档位加深不升(折扣单调)", descending)
Kit.check("档位值随档位加深不升(折扣单调)", descending)
# ---- 吃掷点分量算术:折扣→(暴击)→floor→+克制修正(单一取整链)----
@@ -94,9 +79,9 @@ func _test_roll_discount_percent() -> void:
func _test_rolled_component() -> void:
var base := float(_floor_val() + 7) # 表值推导探针基线
var crit_mult: int = int(db.settlement.crit_multiplier)
_check("满额折扣无暴击 = floor(基线) + 修正 0", DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) == int(floor(base)))
_check("满额折扣暴击按表倍率放大", DamageMathScript.rolled_component(base, 100, true, crit_mult, 0) == int(floor(base * float(crit_mult) / 100.0)))
_check("折扣降低不高于满额", DamageMathScript.rolled_component(base, 50, false, crit_mult, 0) <= DamageMathScript.rolled_component(base, 100, false, crit_mult, 0))
_check("克制修正加在 floor 之后(加法核)", DamageMathScript.rolled_component(base, 100, false, crit_mult, _floor_val()) == int(floor(base)) + _floor_val())
_check("复合序 = 基线×折扣%×暴击%(floor 只在链末)",
Kit.check("满额折扣无暴击 = floor(基线) + 修正 0", DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) == int(floor(base)))
Kit.check("满额折扣暴击按表倍率放大", DamageMathScript.rolled_component(base, 100, true, crit_mult, 0) == int(floor(base * float(crit_mult) / 100.0)))
Kit.check("折扣降低不高于满额", DamageMathScript.rolled_component(base, 50, false, crit_mult, 0) <= DamageMathScript.rolled_component(base, 100, false, crit_mult, 0))
Kit.check("克制修正加在 floor 之后(加法核)", DamageMathScript.rolled_component(base, 100, false, crit_mult, _floor_val()) == int(floor(base)) + _floor_val())
Kit.check("复合序 = 基线×折扣%×暴击%(floor 只在链末)",
DamageMathScript.rolled_component(base, 50, true, crit_mult, 0) == int(floor(base * 50.0 / 100.0 * float(crit_mult) / 100.0)))
@@ -12,35 +12,19 @@ const ConfigDBScript := preload("res://core/config_db.gd")
const RngServiceScript := preload("res://core/rng_service.gd")
const StatusContainerScript := preload("res://core/status_container.gd")
const DamageControllerScript := preload("res://core/damage_controller.gd")
var _failures := 0
var _checks := 0
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
func _init() -> void:
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
_check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
Kit.check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
if load_ok:
_test_damage_controller_vocabularies()
_test_rng_tag_closure(db)
_test_ticket_nine_columns()
_test_status_boolean_acceptance(db)
if _failures > 0:
push_error("[system_contracts_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[system_contracts_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("system_contracts_test"))
# ---- B1 兑付配方词表:五配方/三目标规则/双结账政策——常量非空且互异(封闭枚举负空间) ----
@@ -55,11 +39,11 @@ func _test_damage_controller_vocabularies() -> void:
for k in kinds:
if String(k).is_empty():
nonempty = false
_check("五配方常量非空(开单面唯一分派键)", nonempty)
Kit.check("五配方常量非空(开单面唯一分派键)", nonempty)
var unique_kinds: Dictionary = {}
for k in kinds:
unique_kinds[String(k)] = true
_check("五配方常量互异(词表封闭无别名)", unique_kinds.size() == kinds.size())
Kit.check("五配方常量互异(词表封闭无别名)", unique_kinds.size() == kinds.size())
var targets: Array = [
DamageControllerScript.TARGET_FRONT_ALIVE, DamageControllerScript.TARGET_HERO,
DamageControllerScript.TARGET_ALL_ALIVE,
@@ -67,8 +51,8 @@ func _test_damage_controller_vocabularies() -> void:
var unique_targets: Dictionary = {}
for t in targets:
unique_targets[String(t)] = true
_check("三目标规则常量互异(结账时刻现场解析词表)", unique_targets.size() == targets.size())
_check("双结账政策常量互异(A 即结 / B 攒批回切位)",
Kit.check("三目标规则常量互异(结账时刻现场解析词表)", unique_targets.size() == targets.size())
Kit.check("双结账政策常量互异(A 即结 / B 攒批回切位)",
String(DamageControllerScript.POLICY_IMMEDIATE) != String(DamageControllerScript.POLICY_SEGMENT))
@@ -76,9 +60,9 @@ func _test_damage_controller_vocabularies() -> void:
func _test_rng_tag_closure(db) -> void:
var stream_label: String = String(db.settlement.armor_roll_stream)
_check("settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)",
Kit.check("settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)",
RngServiceScript.PURPOSE_TAGS.has(stream_label))
_check("RngService 用途标签词表非空且互异(封闭语义由流改标签)",
Kit.check("RngService 用途标签词表非空且互异(封闭语义由流改标签)",
RngServiceScript.PURPOSE_TAGS.size() > 0)
@@ -95,9 +79,9 @@ func _test_ticket_nine_columns() -> void:
for c in columns:
if not t.has(c):
all_present = false
_check("单据九栏 + 可选两栏缺省齐备(开单面只写差异栏前提)", all_present)
_check("缺省无类型轴(attack_type 空 = 恒不查克制矩阵)", String(t.attack_type) == "")
_check("缺省无前置依赖(requires = -1)", int(t.requires) == -1)
Kit.check("单据九栏 + 可选两栏缺省齐备(开单面只写差异栏前提)", all_present)
Kit.check("缺省无类型轴(attack_type 空 = 恒不查克制矩阵)", String(t.attack_type) == "")
Kit.check("缺省无前置依赖(requires = -1)", int(t.requires) == -1)
func _bare_controller():
@@ -129,18 +113,18 @@ func _test_status_boolean_acceptance(db) -> void:
var any_row: Dictionary = {}
if db.status_effects.rows.size() > 0:
any_row = db.status_effects.rows[0]
_check("前置:状态表非空", not any_row.is_empty())
Kit.check("前置:状态表非空", not any_row.is_empty())
if any_row.is_empty():
return
var probe_id: String = String(any_row.effect_id)
var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0}
_check("域闭授予拒绝(EC1——战斗域外 apply_effect = false)", effects.apply_effect(probe_id, src) == false)
_check("未知 effect_id 拒绝(EC2)", effects.apply_effect("no_such_effect_id__", src) == false)
Kit.check("域闭授予拒绝(EC1——战斗域外 apply_effect = false)", effects.apply_effect(probe_id, src) == false)
Kit.check("未知 effect_id 拒绝(EC2)", effects.apply_effect("no_such_effect_id__", src) == false)
effects.notify_battle_start()
_check("开域后受理已知 id(布尔受理口径 true 通道)", effects.apply_effect(probe_id, src) == true)
Kit.check("开域后受理已知 id(布尔受理口径 true 通道)", effects.apply_effect(probe_id, src) == true)
var active: Array = effects.query_active(String(any_row.target_side))
var projected := false
for a in active:
if String(a.effect_id) == probe_id:
projected = int(a.effective_value) == int(a.layers) * int(a.snap_magnitude)
_check("受理后投影 effective_value = 层数×授予时快照(抗热重载 EC9 面)", projected)
Kit.check("受理后投影 effective_value = 层数×授予时快照(抗热重载 EC9 面)", projected)
+15 -27
View File
@@ -6,13 +6,7 @@
extends SceneTree
const AuxItemsScript := preload("res://core/aux_items.gd")
var _problems: Array = []
func check(cond: bool, msg: String) -> void:
if not cond:
_problems.append(msg)
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:check/汇总退出样板单点(静默收集通道)
class StubTable:
@@ -59,39 +53,39 @@ func _init() -> void:
var inv = _new_inv()
var r1: Dictionary = inv.add_item("sapper_order", "safe_cell", Vector2i(3, 4), 2)
var r2: Dictionary = inv.add_item("nest_pendulum", "flag_hit", Vector2i(5, 6), 3)
check(int(r1.handle) == 1 and int(r2.handle) == 2, "句柄受理序单调(1→2——零随机)")
check(String(r1.source_class) == "safe_cell" and r1.origin_cell == Vector2i(3, 4) and int(r1.round_index) == 2,
Kit.check_quiet(int(r1.handle) == 1 and int(r2.handle) == 2, "句柄受理序单调(1→2——零随机)")
Kit.check_quiet(String(r1.source_class) == "safe_cell" and r1.origin_cell == Vector2i(3, 4) and int(r1.round_index) == 2,
"授予来源可归因(source/cell/round)")
var inv_proj: Array = inv.query_inventory(Callable())
check(inv_proj.size() == 2 and not bool(inv_proj[0].used), "库存投影两件未用")
check(bool(inv.consume(int(r1.handle)).get("used", false)), "consume 返回已用快照回执")
check(inv.consume(int(r1.handle)).is_empty(), "Used 不可逆——二次 consume 拒绝(空回执)")
check(int(inv.inventory_count()) == 2, "Used 保留只读(库存计数不变)")
Kit.check_quiet(inv_proj.size() == 2 and not bool(inv_proj[0].used), "库存投影两件未用")
Kit.check_quiet(bool(inv.consume(int(r1.handle)).get("used", false)), "consume 返回已用快照回执")
Kit.check_quiet(inv.consume(int(r1.handle)).is_empty(), "Used 不可逆——二次 consume 拒绝(空回执)")
Kit.check_quiet(int(inv.inventory_count()) == 2, "Used 保留只读(库存计数不变)")
# ② 真视恒可用+边缘窗口∩板(3×3 中心贴角——角 4 格)
var v_ok: Dictionary = inv.validate_use(int(r2.handle), Vector2i(0, 0), Vector2i(10, 10), Callable())
check(bool(v_ok.ok) and v_ok.resolved.size() == 4,
Kit.check_quiet(bool(v_ok.ok) and v_ok.resolved.size() == 4,
"真视边缘窗口=窗口∩板(角 4 格——护栏只缩不扩)")
# 预读点击级零信息:中心板内但回调恒 false(全已开)→拒绝零消耗
var inv2 = _new_inv()
var rp: Dictionary = inv2.add_item("loot_compass", "safe_cell", Vector2i(1, 1), 1)
var v_zero: Dictionary = inv2.validate_use(int(rp.handle), Vector2i(5, 5), Vector2i(10, 10), Callable())
check(not bool(v_zero.ok) and int(inv2.inventory_count()) == 1,
Kit.check_quiet(not bool(v_zero.ok) and int(inv2.inventory_count()) == 1,
"预读点击级零信息拒绝(不消耗——库存不变)")
# 目标越板
var v_oob: Dictionary = inv2.validate_use(int(rp.handle), Vector2i(12, 5), Vector2i(10, 10), Callable())
check(not bool(v_zero.ok) and not bool(v_oob.ok), "越板目标拒绝")
Kit.check_quiet(not bool(v_zero.ok) and not bool(v_oob.ok), "越板目标拒绝")
# ③ 叠加层+三清(retire 不动库存;reset 全清)
inv2.apply_overlay("mine_reveal", v_ok.resolved)
inv2.apply_overlay("reward_preview", [Vector2i(5, 5)])
var ov: Dictionary = inv2.query_overlays()
check(ov.mine_reveal.size() == 4 and ov.reward_preview.size() == 1, "叠加层两集落位(真视 4/预读 1)")
Kit.check_quiet(ov.mine_reveal.size() == 4 and ov.reward_preview.size() == 1, "叠加层两集落位(真视 4/预读 1)")
inv2.retire_overlays()
check(inv2.query_overlays().mine_reveal.is_empty() and int(inv2.inventory_count()) == 1,
Kit.check_quiet(inv2.query_overlays().mine_reveal.is_empty() and int(inv2.inventory_count()) == 1,
"换图三清:叠加层消亡+库存不动(关卡域)")
inv2.reset()
check(inv2.inventory_count() == 0 and inv2.probe().used == 0, "关卡末 reset 全清(幂等)")
Kit.check_quiet(inv2.inventory_count() == 0 and inv2.probe().used == 0, "关卡末 reset 全清(幂等)")
# ④ 零随机重放:同操作序列两遍,投影逐位相同
var replay_a: Array = []
@@ -106,13 +100,7 @@ func _init() -> void:
replay_a = [inv3.query_inventory(Callable()), inv3.query_overlays(), inv3.probe()]
else:
replay_b = [inv3.query_inventory(Callable()), inv3.query_overlays(), inv3.probe()]
check(str(replay_a) == str(replay_b), "零随机重放:库存+叠加层+探针投影逐位相同")
Kit.check_quiet(str(replay_a) == str(replay_b), "零随机重放:库存+叠加层+探针投影逐位相同")
# ---- 汇总 ----
if _problems.is_empty():
print("[aux_items_test] PASS — 授予归因/Used 不可逆/真视恒可用/零信息拒绝/边缘窗口/三清/零随机重放 全过")
quit(0)
else:
for p in _problems:
push_error("[aux_items_test] FAIL — " + p)
quit(1)
quit(Kit.finish("aux_items_test", "PASS — 授予归因/Used 不可逆/真视恒可用/零信息拒绝/边缘窗口/三清/零随机重放 全过"))
+42 -54
View File
@@ -8,16 +8,11 @@ extends SceneTree
const SaveManagerScript := preload("res://core/save_manager.gd")
const AdventureRunnerScript := preload("res://core/adventure_runner.gd")
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:check/汇总退出样板单点(静默收集通道)
var _problems: Array = []
var _bp_path := "user://test_breakpoint_resume.json"
func check(cond: bool, msg: String) -> void:
if not cond:
_problems.append(msg)
func _init() -> void:
var db = load("res://core/config_db.gd").new()
if not db.load_tables("res://data/tables", false):
@@ -32,64 +27,64 @@ func _init() -> void:
var adr_a = AdventureRunnerScript.new(db, rng_a)
adr_a.set_save_manager(sm_a)
var params := {"hero_override": {}, "aux_unlocked": {}}
check(adr_a.start_adventure(1234, params), "冒险启动(入口停点)")
check(sm_a.has_breakpoint(), "入口停点断点在档")
check(int((sm_a.breakpoint_snapshot() as Dictionary).get("level_index", -1)) == 1, "断点关卡序号 = 1")
check(adr_a.abandon(), "入口停点放弃受理(非战斗期)")
check(not sm_a.has_breakpoint() and sm_a.write_to_disk(), "放弃清断点+写盘(退出=清除断点)")
Kit.check_quiet(adr_a.start_adventure(1234, params), "冒险启动(入口停点)")
Kit.check_quiet(sm_a.has_breakpoint(), "入口停点断点在档")
Kit.check_quiet(int((sm_a.breakpoint_snapshot() as Dictionary).get("level_index", -1)) == 1, "断点关卡序号 = 1")
Kit.check_quiet(adr_a.abandon(), "入口停点放弃受理(非战斗期)")
Kit.check_quiet(not sm_a.has_breakpoint() and sm_a.write_to_disk(), "放弃清断点+写盘(退出=清除断点)")
var sm_reload = SaveManagerScript.new(_bp_path)
sm_reload.load_from_disk()
check(not sm_reload.has_breakpoint(), "放弃后重读档无断点(「继续上局」隐藏口径)")
Kit.check_quiet(not sm_reload.has_breakpoint(), "放弃后重读档无断点(「继续上局」隐藏口径)")
# ---- Part 2:中断基线——入口停点后选路进战,记板指纹与计数器 ----
var rng_b = load("res://core/rng_service.gd").new()
var sm_b = SaveManagerScript.new(_bp_path)
var adr_b = AdventureRunnerScript.new(db, rng_b)
adr_b.set_save_manager(sm_b)
check(adr_b.start_adventure(1234, params), "基线冒险启动(同种子 1234)")
Kit.check_quiet(adr_b.start_adventure(1234, params), "基线冒险启动(同种子 1234)")
var runner_b = adr_b.runner()
var c0: int = rng_b.counter()
var cand_b: Array = runner_b.next_candidate_ids()
var hp0: int = runner_b.hero_hp
var bp_disk: Dictionary = sm_b.breakpoint_snapshot()
check(int(bp_disk.get("rng_counter", -1)) == c0, "断点计数器 = 当前取数号位(快照契约)")
check(runner_b.pick_next_node(int(cand_b[0])), "基线选路进首战")
Kit.check_quiet(int(bp_disk.get("rng_counter", -1)) == c0, "断点计数器 = 当前取数号位(快照契约)")
Kit.check_quiet(runner_b.pick_next_node(int(cand_b[0])), "基线选路进首战")
var battle_b = runner_b.current_battle()
check(battle_b != null, "基线首战开打")
Kit.check_quiet(battle_b != null, "基线首战开打")
var fp_b := _board_fingerprint(battle_b.minesweeper.current_board())
var c1: int = rng_b.counter()
# ---- Part 3:续玩确定性——新进程对象自盘上断点重建,同种子续玩板逐位一致 ----
var sm_c = SaveManagerScript.new(_bp_path)
check(sm_c.load_from_disk() and sm_c.has_breakpoint(), "断点自盘读回在档")
Kit.check_quiet(sm_c.load_from_disk() and sm_c.has_breakpoint(), "断点自盘读回在档")
var bp_c: Dictionary = sm_c.breakpoint_snapshot()
var rng_c = load("res://core/rng_service.gd").new()
var adr_c = AdventureRunnerScript.new(db, rng_c)
check(adr_c.resume_adventure(bp_c, params), "续玩受理")
Kit.check_quiet(adr_c.resume_adventure(bp_c, params), "续玩受理")
var runner_c = adr_c.runner()
check(int(adr_c.master_seed) == 1234, "续玩主种子回填")
check(int(rng_c.counter()) == c0, "续玩取数号位 = 断点号位(零重耗零跳号)")
check(runner_c.hero_hp == hp0, "续玩生命账本回填")
Kit.check_quiet(int(adr_c.master_seed) == 1234, "续玩主种子回填")
Kit.check_quiet(int(rng_c.counter()) == c0, "续玩取数号位 = 断点号位(零重耗零跳号)")
Kit.check_quiet(runner_c.hero_hp == hp0, "续玩生命账本回填")
var cand_c: Array = runner_c.next_candidate_ids()
check(cand_c.size() == cand_b.size() and int(cand_c[0]) == int(cand_b[0]), "续玩候选一致(停点重建)")
check(runner_c.awaiting_choice(), "续玩处选路停点态")
check(runner_c.pick_next_node(int(cand_c[0])), "续玩选路进首战")
Kit.check_quiet(cand_c.size() == cand_b.size() and int(cand_c[0]) == int(cand_b[0]), "续玩候选一致(停点重建)")
Kit.check_quiet(runner_c.awaiting_choice(), "续玩处选路停点态")
Kit.check_quiet(runner_c.pick_next_node(int(cand_c[0])), "续玩选路进首战")
var battle_c = runner_c.current_battle()
var fp_c := _board_fingerprint(battle_c.minesweeper.current_board())
check(fp_c == fp_b, "续玩首战棋盘与中断前时间线逐位一致(同种子同板)")
check(int(rng_c.counter()) == c1, "续玩开板后号位与基线一致")
Kit.check_quiet(fp_c == fp_b, "续玩首战棋盘与中断前时间线逐位一致(同种子同板)")
Kit.check_quiet(int(rng_c.counter()) == c1, "续玩开板后号位与基线一致")
# ---- Part 4:续玩幂等——同一断点二次续玩(新进程对象)结果逐位同 ----
var sm_d = SaveManagerScript.new(_bp_path)
sm_d.load_from_disk()
var rng_d = load("res://core/rng_service.gd").new()
var adr_d = AdventureRunnerScript.new(db, rng_d)
check(adr_d.resume_adventure(sm_d.breakpoint_snapshot(), params), "二次续玩受理")
Kit.check_quiet(adr_d.resume_adventure(sm_d.breakpoint_snapshot(), params), "二次续玩受理")
var runner_d = adr_d.runner()
check(int(rng_d.counter()) == c0 and runner_d.hero_hp == hp0, "二次续玩状态逐位同(幂等)")
check(runner_d.pick_next_node(int(runner_d.next_candidate_ids()[0])), "二次续玩选路")
Kit.check_quiet(int(rng_d.counter()) == c0 and runner_d.hero_hp == hp0, "二次续玩状态逐位同(幂等)")
Kit.check_quiet(runner_d.pick_next_node(int(runner_d.next_candidate_ids()[0])), "二次续玩选路")
var battle_d = runner_d.current_battle()
check(_board_fingerprint(battle_d.minesweeper.current_board()) == fp_b, "二次续玩板逐位同(幂等)")
Kit.check_quiet(_board_fingerprint(battle_d.minesweeper.current_board()) == fp_b, "二次续玩板逐位同(幂等)")
# ---- Part 5:三选一停点中断→续玩(迭代 10 奖励重构——候选不重抽、已兑付不重付、次数计数一致)----
var rng_e = load("res://core/rng_service.gd").new()
@@ -97,50 +92,43 @@ func _init() -> void:
var adr_e = AdventureRunnerScript.new(db, rng_e)
adr_e.set_save_manager(sm_e)
var params_e := {"hero_override": {}, "aux_unlocked": {"blast_kit": true}}
check(adr_e.start_adventure(4321, params_e), "P5 冒险启动(seed 4321)")
Kit.check_quiet(adr_e.start_adventure(4321, params_e), "P5 冒险启动(seed 4321)")
adr_e._bonus_taken["attack"] = 1 # 白盒预置计数(断点附随件往返的判据面——计数不可由加算区反推)
adr_e._on_level_finished({"result": "victory", "hp": 30, "node_index": 1, "node_id": 1, "node_type": "shop"})
var cands_e: Array = (adr_e._bonus_pending.get("candidates", []) as Array).duplicate()
check(not cands_e.is_empty(), "P5 非末关通关三选一停点在案(直调 victory——L1 非末关)")
Kit.check_quiet(not cands_e.is_empty(), "P5 非末关通关三选一停点在案(直调 victory——L1 非末关)")
var sm_f = SaveManagerScript.new(_bp_path)
check(sm_f.load_from_disk() and sm_f.has_breakpoint(), "P5 选择停点断点在档(选择停点可中断契约)")
Kit.check_quiet(sm_f.load_from_disk() and sm_f.has_breakpoint(), "P5 选择停点断点在档(选择停点可中断契约)")
var bp_f: Dictionary = sm_f.breakpoint_snapshot()
var bp_f_cands: Array = (bp_f.get("bonus_pending", {}) as Dictionary).get("candidates", []) as Array
check(bp_f_cands == cands_e, "P5 断点附随件含候选(bonus_pending 往返)")
check(int((bp_f.get("bonus_taken", {}) as Dictionary).get("attack", -1)) == 1, "P5 断点附随件含次数计数(bonus_taken 往返)")
Kit.check_quiet(bp_f_cands == cands_e, "P5 断点附随件含候选(bonus_pending 往返)")
Kit.check_quiet(int((bp_f.get("bonus_taken", {}) as Dictionary).get("attack", -1)) == 1, "P5 断点附随件含次数计数(bonus_taken 往返)")
var got_cands := {"cands": [] as Array}
var adr_f = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
adr_f.set_save_manager(sm_f) # 兑付后停点快照落盘承载(与真机组合根同构)
adr_f.bonus_choice_presented.connect(func(p): got_cands["cands"] = (p.get("candidates", []) as Array).duplicate())
check(adr_f.resume_adventure(bp_f, params_e), "P5 续玩受理(选择态分支)")
check(adr_f.runner() == null, "P5 续玩不重跑关卡(三选一停点直回,_start_current_level 未走)")
check(got_cands["cands"] == cands_e, "P5 续玩候选不重抽(逐位一致)")
check(int(adr_f._bonus_taken["attack"]) == 1, "P5 次数计数恢复一致(牌库剔除态续接)")
check(not adr_f.continue_to_next_level(), "P5 未兑付推进拒绝(续玩态零自动推进同契约)")
check(adr_f.pick_bonus(String(cands_e[0])), "P5 点选兑付受理")
Kit.check_quiet(adr_f.resume_adventure(bp_f, params_e), "P5 续玩受理(选择态分支)")
Kit.check_quiet(adr_f.runner() == null, "P5 续玩不重跑关卡(三选一停点直回,_start_current_level 未走)")
Kit.check_quiet(got_cands["cands"] == cands_e, "P5 续玩候选不重抽(逐位一致)")
Kit.check_quiet(int(adr_f._bonus_taken["attack"]) == 1, "P5 次数计数恢复一致(牌库剔除态续接)")
Kit.check_quiet(not adr_f.continue_to_next_level(), "P5 未兑付推进拒绝(续玩态零自动推进同契约)")
Kit.check_quiet(adr_f.pick_bonus(String(cands_e[0])), "P5 点选兑付受理")
var picked_e: String = String(adr_f._bonus_result.get("picked", ""))
if adr_f._bonus_taken.has(picked_e):
check(int(adr_f._bonus_taken[picked_e]) == (2 if picked_e == "attack" else 1),
Kit.check_quiet(int(adr_f._bonus_taken[picked_e]) == (2 if picked_e == "attack" else 1),
"P5 兑付计数一致(%s——预置 1 + 本次兑付)" % picked_e)
check(sm_f.has_breakpoint(), "P5 兑付后停点快照在档(已兑付待推进态可中断)")
Kit.check_quiet(sm_f.has_breakpoint(), "P5 兑付后停点快照在档(已兑付待推进态可中断)")
var bp_g: Dictionary = sm_f.breakpoint_snapshot()
var adr_g = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
adr_g.set_save_manager(sm_f) # 已兑付态续玩的后续快照同承载
var got_result := {"r": {}}
adr_g.bonus_resolved.connect(func(r): got_result["r"] = r.duplicate())
check(adr_g.resume_adventure(bp_g, params_e), "P5 已兑付态续玩受理")
check(String(got_result["r"].get("picked", "")) == picked_e, "P5 已兑付不重付(resolved 重发同件)")
check(adr_g.continue_to_next_level(), "P5 已兑付续玩后推进受理(下一关开始)")
Kit.check_quiet(adr_g.resume_adventure(bp_g, params_e), "P5 已兑付态续玩受理")
Kit.check_quiet(String(got_result["r"].get("picked", "")) == picked_e, "P5 已兑付不重付(resolved 重发同件)")
Kit.check_quiet(adr_g.continue_to_next_level(), "P5 已兑付续玩后推进受理(下一关开始)")
_clear_disk()
if _problems.is_empty():
print("[breakpoint_resume_test] PASS — 断点往返/续玩确定性/幂等/退出清断点 全绿")
quit(0)
else:
for p in _problems:
push_error("[breakpoint_resume_test] FAIL — " + p)
print("[breakpoint_resume_test] FAIL — " + p)
quit(1)
quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点 全绿"))
func _board_fingerprint(b) -> String:
@@ -7,27 +7,12 @@
extends SceneTree
const ConfigDBScript := preload("res://core/config_db.gd")
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:BAD 表目录夹具共享(copy_tables)
const SRC_DIR := "res://data/tables"
const BAD_DIR := "user://aux_fixture_target_violation"
func _copy_tables() -> bool:
DirAccess.make_dir_recursive_absolute(BAD_DIR)
var dir := DirAccess.open(SRC_DIR)
if dir == null:
return false
dir.list_dir_begin()
var f := dir.get_next()
while f != "":
if not dir.current_is_dir():
var err := dir.copy(SRC_DIR + "/" + f, BAD_DIR + "/" + f)
if err != OK:
return false
f = dir.get_next()
return true
func _inject_violation() -> bool:
var path := BAD_DIR + "/aux_items.tres"
var f := FileAccess.open(path, FileAccess.READ)
@@ -58,7 +43,7 @@ func _inject_violation() -> bool:
func _init() -> void:
if not _copy_tables() or not _inject_violation():
if not Kit.copy_tables(SRC_DIR, BAD_DIR) or not _inject_violation():
push_error("[aux_target_violation_test] 夹具目录准备失败")
quit(2)
return
+28 -44
View File
@@ -5,16 +5,14 @@
extends SceneTree
const ConfigDBScript := preload("res://core/config_db.gd")
var _failures := 0
var _checks := 0
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
func _init() -> void:
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
_check("全部表加载 + 校验通过(含跨表护栏零 error 诊断)", load_ok and db.diagnostics_count("error") == 0)
_check("is_ready 通道随加载置位", db.is_ready)
Kit.check("全部表加载 + 校验通过(含跨表护栏零 error 诊断)", load_ok and db.diagnostics_count("error") == 0)
Kit.check("is_ready 通道随加载置位", db.is_ready)
if not load_ok:
for d in db.diagnostics():
if d.severity == "error":
@@ -25,21 +23,7 @@ func _init() -> void:
_test_shop_accessors(db)
_test_meta_accessors(db)
_test_manifest_dir_sync(db)
if _failures > 0:
push_error("[config_db_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[config_db_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("config_db_test"))
# ---- 进度坐标 ceil 解析(R1:向上下取整保证偏难;越最大行 clamp 取最大值 + 警告去重)----
@@ -67,34 +51,34 @@ func _test_progress_resolution(db) -> void:
# 精确命中:请求 = 表内现存 (L,D) → 原行返回(字段逐位相等)
var probe_row: Dictionary = bg_rows[0]
var got: Dictionary = db.resolve_board_gen(int(probe_row.level_index), int(probe_row.node_depth))
_check("进度解析精确命中返回原行(board_gen 首行)",
Kit.check("进度解析精确命中返回原行(board_gen 首行)",
int(got.get("level_index", -99)) == int(probe_row.level_index)
and int(got.get("node_depth", -99)) == int(probe_row.node_depth)
and String(got.get("board_type", "")) == String(probe_row.get("board_type", "")))
# enemy 行集同通道:同 (L,D) 精确行存在(关卡节点地图 前置——battle 节点双表精确行契约)
var en_got: Dictionary = db.resolve_enemy_rowset(int(probe_row.level_index), int(probe_row.node_depth))
_check("enemy 行集同坐标解析非空(双表精确行契约)", not en_got.is_empty())
Kit.check("enemy 行集同坐标解析非空(双表精确行契约)", not en_got.is_empty())
# 深度 clamp:请求深度超最大行 → 取该关最大深度行 + 警告诊断一次(去重)
db.reset_diagnostics()
var deep: Dictionary = db.resolve_board_gen(min_l, int(min_depths[-1]) + 1)
_check("深度越最大行 clamp 取该关最大深度行",
Kit.check("深度越最大行 clamp 取该关最大深度行",
int(deep.get("level_index", -99)) == min_l and int(deep.get("node_depth", -99)) == int(min_depths[-1]))
_check("clamp 解析发警告诊断(resolve_depth_clamp)", db.diagnostics_count("warning") >= 1)
Kit.check("clamp 解析发警告诊断(resolve_depth_clamp)", db.diagnostics_count("warning") >= 1)
var found_clamp := false
for d in db.diagnostics():
if d.category == "resolve_depth_clamp":
found_clamp = true
_check("警告类别 = resolve_depth_clamp", found_clamp)
Kit.check("警告类别 = resolve_depth_clamp", found_clamp)
var warn_before: int = db.diagnostics_count("warning")
db.resolve_board_gen(min_l, int(min_depths[-1]) + 1)
_check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before)
Kit.check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before)
# 关卡 clamp:请求关卡超最大行 → 取最大关卡行
db.reset_diagnostics()
var high: Dictionary = db.resolve_board_gen(max_l + 1, 0)
_check("关卡越最大行 clamp 取最大关卡行", int(high.get("level_index", -99)) == max_l)
Kit.check("关卡越最大行 clamp 取最大关卡行", int(high.get("level_index", -99)) == max_l)
# ---- 变异池门控(unlock_level 闸 + ∅ board_types 通配)----
@@ -114,12 +98,12 @@ func _test_variant_pool_gate(db) -> void:
var tag: String = String(gated_row.board_types[0])
var before: Array = db.variant_pool(u - 1, tag)
var after: Array = db.variant_pool(u, tag)
_check("变异池 unlock 前不含未解锁行(level=unlock−1)", not _rows_contain(before, gated_row))
_check("变异池 unlock 当级含该行", _rows_contain(after, gated_row))
Kit.check("变异池 unlock 前不含未解锁行(level=unlock−1)", not _rows_contain(before, gated_row))
Kit.check("变异池 unlock 当级含该行", _rows_contain(after, gated_row))
if not wildcard_row.is_empty():
var u2: int = int(wildcard_row.unlock_level)
var any_pool: Array = db.variant_pool(u2, "nonexistent_board_type_tag")
_check("∅ board_types 行通配(任一 board_type 命中)", _rows_contain(any_pool, wildcard_row))
Kit.check("∅ board_types 行通配(任一 board_type 命中)", _rows_contain(any_pool, wildcard_row))
func _rows_contain(pool: Array, row: Dictionary) -> bool:
@@ -134,26 +118,26 @@ func _rows_contain(pool: Array, row: Dictionary) -> bool:
func _test_shop_accessors(db) -> void:
var products: Array = db.shop_products()
if products.is_empty():
_check("ShopTable products 行族非空(表已落籍前提)", false)
Kit.check("ShopTable products 行族非空(表已落籍前提)", false)
return
_check("ShopTable products 行族非空", true)
Kit.check("ShopTable products 行族非空", true)
var first: Dictionary = products[0]
var got: Dictionary = db.shop_product_row(String(first.product_id))
_check("shop_product_row 按 product_id 精确命中首行", got == first)
_check("shop_product_row 未知 id 返回空字典(容错通道)", db.shop_product_row("no_such_product_id__").is_empty())
Kit.check("shop_product_row 按 product_id 精确命中首行", got == first)
Kit.check("shop_product_row 未知 id 返回空字典(容错通道)", db.shop_product_row("no_such_product_id__").is_empty())
var tier_rows: Array = db._shop_rows("gp_drops")
if not tier_rows.is_empty():
var tier0: String = String(tier_rows[0].tier)
_check("shop_gp_drop_row 按 tier 命中", not db.shop_gp_drop_row(tier0).is_empty())
Kit.check("shop_gp_drop_row 按 tier 命中", not db.shop_gp_drop_row(tier0).is_empty())
var bonus_rows: Array = db._shop_rows("level_gp_bonus")
if not bonus_rows.is_empty():
var blv: int = int(bonus_rows[0].level_index)
_check("shop_level_gp_bonus 按关卡序号命中行值", db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus))
_check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.shop_level_gp_bonus(-1) == 0)
Kit.check("shop_level_gp_bonus 按关卡序号命中行值", db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus))
Kit.check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.shop_level_gp_bonus(-1) == 0)
var price_rows: Array = db._shop_rows("price_level_bonus")
if not price_rows.is_empty():
var plv: int = int(price_rows[0].level_index)
_check("shop_price_level_bonus 按关卡序号命中行值", db.shop_price_level_bonus(plv) == int(price_rows[0].bonus))
Kit.check("shop_price_level_bonus 按关卡序号命中行值", db.shop_price_level_bonus(plv) == int(price_rows[0].bonus))
# ---- MetaTable 消费面 ----
@@ -161,19 +145,19 @@ func _test_shop_accessors(db) -> void:
func _test_meta_accessors(db) -> void:
var forge_rows: Array = db._meta_rows("forge_products")
if forge_rows.is_empty():
_check("MetaTable forge_products 行族非空", false)
Kit.check("MetaTable forge_products 行族非空", false)
else:
var attr0: String = String(forge_rows[0].attr)
_check("meta_forge_row 按 attr 命中首行", db.meta_forge_row(attr0) == forge_rows[0])
_check("meta_forge_row 未知 attr 返回空字典", db.meta_forge_row("no_such_attr__").is_empty())
Kit.check("meta_forge_row 按 attr 命中首行", db.meta_forge_row(attr0) == forge_rows[0])
Kit.check("meta_forge_row 未知 attr 返回空字典", db.meta_forge_row("no_such_attr__").is_empty())
var key_rows: Array = db._meta_rows("boss_keystone")
if not key_rows.is_empty():
var klv: int = int(key_rows[0].level_index)
_check("meta_boss_keystone_row 按关卡序号命中", db.meta_boss_keystone_row(klv) == key_rows[0])
Kit.check("meta_boss_keystone_row 按关卡序号命中", db.meta_boss_keystone_row(klv) == key_rows[0])
var conv_rows: Array = db._meta_rows("conversion")
if not conv_rows.is_empty():
var cattr: String = String(conv_rows[0].attr)
_check("meta_conversion_row 按 attr 命中", db.meta_conversion_row(cattr) == conv_rows[0])
Kit.check("meta_conversion_row 按 attr 命中", db.meta_conversion_row(cattr) == conv_rows[0])
# ---- TABLE_MANIFEST ↔ data/tables 目录同步(各系统→D2 单点契约的登记面)----
@@ -195,4 +179,4 @@ func _test_manifest_dir_sync(db) -> void:
for key in manifest.keys():
if not disk_files.has(String(manifest[key])):
synced = false
_check("TABLE_MANIFEST 与 data/tables 目录逐文件同步(无缺登/无悬登)", synced)
Kit.check("TABLE_MANIFEST 与 data/tables 目录逐文件同步(无缺登/无悬登)", synced)
@@ -7,26 +7,12 @@
extends SceneTree
const ConfigDBScript := preload("res://core/config_db.gd")
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:BAD 表目录夹具共享(copy_tables)
const SRC_DIR := "res://data/tables"
const BAD_DIR := "user://aux_fixture_dangling_aux"
func _copy_tables() -> bool:
DirAccess.make_dir_recursive_absolute(BAD_DIR)
var dir := DirAccess.open(SRC_DIR)
if dir == null:
return false
dir.list_dir_begin()
var f := dir.get_next()
while f != "":
if not dir.current_is_dir():
if dir.copy(SRC_DIR + "/" + f, BAD_DIR + "/" + f) != OK:
return false
f = dir.get_next()
return true
func _inject_violation() -> bool:
var path := BAD_DIR + "/reward.tres"
var f := FileAccess.open(path, FileAccess.READ)
@@ -50,7 +36,7 @@ func _inject_violation() -> bool:
func _init() -> void:
if not _copy_tables() or not _inject_violation():
if not Kit.copy_tables(SRC_DIR, BAD_DIR) or not _inject_violation():
push_error("[reward_dangling_aux_test] 夹具目录准备失败")
quit(2)
return
@@ -6,33 +6,17 @@
extends SceneTree
const RngServiceScript := preload("res://core/rng_service.gd")
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
const TEST_SEED := 20260928 # 夹具种子(任意固定值;语义 = 可复现性,非玩法数值)
var _failures := 0
var _checks := 0
func _init() -> void:
_test_derive_determinism()
_test_counter_ledger()
_test_stream_draw_determinism()
_test_negative_seed_normalization()
if _failures > 0:
push_error("[rng_service_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[rng_service_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("rng_service_test"))
func _fresh_service():
@@ -49,11 +33,11 @@ func _test_derive_determinism() -> void:
for scope in range(8):
if a.derive_at(scope) != b.derive_at(scope):
same = false
_check("同种子两实例 derive_at(0..7) 逐位一致", same)
Kit.check("同种子两实例 derive_at(0..7) 逐位一致", same)
var a2 = _fresh_service()
var before: int = a2.counter()
a2.derive_at(0)
_check("derive_at 为纯函数不推计数器", a2.counter() == before)
Kit.check("derive_at 为纯函数不推计数器", a2.counter() == before)
# 全局取数计数器:唯一账本——open_stream 逐次 +1(scope 单调)
@@ -64,13 +48,13 @@ func _test_counter_ledger() -> void:
var c1: int = rng.counter()
rng.open_stream("armor_roll")
var c2: int = rng.counter()
_check("open_stream 逐次推进全局计数器(+1/次)", c1 == c0 + 1 and c2 == c0 + 2)
Kit.check("open_stream 逐次推进全局计数器(+1/次)", c1 == c0 + 1 and c2 == c0 + 2)
# 用途标签只进事件日志不参与派生——同 scope 同派生种子(ADR-0007 裁丙口径)
var rng_x = _fresh_service()
var rng_y = _fresh_service()
var s1: int = int(rng_x.open_stream("board_gen").seed)
var s2: int = int(rng_y.open_stream("reward_roll").seed)
_check("同 scope 异用途标签派生种子一致(标签不参与派生)", s1 == s2)
Kit.check("同 scope 异用途标签派生种子一致(标签不参与派生)", s1 == s2)
# 同种子两实例同标签流的实际取数序列逐位一致(回放确定性契约——存档快照=主种子+计数器的前提)
@@ -84,7 +68,7 @@ func _test_stream_draw_determinism() -> void:
var rb: int = b.open_stream("armor_roll").randi_range(1, faces)
if ra != rb:
same = false
_check("同种子两实例同标签流取数 12 次逐位一致", same)
Kit.check("同种子两实例同标签流取数 12 次逐位一致", same)
# 负输入 abs 归一化(警告通道)——|−s| 与 s 同派生
@@ -92,4 +76,4 @@ func _test_negative_seed_normalization() -> void:
var pos = _fresh_service()
var neg = RngServiceScript.new()
neg.set_master_seed(-TEST_SEED)
_check("负种子 abs 归一化后派生与正种子一致", pos.derive_at(0) == neg.derive_at(0))
Kit.check("负种子 abs 归一化后派生与正种子一致", pos.derive_at(0) == neg.derive_at(0))
+5 -16
View File
@@ -7,23 +7,12 @@
# 第一个真实测试:复制本文件为 tests/unit/<system>/<system>_<feature>_test.gd 并替换占位断言
extends SceneTree
func _init() -> void:
var failures := 0
failures += _check("headless SceneTree 协议可用(quit(code) 通道)", _test_headless_protocol())
failures += _check("命名规范可执行:test_[scenario]_[expected]", _test_naming_convention_followed())
if failures > 0:
push_error("[example_test] %d 项失败" % failures)
quit(1)
return
print("[example_test] all green")
quit(0)
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:check/汇总退出样板单点
func _check(desc: String, ok: bool) -> int:
if not ok:
push_error("[example_test] FAIL: " + desc)
return 1
print("[example_test] PASS: " + desc)
return 0
func _init() -> void:
Kit.check("headless SceneTree 协议可用(quit(code) 通道)", _test_headless_protocol())
Kit.check("命名规范可执行:test_[scenario]_[expected]", _test_naming_convention_followed())
quit(Kit.finish("example_test"))
func _test_headless_protocol() -> bool:
# 占位断言:验证脚本本身在 SceneTree 上下文可执行。
@@ -3,6 +3,17 @@ extends SceneTree
# 原守 node_overlay.show_reward_items;奖励节点三选一与通关三选一合并共用 BonusPickOverlay 后,
# 本套件改守共用面的「功能存在 ≠ 功能可达」盲区:选择外发/强制确认两段式/替换子面板两路/结果态。
# 运行:godot --headless --path game --script res://tests/unit/presentation/reward_overlay_test.gd(expect 0)
# T6 收口批:断言走 test_kit 计数(Kit.check)+ 失败即停早退协议保留(顺序状态机——坏态下后继断言无效)。
const Kit := preload("res://tests/unit/test_kit.gd")
# 断言 + 失败即停(原直写 print FAIL + quit(1) + return 样板单点化——早退语义逐位保持)
func _req(desc: String, ok: bool) -> bool:
Kit.check(desc, ok)
if not ok:
quit(1)
return false
return true
func _initialize() -> void:
call_deferred("_run")
@@ -25,17 +36,15 @@ func _run() -> void:
for c in ov._choice_box.get_children():
if c is Button:
btns += 1
if ov.visible != true or btns != 3 or ov._confirm_btn.visible or ov._continue_btn.visible:
print("[reward_verify] FAIL — 选择态应三卡呈现且确认/继续隐藏(btns=%d)" % btns)
quit(1)
if not _req("选择态应三卡呈现且确认/继续隐藏(btns=%d)" % btns,
ov.visible and btns == 3 and not ov._confirm_btn.visible and not ov._continue_btn.visible):
return
# ② 点选→高亮→确认外发(真实按钮路径);确认前改选以最后点选为准
ov.press_card(0)
ov.press_card(1)
ov.press_confirm()
if ov._confirm_btn.visible != true or picked.size() != 1 or String(picked[0]) != "gp":
print("[reward_verify] FAIL — 点选确认未外发 pick_confirmed(gp)(%s)" % str(picked))
quit(1)
if not _req("点选确认未外发 pick_confirmed(gp)(%s)" % str(picked),
ov._confirm_btn.visible and picked.size() == 1 and String(picked[0]) == "gp"):
return
# ③ 替换子面板:丢旧收新与放弃新件两路外发(通关渠道=携带序号 / 节点渠道=库存句柄——tag 直通)
ov.open_replace([{"index": 0, "item_id": "probe_dust", "display_name": "显影尘"}], "爆破工兵包", 1, 1)
@@ -43,33 +52,26 @@ func _run() -> void:
for c in ov._replace_box.get_children():
if c is Button:
replace_btns += 1
if replace_btns != 2 or ov._choice_box.visible:
print("[reward_verify] FAIL — 替换面板应 2 钮(N+1 选 N+放弃)且三卡隐藏(btns=%d)" % replace_btns)
quit(1)
if not _req("替换面板应 2 钮(N+1 选 N+放弃)且三卡隐藏(btns=%d)" % replace_btns,
replace_btns == 2 and not ov._choice_box.visible):
return
var replaced_first := false
for c in ov._replace_box.get_children():
if c is Button:
c.pressed.emit()
replaced_first = true
break
if replaced.size() != 1 or int(replaced[0]) != 0:
print("[reward_verify] FAIL — 丢旧收新未外发 replace_chosen(0)(%s)" % str(replaced))
quit(1)
if not _req("丢旧收新未外发 replace_chosen(0)(%s)" % str(replaced),
replaced_first and replaced.size() == 1 and int(replaced[0]) == 0):
return
# ④ 结果态:继续钮点亮、唯一出口
ov.show_result("已选择:攻击力提升 · 攻击 +1")
if ov._continue_btn.visible != true or ov._replace_box.visible:
print("[reward_verify] FAIL — 结果态应点亮继续且收替换面板")
quit(1)
if not _req("结果态应点亮继续且收替换面板", ov._continue_btn.visible and not ov._replace_box.visible):
return
ov.press_continue()
if continued.size() != 1:
print("[reward_verify] FAIL — 继续未外发 continue_pressed")
quit(1)
if not _req("继续未外发 continue_pressed", continued.size() == 1):
return
ov.close_pick()
if ov.visible:
print("[reward_verify] FAIL — close_pick 应收屏")
quit(1)
if not _req("close_pick 应收屏", not ov.visible):
return
print("[reward_verify] PASS — 三卡两段式/确认外发/替换两路/结果态/收屏 全过")
quit(0)
quit(Kit.finish("reward_verify", "PASS — 三卡两段式/确认外发/替换两路/结果态/收屏 全过"))
@@ -9,9 +9,7 @@ extends SceneTree
const ConfigDBScript := preload("res://core/config_db.gd")
const BoardViewScript := preload("res://presentation/views/board_view.gd")
var _failures := 0
var _checks := 0
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
func _init() -> void:
@@ -23,7 +21,7 @@ func _init() -> void:
return
var db = ConfigDBScript.new()
var load_ok: bool = db.load_tables("res://data/tables", true)
_check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
Kit.check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0)
if not load_ok:
quit(1)
return
@@ -33,25 +31,11 @@ func _init() -> void:
var bt := String(row.get("board_type", ""))
if bt != "" and not types.has(bt):
types.append(bt)
_check("前置:board_gen 词表非空(%d 型)" % types.size(), types.size() > 0)
Kit.check("前置:board_gen 词表非空(%d 型)" % types.size(), types.size() > 0)
_test_set_completeness(board_dir, types)
_test_hue_domains(board_dir)
_test_digit_ink_modes(board_dir, types)
if _failures > 0:
push_error("[terrain_tile_sets_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[terrain_tile_sets_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
quit(Kit.finish("terrain_tile_sets_test"))
# 套完整性:词表每个 board_type 的 16 墙 + 5 地面变体全部在盘(缺位 → BoardView 整批回落 dungeon 的
@@ -69,7 +53,7 @@ func _test_set_completeness(board_dir: String, types: Array) -> void:
var pf := board_dir.path_join("terrain_floor_%s_%d.png" % [String(bt), i])
if not FileAccess.file_exists(pf):
missing.append("floor_%d" % i)
_check("图型 %s 瓦片套完整(16 墙 + %d 地面,缺=%s)" % [String(bt), variants, str(missing)], missing.is_empty())
Kit.check("图型 %s 瓦片套完整(16 墙 + %d 地面,缺=%s)" % [String(bt), variants, str(missing)], missing.is_empty())
# 色相域抽检:wall_center 上半幅(墙顶面)与 floor_1 全幅的均值色相/明度
@@ -85,17 +69,17 @@ func _test_hue_domains(board_dir: String) -> void:
var wall := _load_stats(board_dir, "terrain_wall_center_%s.png" % String(bt), true)
var floor_img := _load_stats(board_dir, "terrain_floor_%s_1.png" % String(bt), false)
if wall.is_empty() or floor_img.is_empty():
_check("图型 %s 抽检样本可读" % String(bt), false)
Kit.check("图型 %s 抽检样本可读" % String(bt), false)
continue
wall_v[bt] = float(wall["v"])
floor_v[bt] = float(floor_img["v"])
var band: Array = bands[bt]
_check("图型 %s 墙顶均值色相在带 %s(实测 %.3f)" % [String(bt), str(band), float(wall["h"])],
Kit.check("图型 %s 墙顶均值色相在带 %s(实测 %.3f)" % [String(bt), str(band), float(wall["h"])],
float(wall["h"]) >= float(band[0]) and float(wall["h"]) <= float(band[1]))
_check("图型 %s 图底分离(墙顶明度 %.3f > 地面 %.3f)" % [String(bt), wall_v[bt], floor_v[bt]],
Kit.check("图型 %s 图底分离(墙顶明度 %.3f > 地面 %.3f)" % [String(bt), wall_v[bt], floor_v[bt]],
wall_v[bt] > floor_v[bt])
if wall_v.has("underdark") and wall_v.has("deep_underdark"):
_check("deep_underdark 墙顶更暗于 underdark(更深蓝黑方向)", float(wall_v["deep_underdark"]) < float(wall_v["underdark"]))
Kit.check("deep_underdark 墙顶更暗于 underdark(更深蓝黑方向)", float(wall_v["deep_underdark"]) < float(wall_v["underdark"]))
# 均值读数:hue 取饱和度 > 0.05 的像素(墨线/灰黑不影响色相均值);v 取全幅均值
@@ -150,26 +134,26 @@ func _test_digit_ink_modes(board_dir: String, types: Array) -> void:
var tex: Texture2D = ImageTexture.create_from_image(Image.load_from_file(p))
BoardViewScript._normalize_floor_luma(tex, 0.10, lumas)
if not readable:
_check("图型 %s 数字墨族断言样本可读" % String(bt), false)
Kit.check("图型 %s 数字墨族断言样本可读" % String(bt), false)
continue
var mode: Dictionary = BoardViewScript._digit_ink_mode(lumas)
var plate: bool = bool(mode["plate"])
var dark: bool = bool(mode["dark"])
var mode_name := "色牌垫底" if plate else ("深墨族直画" if dark else "亮墨族直画")
if plate:
_check("图型 %s 双峰纹理落牌(亮族 p90 %.2f/均值 %.2f,深族 p90 %.2f/均值 %.2f 均未过门)" % [
Kit.check("图型 %s 双峰纹理落牌(亮族 p90 %.2f/均值 %.2f,深族 p90 %.2f/均值 %.2f 均未过门)" % [
String(bt), float(mode["cr_p90_bright"]), float(mode["cr_mean_bright"]),
float(mode["cr_p90_dark"]), float(mode["cr_mean_dark"])],
float(mode["cr_mean_dark"]) < float(BoardViewScript.DIGIT_INK_CR_MEAN_DARK))
else:
var cr_p90 := float(mode["cr_p90_bright"]) if not dark else float(mode["cr_p90_dark"])
var cr_mean := float(mode["cr_mean_bright"]) if not dark else float(mode["cr_mean_dark"])
_check("图型 %s 所选%s 对 p90 ≥%.1f(实测 %.2f)" % [String(bt), mode_name,
Kit.check("图型 %s 所选%s 对 p90 ≥%.1f(实测 %.2f)" % [String(bt), mode_name,
float(BoardViewScript.DIGIT_INK_CR_P90), cr_p90],
cr_p90 >= float(BoardViewScript.DIGIT_INK_CR_P90))
var mean_gate := float(BoardViewScript.DIGIT_INK_CR_MEAN_BRIGHT) if not dark else float(BoardViewScript.DIGIT_INK_CR_MEAN_DARK)
_check("图型 %s 所选%s 对均值 ≥%.1f(实测 %.2f)" % [String(bt), mode_name, mean_gate, cr_mean],
Kit.check("图型 %s 所选%s 对均值 ≥%.1f(实测 %.2f)" % [String(bt), mode_name, mean_gate, cr_mean],
cr_mean >= mean_gate)
if expect_plate.has(bt):
_check("图型 %s 呈现模式期望=%s(实测=%s)" % [String(bt),
Kit.check("图型 %s 呈现模式期望=%s(实测=%s)" % [String(bt),
"色牌" if bool(expect_plate[bt]) else "直画", mode_name], plate == bool(expect_plate[bt]))
@@ -7,38 +7,22 @@
extends SceneTree
const UiKitScript := preload("res://presentation/views/ui_kit.gd")
var _failures := 0
var _checks := 0
const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇总退出样板单点
func _init() -> void:
var path: String = UiKitScript.FONT_UI
var f: Font = UiKitScript.load_font(path)
_check("随包字体守卫装载非空(res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf)", f != null)
Kit.check("随包字体守卫装载非空(res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf)", f != null)
if f != null:
_check("中文字形在集(攻/雷/旗/封)", f.has_char("攻".unicode_at(0)) and f.has_char("雷".unicode_at(0)) \
Kit.check("中文字形在集(攻/雷/旗/封)", f.has_char("攻".unicode_at(0)) and f.has_char("雷".unicode_at(0)) \
and f.has_char("旗".unicode_at(0)) and f.has_char("封".unicode_at(0)))
_check("ASCII/数字字形在集(A/0/%)", f.has_char("A".unicode_at(0)) and f.has_char("0".unicode_at(0)) and f.has_char("%".unicode_at(0)))
Kit.check("ASCII/数字字形在集(A/0/%)", f.has_char("A".unicode_at(0)) and f.has_char("0".unicode_at(0)) and f.has_char("%".unicode_at(0)))
var sz := 18
var w_cn: float = f.get_string_size("战斗胜利", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x
var w_label: float = f.get_string_size("攻击 2", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x
_check("中文采样文本宽度 > 0(C6 headless 判据)", w_cn > 0.0 and w_label > 0.0)
Kit.check("中文采样文本宽度 > 0(C6 headless 判据)", w_cn > 0.0 and w_label > 0.0)
# 注:不设「私用区 has_char=false」负探针——fusion pixel 实测覆盖 PUA 码位,负向覆盖非契约面(宁缺勿错)
else:
_check("字体缺失时后续断言跳过(缺位=回归,此行仅占位计数)", false)
if _failures > 0:
push_error("[ui_font_asset_test] %d/%d 项失败" % [_failures, _checks])
quit(1)
return
print("[ui_font_asset_test] all green (%d checks)" % _checks)
quit(0)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if ok:
print(" [PASS] " + desc)
else:
_failures += 1
push_error(" [FAIL] " + desc)
Kit.check("字体缺失时后续断言跳过(缺位=回归,此行仅占位计数)", false)
quit(Kit.finish("ui_font_asset_test"))
+72
View File
@@ -0,0 +1,72 @@
# tests/unit/test_kit.gd — 单元测试协议样板单点(T6 样板收口批 2026-10-02)
# 收口范围:11 份套件的 _check/_problems 样板(计数器 + PASS/FAIL 行 + 汇总退出)+
# reward_dangling_aux/aux_target_violation 两致命类用例各自复制的 BAD 表目录夹具。
# 用法(--script 模式无 autoload/全局标识符——禁 class_name 裸引用,使用方 preload):
# const Kit := preload("res://tests/unit/test_kit.gd")
# Kit.check("断言描述", cond) # 计数 + 即时 PASS/FAIL 行
# Kit.check_quiet(cond, "描述") # 计数 + 失败收集(finish 时统一汇总——原 _problems 风格,原参序)
# quit(Kit.finish("suite_name")) # 汇总输出 + 返回退出码(0 全绿 / 1 有败)
# 计数器为 static(套件 = 独立 Godot 子进程恰一份生命周期,见 run_all.gd 执行形态);
# 单进程跑多套件须先 Kit.reset()。
extends RefCounted
static var checks: int = 0
static var failures: int = 0
static var problems: Array = []
# 主通道:计数 + 即时 PASS/FAIL 行(config_db/rng_service/damage_math/system_contracts/
# damage_controller/ui_font_asset/terrain_tile_sets 等套件原 _check 同款输出,描述在前)
static func check(desc: String, ok: bool) -> void:
checks += 1
if ok:
print(" [PASS] " + desc)
else:
failures += 1
push_error(" [FAIL] " + desc)
# 静默收集通道:失败只记入 problems(即时零刷屏),finish 统一汇总。
# 参序沿原函数式套件 aux_items/breakpoint_resume 的 check(cond, msg)——条件在前,与主通道相反。
static func check_quiet(ok: bool, desc: String) -> void:
checks += 1
if not ok:
failures += 1
problems.append(desc)
# 汇总退出:返回退出码(调用方 `quit(Kit.finish(...))`)。pass_line = 成功尾行覆盖
#(保留原套件 PASS 摘要文案;缺省 = "all green (N checks)" 计数口径)。
# 失败路径:push_error 计数汇总 + problems 逐条 FAIL 行(print + push_error 双通道——原两风格并档)。
static func finish(suite: String, pass_line: String = "") -> int:
if failures > 0:
push_error("[%s] %d/%d 项失败" % [suite, failures, checks])
for p in problems:
print("[%s] FAIL — %s" % [suite, p])
push_error("[%s] FAIL — %s" % [suite, p])
return 1
print("[%s] %s" % [suite, pass_line if pass_line != "" else "all green (%d checks)" % checks])
return 0
# BAD 表夹具共享(原 reward_dangling_aux/aux_target_violation 各自复制的 _copy_tables):
# 整目录拷贝 src_dir → bad_dir(目录已建)。BAD_DIR 常量仍由各用例自持(名字互异——独立进程并行不互踩)。
static func copy_tables(src_dir: String, bad_dir: String) -> bool:
DirAccess.make_dir_recursive_absolute(bad_dir)
var dir := DirAccess.open(src_dir)
if dir == null:
return false
dir.list_dir_begin()
var f := dir.get_next()
while f != "":
if not dir.current_is_dir():
if dir.copy(src_dir + "/" + f, bad_dir + "/" + f) != OK:
return false
f = dir.get_next()
return true
static func reset() -> void:
checks = 0
failures = 0
problems = []
+1
View File
@@ -0,0 +1 @@
uid://dtestkit123456