From 4c5bdbc9aa96f255340f560f95085bc823c601ec Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Tue, 20 Oct 2026 23:12:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=8D=A1=E2=91=A1=20T5):=20=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E5=99=A8=E5=BC=95=E7=94=A8=E9=97=AD=E5=90=88=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20+=20V9=20=E8=B4=9F=E9=9D=A2=E5=A5=97=E4=BB=B6=20+?= =?UTF-8?q?=20manifest=20=E7=99=BB=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config_db_cross_validator ⑦ 引用闭合:声明集=feats+treasure 各行 do.counter_id, 读点(when counter id / value_counter / counter_spend counter_id)无同名写入行→加载致命 (判据只做成员判定,不重写前缀规则——契约 6 两落点同源) - 新建 counter_prefix_fatal_test.gd(expect1/negative):①meta_ 非法前缀②悬空读点两断言,均正确拒绝 - run_all.gd manifest 登记负面套件 real 数据 config_db_test 26 全绿(闭合零误伤);insurance_used 经 forgive_seal_miss 行 do.counter_id 声明纳入。 --- game/core/config_db_cross_validator.gd | 34 +++++++++ game/tests/run_all.gd | 1 + .../data_config/counter_prefix_fatal_test.gd | 71 +++++++++++++++++++ .../counter_prefix_fatal_test.gd.uid | 1 + 4 files changed, 107 insertions(+) create mode 100644 game/tests/unit/data_config/counter_prefix_fatal_test.gd create mode 100644 game/tests/unit/data_config/counter_prefix_fatal_test.gd.uid diff --git a/game/core/config_db_cross_validator.gd b/game/core/config_db_cross_validator.gd index 7ba5f18e..f1bc4a79 100644 --- a/game/core/config_db_cross_validator.gd +++ b/game/core/config_db_cross_validator.gd @@ -109,6 +109,40 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: if quota < 1: push_diag.call("error", "shelf_zero_quota", "ShopTable.pool_shelf kind %s 令牌配额为 0(牌库空种入货架 = 库存无锚)" % kind) + # ⑦ 计数器引用闭合(卡② V9——读点 id 必命中同名写入行,否则加载致命): + # 声明集 = feats + treasure 各行 triggers 内 kind=counter_add 的 do.counter_id; + # 读点 = when fact=counter 的 id、do.value_counter、kind=counter_spend 的 counter_id。 + var feats_tab = loaded.get("feats") + var trig_rows: Array = [] + if feats_tab != null: + for r in feats_tab.rows: + trig_rows.append(r) + if treasure_tab != null: + for r in treasure_tab.rows: + trig_rows.append(r) + var counter_decls := {} + for row in trig_rows: + for tr_v in (row.get("triggers", []) as Array): + var dd: Dictionary = (tr_v as Dictionary).get("do", {}) as Dictionary + # 声明集=任一 do.counter_id 行(counter_add 直写、forgive_seal_miss 经 _apply_counter_hit 写——皆写入行) + if dd.has("counter_id"): + counter_decls[String(dd.counter_id)] = true + for row in trig_rows: + var rid := String(row.get("feat_id", row.get("item_id", ""))) + for tr_v in (row.get("triggers", []) as Array): + var tr: Dictionary = tr_v as Dictionary + for c_v in (tr.get("when", []) as Array): + var c: Dictionary = c_v as Dictionary + if String(c.get("fact", "")) == "counter" and c.has("id"): + var rd := String(c.id) + if not counter_decls.has(rd): + push_diag.call("error", "counter_dangling_ref", "%s when counter 读点无同名写入行: %s(引用闭合)" % [rid, rd]) + var do2: Dictionary = tr.get("do", {}) as Dictionary + if do2.has("value_counter") and not counter_decls.has(String(do2.value_counter)): + push_diag.call("error", "counter_dangling_ref", "%s value_counter 读点无同名写入行: %s" % [rid, String(do2.value_counter)]) + if String(do2.get("kind", "")) == "counter_spend" and do2.has("counter_id") and not counter_decls.has(String(do2.counter_id)): + push_diag.call("error", "counter_dangling_ref", "%s counter_spend 无同名写入行: %s" % [rid, String(do2.counter_id)]) + # 池模板 × 技能行跨表校验(加载期致命;返回错误列表——ConfigDB 诊断通道与冒烟直验共用): # skill_id 悬空致命 + 池构成纪律(每池恰一行普攻、增益技 ≤1、妨害技 ≤1——proposal §2.1 结构保证 + diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 7e9113e0..9f282a4c 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -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}, # 断点往返/续玩确定性/幂等/退出清断点 diff --git a/game/tests/unit/data_config/counter_prefix_fatal_test.gd b/game/tests/unit/data_config/counter_prefix_fatal_test.gd new file mode 100644 index 00000000..f5289ec2 --- /dev/null +++ b/game/tests/unit/data_config/counter_prefix_fatal_test.gd @@ -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) diff --git a/game/tests/unit/data_config/counter_prefix_fatal_test.gd.uid b/game/tests/unit/data_config/counter_prefix_fatal_test.gd.uid new file mode 100644 index 00000000..1a1ee1ff --- /dev/null +++ b/game/tests/unit/data_config/counter_prefix_fatal_test.gd.uid @@ -0,0 +1 @@ +uid://ckn8u3rt64qmi