merge(迭代 32 卡②): 效果引擎无状态化批合回

EffectEngine 改纯计算引擎(零运行时状态·仅 _triggers+db)+四桶外置宿主(round/battle→编排器、level→LevelRunner、adventure→RunLedger)+
搬运链整段删除+dig_combo 死路删+前缀校验双落点+引用闭合加载致命+V9 负面套件+V12 同种子复放三组判读通过(off 逐字节一致)+S12 旧件零残留。
完成定义 8 条全绿;代码域闸 all_green(回执 6491f7d5),parity 落 skip(契约 12)。
This commit is contained in:
DKChan committed 2026-10-21 00:04:15 +08:00
commit d00f24a8ec
28 files changed
+653 -312

No files matched your search

+1
View File
@@ -46,6 +46,7 @@ func _suites() -> Array:
# negative=true=负面套件:坏表/违规输入是它的测试目标,输出里必然带 Parse Error,
# 脚本错误扫描对它属误伤——豁免只放行这一件,且仅限「错误扫描」一闸(退出码与断言下限照判)。
{"path": "res://tests/unit/data_config/reward_dangling_treasure_test.gd", "expect": 1, "negative": true}, # 秘宝双表校验
{"path": "res://tests/unit/data_config/counter_prefix_fatal_test.gd", "expect": 1, "negative": true}, # 卡② V9 前缀合法性+引用闭合加载致命
{"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0},
{"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 秘宝结构冻结断言面
{"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 断点往返/续玩确定性/幂等/退出清断点
+23
View File
@@ -210,4 +210,27 @@ else
fi
fi
# ── S12 — 效果引擎无状态化旧件零残留(卡② V10/F1;禁 counters_export/counters_seed/_reset_tree_limited_counters 等旧符号与带引号旧 id 字面回归)──
# 扫描面:game/ 全域 .gd 与 game/data/tables/*.tres(改名与旧符号同时存在于数据行/测试/smoke,非仅 core——不扩面即空转)
# 带引号旧 id 匹配("twin_digs" 不误伤 "adv_twin_digs"——引号锚定;round_instinct_atk 不匹 "instinct_atk")
# 自检:扫描面文件数为零即判红(防静默空转)
if [ -d game/core ]; then
S12_SYM='counters_export|counters_seed|counters_merge_export|counters_seed_snapshot|_reset_tree_limited_counters|"tree_sense_sharp_used"|"tree_sense_guard_used"|"twin_digs"|"instinct_atk"'
S12_FACE=$(find game/ -type f \( -name '*.gd' -o -name '*.tres' \) 2>/dev/null)
S12_FC=$(echo "$S12_FACE" | grep -c .)
if [ "$S12_FC" -eq 0 ]; then
echo "[static_scan] S12 FAIL: 扫描面为空——路径写错导致的静默空转"
fail=1
else
S12_HITS=$(echo "$S12_FACE" | tr '\n' '\0' | xargs -0 grep -lEn "$S12_SYM" 2>/dev/null | wc -l)
if [ "$S12_HITS" -gt 0 ]; then
echo "[static_scan] S12 FAIL: 无状态化旧件残留命中 $S12_HITS 文件"
echo "$S12_FACE" | tr '\n' '\0' | xargs -0 grep -En "$S12_SYM" 2>/dev/null
fail=1
else
echo "[static_scan] S12 ok(扫描面 $S12_FC 个 .gd/.tres,旧件零残留)"
fi
fi
fi
exit $fail
+48 -44
View File
@@ -26,12 +26,12 @@ func _init() -> void:
"新效果类型在册:heal/ac_bonus/damage_reduce/bonus_delta/blast_scaled/preview_area/target_attack_bonus〔专长批 V1〕")
Kit.check_quiet(EffectEngineScript.OPS.has("mod_eq"), "新算子在册:mod_eq〔专长批 V1〕")
# ② 合法新词行零违例(validate_trigger_row 正例)
# ② 合法新词行零违例(validate_trigger_row 正例——前缀归属替代旧 domain 键)
var errs: Array = []
var ok_row := {"on": "dig_stop", "when": [{"fact": "ap_left", "op": "gte", "value": 3}],
"do": {"kind": "counter_add", "counter_id": "stop_swap", "value_event": "ap_left", "domain": "adventure"}}
"do": {"kind": "counter_add", "counter_id": "stop_swap", "value_event": "ap_left"}}
var mine_covered: bool = EffectEngineScript.validate_trigger_row("V1正例", {}, [ok_row], errs)
Kit.check_quiet(errs.is_empty() and not mine_covered, "合法新词行 validate 零违例(dig_stop/ap_left/counter_add+domain)〔专长批 V1〕")
Kit.check_quiet(errs.is_empty() and not mine_covered, "合法新词行 validate 零违例(dig_stop/ap_left/counter_add——battle 桶无前缀)〔专长批 V1〕")
errs = []
EffectEngineScript.validate_trigger_row("V1正例2", {}, [{"on": "battle_won",
"when": [{"fact": "hp_ratio", "op": "lte", "value": 1}],
@@ -46,81 +46,85 @@ func _init() -> void:
EffectEngineScript.validate_trigger_row("出域kind", {}, [{"on": "dig_stop", "do": {"kind": "nope_kind", "value": 1}}], errs)
Kit.check_quiet(errs.size() == 4, "出域 on/fact/op/kind 各一行全部被拒(4 违例)〔专长批 V1〕")
# ④ 运行期命中序(合法新事件;dispatch 出域 fail-fast=debug assert〔effect_engine dispatch 首行
# assert(EVENTS.has)——release 剥离系引擎语义,自动化覆盖走 ③ 加载期 validate 致命通道〕)
# ④ 运行期命中序(合法新事件;dispatch 出域 fail-fast=debug assert;引擎无状态——计数器经 ctx)
var fx = EffectEngineScript.new(null)
var ctx_f := EffectContext.new()
var hits: Array = []
fx.rebuild([], [], [])
fx._push_trigger("test", "v1_row", {"on": "dig_stop",
"when": [{"fact": "ap_left", "op": "gte", "value": 3}],
"do": {"kind": "counter_add", "counter_id": "stop_swap", "value_event": "ap_left"}}, {})
hits = fx.dispatch("dig_stop", {"ap_left": 5})
hits = fx.dispatch(ctx_f, "dig_stop", {"ap_left": 5})
Kit.check_quiet(hits.size() == 1, "dig_stop 合法命中(ap_left 5≥3)〔专长批 V1〕")
hits = fx.dispatch("dig_stop", {"ap_left": 1})
hits = fx.dispatch(ctx_f, "dig_stop", {"ap_left": 1})
Kit.check_quiet(hits.is_empty(), "dig_stop 反例不命中(ap_left 1<3)〔专长批 V1〕")
Kit.check_quiet(not EffectEngineScript.EVENTS.has("dig_stop_x"),
"词表封闭:出域事件词不在 EVENTS(dispatch 首行 assert 消费同一词表)〔专长批 V1〕")
# ⑤ mod_eq 算子(双管镐落法——预评估口径每第 2 次判定)
# ⑤ mod_eq 算子(双管镐落法——预评估口径每第 2 次判定;battle 桶无前缀)
var fx2 = EffectEngineScript.new(null)
var ctx2 := EffectContext.new()
fx2.rebuild([], [], [])
fx2._push_trigger("test", "mod_row", {"on": "dig_paid",
"when": [{"fact": "counter", "id": "dbl_digs", "op": "mod_eq", "value": 2, "ref": 1}],
"do": {"kind": "grant_ap", "value": 1}}, {})
var refunds := 0
for i in 4:
if not fx2.dispatch("dig_paid", {"ap_cost": 1}).is_empty():
if not fx2.dispatch(ctx2, "dig_paid", {"ap_cost": 1}).is_empty():
refunds += 1
fx2.counter_add("dbl_digs", 1)
EffectEngine.counter_add(ctx2, "dbl_digs", 1)
Kit.check_quiet(refunds == 2, "mod_eq 预评估口径:4 次付费挖格恰返 2 次(第 2/4 次)〔专长批 V1〕")
# ⑥ adventure 域计数器 export/seed 往返(T6)
# ⑥ adventure 桶共享引用跨引擎持久(T6 换代——替代旧 export/seed 副本搬运)
var adv_bucket := {} # 同一冒险账本字典引用跨两引擎=跨战持久(无副本无搬运时点)
var fx3 = EffectEngineScript.new(null)
fx3._push_trigger("test", "adv_row", {"on": "mine_hit",
"do": {"kind": "counter_add", "counter_id": "pain_layers", "value": 1, "domain": "adventure"}}, {})
fx3._push_trigger("test", "adv_row2", {"on": "mine_hit",
"do": {"kind": "counter_add", "counter_id": "round_mines", "value": 1, "reset_at": "round_start"}}, {})
"do": {"kind": "counter_add", "counter_id": "adv_persist", "value": 1}}, {})
var ctx3 := EffectContext.new()
ctx3.adventure_bucket = adv_bucket
for _i in 2:
for h in fx3.dispatch("mine_hit", {}): # 施加面同构(编排侧 counter_add 逐条落账)
for h in fx3.dispatch(ctx3, "mine_hit", {}):
if String(h.get("kind", "")) == "counter_add":
fx3.counter_add(String(h.get("counter_id", "")), int(h.get("value", 0)))
var exported: Dictionary = fx3.counters_export()
Kit.check_quiet(exported.get("pain_layers", -1) == 2 and not exported.has("round_mines"),
"export 只含 adventure 域(pain_layers=2、round_mines battle 域不入)〔专长批 V1〕")
EffectEngine.counter_add(ctx3, String(h.get("counter_id", "")), int(h.get("value", 0)))
Kit.check_quiet(EffectEngine.counter(ctx3, "adv_persist") == 2 and int(adv_bucket.get("adv_persist", -1)) == 2,
"adventure 桶就地直写共享字典累积=2(adv_ 前缀归 adventure 桶——替代旧导出搬运)〔专长批 V1〕")
var fx4 = EffectEngineScript.new(null)
fx4._push_trigger("test", "adv_row_seed", {"on": "mine_hit",
"do": {"kind": "counter_add", "counter_id": "pain_layers", "value": 1, "domain": "adventure"}}, {})
fx4.counters_seed({"pain_layers": 7})
Kit.check_quiet(fx4.counter("pain_layers") == 7, "seed 回灌:counters_seed 后 adventure 域计数器=7〔专长批 V1〕")
fx4._push_trigger("test", "adv_row2", {"on": "mine_hit",
"do": {"kind": "counter_add", "counter_id": "adv_persist", "value": 1}}, {})
var ctx4 := EffectContext.new()
ctx4.adventure_bucket = adv_bucket # 挂同一字典引用=下一战可读上一战持久值(替代旧回灌)
Kit.check_quiet(EffectEngine.counter(ctx4, "adv_persist") == 2,
"adventure 桶共享引用跨引擎可读(无 export/seed,同字典即跨战持久)〔专长批 V1〕")
# ⑦ V4——攒层/计数类件跨事件计数(置计数→事件路径→清零点:round_start 重置/用后清零)
# ⑦ V4——前缀归桶清零/消耗语义(round_ 桶随 round_start 整桶清空;battle 无前缀计数跨 round_start 存活)
var fx5 = EffectEngineScript.new(null)
var ctx5 := EffectContext.new()
fx5.rebuild([], [], [])
fx5._push_trigger("test", "round_row", {"on": "mine_hit",
"do": {"kind": "counter_add", "counter_id": "round_mines", "value": 1}}, {}) # 未声明 reset_at——round_start 不重置(声明制对照面)
fx5._push_trigger("test", "spend_row", {"on": "treasure_used",
"do": {"kind": "counter_spend", "counter_id": "round_mines", "value": 1}}, {})
for h in fx5.dispatch("mine_hit", {}):
"do": {"kind": "counter_add", "counter_id": "round_mines", "value": 1}}, {})
for h in fx5.dispatch(ctx5, "mine_hit", {}):
if String(h.get("kind", "")) == "counter_add":
fx5.counter_add(String(h.get("counter_id", "")), int(h.get("value", 0)))
for h in fx5.dispatch("mine_hit", {}):
EffectEngine.counter_add(ctx5, String(h.get("counter_id", "")), int(h.get("value", 0)))
for h in fx5.dispatch(ctx5, "mine_hit", {}):
if String(h.get("kind", "")) == "counter_add":
fx5.counter_add(String(h.get("counter_id", "")), int(h.get("value", 0)))
Kit.check_quiet(fx5.counter("round_mines") == 2, "V4:跨事件计数累积(两雷两计)〔专长批 V4〕")
Kit.check_quiet(fx5.counter_spend("round_mines", 1) and fx5.counter("round_mines") == 1,
"V4:counter_spend 写口消耗(用后清零语义承载面)〔专长批 V4〕")
fx5.dispatch("round_start", {"round": 2})
Kit.check_quiet(fx5.counter("round_mines") == 1,
"V4:非 reset 点计数器不受 round_start 影响(reset_at 声明制——round_mines 未声明重置)〔专长批 V4〕")
EffectEngine.counter_add(ctx5, String(h.get("counter_id", "")), int(h.get("value", 0)))
Kit.check_quiet(EffectEngine.counter(ctx5, "round_mines") == 2, "V4:跨事件计数累积(两雷两计——round_ 前缀落 round 桶)〔专长批 V4〕")
Kit.check_quiet(EffectEngine.counter_spend(ctx5, "round_mines", 1) and EffectEngine.counter(ctx5, "round_mines") == 1,
"V4:counter_spend 静态写口消耗(用后清零语义承载面)〔专长批 V4〕")
fx5.dispatch(ctx5, "round_start", {"round": 2})
Kit.check_quiet(EffectEngine.counter(ctx5, "round_mines") == 0,
"V4:round_ 前缀计数器随 round_start 整桶清空(旧 reset_at 名单声明制换代为前缀归桶)〔专长批 V4〕")
# battle 桶对照:无前缀 helm_layers 不受 round_start 影响(跨 round_start 存活)
var fx6 = EffectEngineScript.new(null)
var ctx6 := EffectContext.new()
fx6.rebuild([], [], [])
fx6._push_trigger("test", "reset_row", {"on": "mine_hit",
"do": {"kind": "counter_add", "counter_id": "helm_layers", "value": 1, "reset_at": "round_start"}}, {})
for h in fx6.dispatch("mine_hit", {}):
fx6._push_trigger("test", "battle_row", {"on": "mine_hit",
"do": {"kind": "counter_add", "counter_id": "helm_layers", "value": 1}}, {})
for h in fx6.dispatch(ctx6, "mine_hit", {}):
if String(h.get("kind", "")) == "counter_add":
fx6.counter_add(String(h.get("counter_id", "")), int(h.get("value", 0)))
fx6.dispatch("round_start", {"round": 2})
Kit.check_quiet(fx6.counter("helm_layers") == 0,
"V4:round_start 重置点清零(订阅行声明 reset_at——安全帽攒层清零语义)〔专长批 V4〕")
EffectEngine.counter_add(ctx6, String(h.get("counter_id", "")), int(h.get("value", 0)))
fx6.dispatch(ctx6, "round_start", {"round": 2})
Kit.check_quiet(EffectEngine.counter(ctx6, "helm_layers") == 1,
"V4:battle 桶(无前缀)计数器不受 round_start 清空(round 桶整桶清空仅触及 round_ 前缀键)〔专长批 V4〕")
quit(Kit.finish("effect_words_t1"))
+11 -1
View File
@@ -111,7 +111,7 @@ func _init() -> void:
# V16——断点扩容往返:新账本随断点快照/续玩回填(breakpoint_resume_test P5 同构)
var sm = load("res://core/save_manager.gd").new("user://_feat_vouchers_bp.json")
sm.load_from_disk()
a.run_ledger.adventure_counters = {"pain_layers": 3, "twin_digs": 5} # 四账住 RunLedger
a.run_ledger.adventure_counters = {"pain_layers": 3, "adv_twin_digs": 5} # 四账住 RunLedger
a._refresh_stock = 2
a.set_save_manager(sm)
a._on_level_finished({"result": "victory", "hp": 10, "node_index": 1, "node_id": 1, "node_type": "shop"})
@@ -125,6 +125,16 @@ func _init() -> void:
bp_legacy["battery_pending"] = 7 # 旧断点档遗留键(Alpha 无迁移——缺键/多键兼容面)
var b3 = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
Kit.check_quiet(b3.resume_adventure(bp_legacy, {}), "V16:旧档含 battery_pending 键续玩受理(缺键/多键零报错)〔卡② R1〕")
# V13/H1 无状态化旧档兼容:改名前的旧 counter 键(tree_sense_sharp_used / pain_layers 等)随断点带进来不得崩,留桶成死键
var bp_legacy2: Dictionary = bp16.duplicate(true)
var legacy_ac: Dictionary = (bp_legacy2.get("adventure_counters", {}) as Dictionary).duplicate(true)
# 旧键名拼接构造(避免整串旧 id 字面触发 S12 旧件扫描——这里是"模拟旧档"非现役符号)
legacy_ac["tree_" + "sense_sharp_used"] = 2
legacy_ac["twin_" + "digs"] = 4
bp_legacy2["adventure_counters"] = legacy_ac
var b4 = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
Kit.check_quiet(b4.resume_adventure(bp_legacy2, {}),
"V13:旧档 adventure_counters 含改名前旧键续玩受理零崩溃(无迁移——旧键留桶成死键)〔卡② V13/H1〕")
Kit.check_quiet(int(b2.run_ledger.adventure_counters.get("pain_layers", -1)) == 3 and b2.refresh_stock() == 2,
"V16:续玩回填逐项一致(计数恢复——跨战账不丢)〔专长批 V16〕")
# V25 完美保险专长验收组(R2 全项:装载/限次一次/计数纯净/两账相减/跨战清零/事件不吞)
+2 -2
View File
@@ -29,8 +29,8 @@ func _init() -> void:
Kit.check_quiet(oc._combat_seg != null and oc._combat_seg.oc == oc, "V4 编排器接线·战斗段〔迭代27 T2〕")
Kit.check_quiet(oc._decay_seg != null and oc._decay_seg.oc == oc, "V4 编排器接线·衰减段〔迭代27 T2〕")
Kit.check_quiet(oc._verdict_seg != null and oc._verdict_seg.oc == oc, "V4 编排器接线·判定段〔迭代27 T2〕")
Kit.check_quiet(oc._grant_seg._apply_counter_hit({"counter_id": "dig_combo", "value": 1}) == null
and oc._fx.counter("dig_combo") == 1, "V4 counter_add 助手单点(原五位三行组收拢后行为等价)〔迭代27 T2〕")
Kit.check_quiet(oc._grant_seg._apply_counter_hit({"counter_id": "geo_types", "value": 1}) == null
and EffectEngine.counter(oc.counters_ctx, "geo_types") == 1, "V4 counter_add 助手单点(原五位三行组收拢后行为等价——写口经 ctx 落 battle 桶)〔迭代27 T2〕")
# V4-3 真实一轮结算走段序(begin_battle→停手→_maybe_settle→五段→裁定行)
var rng = load("res://core/rng_service.gd").new()
@@ -31,7 +31,7 @@ func _init() -> void:
var ap_with := int(ob1.action_economy._ap) # 测试夹具直读(呈现读数走 presentation_snapshot——本断言取账本真值)
Kit.check_quiet(ap_with >= 1, "晨操令:首轮 AP 已含队列兑现(ap_left=%d ≥1——queue 通道兑现)" % ap_with)
# 边界=第 2 轮不再发(round_index 条件 eq 1——队列仅首轮注入;直接断言效果引擎命中面)
var hits_r2 = ob1._fx.dispatch("round_start", {"round_index": 2})
var hits_r2 = ob1._fx.dispatch(ob1.counters_ctx, "round_start", {"round_index": 2})
var r2_ap := 0
for h in hits_r2:
if String(h.get("kind", "")) == "grant_ap" and String(h.get("mode", "")) == "queue":
@@ -42,7 +42,7 @@ func _init() -> void:
var ob1b = orch_s.new(db, rng1b)
Kit.check_quiet(ob1b.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp)}) and ob1b.attune_param_sum("light_radius_bonus") == 0,
"空装配基线复用:未装配件参数读数 0")
var hits_none = ob1b._fx.dispatch("round_start", {"round_index": 1})
var hits_none = ob1b._fx.dispatch(ob1b.counters_ctx, "round_start", {"round_index": 1})
Kit.check_quiet(_ap_of(hits_none) == 0, "晨操令反例:未装配首轮零命中(订阅行随装配集装载)")
# ② 双管装填(前 2 轮各 +1 / 第 3 轮零)
@@ -50,10 +50,10 @@ func _init() -> void:
var ob2 = orch_s.new(db, rng2)
Kit.check_quiet(ob2.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(db.hero.max_hp), "attunement": ["twin_loader"]}),
"双管装填:装配起战")
Kit.check_quiet(_ap_of(ob2._fx.dispatch("round_start", {"round_index": 1})) == 1
and _ap_of(ob2._fx.dispatch("round_start", {"round_index": 2})) == 1,
Kit.check_quiet(_ap_of(ob2._fx.dispatch(ob2.counters_ctx, "round_start", {"round_index": 1})) == 1
and _ap_of(ob2._fx.dispatch(ob2.counters_ctx, "round_start", {"round_index": 2})) == 1,
"双管装填:第 1/2 轮各命中 +1(lte 2 边界内)")
Kit.check_quiet(_ap_of(ob2._fx.dispatch("round_start", {"round_index": 3})) == 0,
Kit.check_quiet(_ap_of(ob2._fx.dispatch(ob2.counters_ctx, "round_start", {"round_index": 3})) == 0,
"双管装填反例:第 3 轮零命中;战斗外零发放(round_start 事件域外无触发面)")
# ③ 阔视野带(参数读数=4 / 未装配=0——注入面消费在呈现桥 T4)
+6 -6
View File
@@ -26,17 +26,17 @@ func _init() -> void:
{"fact": "round_index", "op": "eq", "value": 1}],
"do": {"kind": "shield", "value": 1}}],
"sense_sharp": [{"on": "mine_hit",
"when": [{"fact": "counter", "id": "tree_sense_sharp_used", "op": "lt", "value": 2}],
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}],
"do": {"kind": "damage_mult", "value": 50}},
{"on": "mine_hit",
"when": [{"fact": "counter", "id": "tree_sense_sharp_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "tree_sense_sharp_used", "value": 1, "domain": "adventure"}}],
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_sharp_used", "value": 1}}],
"sense_guard": [{"on": "seal_missed",
"when": [{"fact": "counter", "id": "tree_sense_guard_used", "op": "lt", "value": 2}],
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}],
"do": {"kind": "grant_ap", "mode": "queue", "value": 1}},
{"on": "seal_missed",
"when": [{"fact": "counter", "id": "tree_sense_guard_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "tree_sense_guard_used", "value": 1, "domain": "adventure"}}],
"when": [{"fact": "counter", "id": "lvl_tree_sense_guard_used", "op": "lt", "value": 2}],
"do": {"kind": "counter_add", "counter_id": "lvl_tree_sense_guard_used", "value": 1}}],
}
var six := ["posture_first_strike", "posture_bulwark", "sense_sharp", "sense_guard", "economy_hoard", "economy_spend"]
# rebuild 实装面(db=null→feat/attune 两段空转,_triggers 只含树规则合成行)——行数对照载体
@@ -0,0 +1,71 @@
# 致命类用例(独立进程,期望退出码 1)
# 卡② V9/E1——计数器前缀合法性 + 引用闭合加载期致命双断言:
# ①向 feats.tres 注入 kind=counter_add 但 counter_id 前缀非法(meta_ 出域)的行 → validate_trigger_row 须拦;
# ②注入读点(when fact=counter id)指向无同名写入行的悬空 id → config_db_cross_validator 引用闭合须拦。
# 任一违规被放行即回归(退出码 2)。两夹具目录各跑一次 load_tables,均须拒绝。
# 运行:godot --headless --path game --script res://tests/unit/data_config/counter_prefix_fatal_test.gd
extends SceneTree
const ConfigDBScript := preload("res://core/config_db.gd")
const Kit := preload("res://tests/unit/test_kit.gd")
const SRC_DIR := "res://data/tables"
const BAD_PREFIX_DIR := "user://counter_fixture_prefix"
const BAD_REF_DIR := "user://counter_fixture_ref"
# 注入探针:非法前缀写入行(meta_probe 无 meta_ 合法归桶 → counter_id_legal false)
const PROBE_PREFIX := ' {\n "feat_id": "prefix_probe",\n "display_name": "前缀探针",\n "rarity": "common",\n "triggers": [ { "on": "dig_reveal", "when": [], "do": { "kind": "counter_add", "counter_id": "meta_probe", "value": 1 } } ],\n "desc": "夹具探针——非法前缀"\n},\n'
# 注入探针:悬空读点(when counter id=pain_layer 单数,全表无同名写入行 → 引用闭合拦)
const PROBE_REF := ' {\n "feat_id": "ref_probe",\n "display_name": "引用探针",\n "rarity": "common",\n "triggers": [ { "on": "dig_reveal", "when": [ { "fact": "counter", "id": "pain_layer", "op": "lt", "value": 1 } ], "do": { "kind": "grant_ap", "mode": "queue", "value": 1 } } ],\n "desc": "夹具探针——悬空读点"\n},\n'
func _inject(dir: String, probe: String) -> bool:
var path := dir + "/feats.tres"
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
push_error("注入失败:读不到 %s" % path)
return false
var s := f.get_as_text()
f.close()
var anchor := "rows = ["
var idx := s.find(anchor)
if idx < 0:
push_error("注入失败:锚点 '%s' 未找到(len=%d)" % [anchor, s.length()])
return false
var cut := idx + anchor.length()
s = s.substr(0, cut) + "\n" + probe + s.substr(cut)
var w := FileAccess.open(path, FileAccess.WRITE)
if w == null:
push_error("注入失败:写不开 %s" % path)
return false
w.store_string(s)
w.close()
return true
func _assert_rejected(bad_dir: String, probe: String, label: String) -> bool:
# 返回 true=正确拒绝;false=放行或夹具失败(调用方据此退出 2)
if not Kit.copy_tables(SRC_DIR, bad_dir) or not _inject(bad_dir, probe):
push_error("[%s] 夹具目录准备失败" % label)
return false
var db = ConfigDBScript.new()
var ok: bool = db.load_tables(bad_dir, false)
if ok:
push_error("[%s] 违规表被放行——加载期校验失守(回归)" % label)
return false
if db.diagnostics_count("error") < 1:
push_error("[%s] 加载被拒但零 error 诊断——校验通道静默化(回归)" % label)
return false
print("[%s] 正确拒绝(error 诊断在案)" % label)
return true
func _init() -> void:
if not _assert_rejected(BAD_PREFIX_DIR, PROBE_PREFIX, "counter_prefix_fatal_test/①非法前缀"):
quit(2)
return
if not _assert_rejected(BAD_REF_DIR, PROBE_REF, "counter_prefix_fatal_test/②悬空读点"):
quit(2)
return
print("[counter_prefix_fatal_test] 双断言均正确拒绝(前缀合法性 + 引用闭合)")
quit(1)
@@ -0,0 +1 @@
uid://ckn8u3rt64qmi