From 9646ce545de48825df67d0fbdfe0124e0fcd0480 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Wed, 28 Oct 2026 07:14:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=A1=E2=91=A0T2(=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E9=9D=A2=E7=BB=93=E6=9E=84=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E6=89=B9):=20=E6=A0=A1=E9=AA=8C=E6=97=8F=E5=B0=8F=E8=A1=A8?= =?UTF-8?q?=E7=BB=84+=E8=B7=A8=E8=A1=A8=E6=A0=A1=E9=AA=8C=E5=99=A8+?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=99=A8=E5=85=AB=E6=96=87=E4=BB=B6=E6=8B=86?= =?UTF-8?q?=E5=88=86=E2=80=94=E2=80=94board=5Fgen=20=E8=A1=8C/=E7=BA=A6?= =?UTF-8?q?=E6=9D=9F/=E6=94=B6=E7=AA=84=E4=B8=89=E5=9F=9F/level=5Fgen=5Fpa?= =?UTF-8?q?rams=20=E8=A1=8C=E5=A4=B4(skip)/=E5=BD=A2=E6=80=81/=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=89=E5=9F=9F+=E5=B1=82=E6=B7=B1=E5=B0=BE?= =?UTF-8?q?=E6=AE=B5/meta=5Ftable=20=E4=B8=89=E8=A1=8C=E6=97=8F=E5=9F=9F/s?= =?UTF-8?q?kill=5Ftable=20=E8=A1=8C=E5=A4=B4(skip)/=E5=85=AC=E5=85=B1/?= =?UTF-8?q?=E5=9B=9B=E7=B1=BB=E5=9E=8B=E5=9F=9F+=E8=A1=A8=E4=B8=8D?= =?UTF-8?q?=E5=8F=98=E9=87=8F/variation=20=E8=A1=8C=E5=9F=9F+=E6=95=88?= =?UTF-8?q?=E6=9E=9C=E4=BA=92=E9=94=81+=E5=85=A8=E5=B1=80=E4=BA=92?= =?UTF-8?q?=E9=94=81/status=5Feffect=20=E8=A1=8C=E5=A4=B4+=E9=87=8F?= =?UTF-8?q?=E7=BA=B2=E5=9F=9F/cross=5Fvalidator=20=E4=B8=83=E7=BC=96?= =?UTF-8?q?=E5=8F=B7=E5=9D=97=E5=9F=9F=E5=87=BD=E6=95=B0=E5=8C=96(=5Fcv=5F?= =?UTF-8?q?reward=5Fdangling/=5Fcv=5Freward=5Ftreasure/=5Fcv=5Fpoolmap/=5F?= =?UTF-8?q?cv=5Fsource=5Flabels/=5Fcv=5Fskill=5Fpool/=5Fcv=5Fshelf=5Fpool/?= =?UTF-8?q?=5Fcv=5Fcounter=5Fclosure+decls/reads=E5=88=86=E5=B1=82)+valida?= =?UTF-8?q?te=5Fskill=5Fpool=5Flinks=E8=AE=A1=E6=95=B0=E6=9D=9F=E5=8C=96/r?= =?UTF-8?q?esolver=20resolve=5Fenemy=5Frowset=E4=B8=89=E7=BA=AF=E8=A1=8C?= =?UTF-8?q?=E6=89=AB=E6=8F=8F+=5Fresolve=5Fprogress=E4=B8=89=E6=AE=B5(?= =?UTF-8?q?=E5=9D=90=E6=A0=87=E5=AF=B9=E6=9D=9F=E5=8C=96);=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=B8=85=E5=8D=95=E4=B8=8E=E5=91=8A=E8=AD=A6=E5=BA=8F?= =?UTF-8?q?=E9=80=90=E5=AD=97,23=E9=94=AE=E5=85=A8=E6=B8=85,data=E9=9D=A21?= =?UTF-8?q?3=E5=A5=97=E4=BB=B6=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/core/config_db_cross_validator.gd | 135 +++++++++++++------- game/core/config_db_resolver.gd | 114 ++++++++++------- game/data/schemas/board_gen_table.gd | 141 ++++++++++++--------- game/data/schemas/level_gen_params.gd | 111 ++++++++++------- game/data/schemas/meta_table.gd | 27 +++- game/data/schemas/skill_table.gd | 149 ++++++++++++++--------- game/data/schemas/status_effect_table.gd | 66 ++++++---- game/data/schemas/variation_table.gd | 123 +++++++++++-------- 8 files changed, 545 insertions(+), 321 deletions(-) diff --git a/game/core/config_db_cross_validator.gd b/game/core/config_db_cross_validator.gd index 8bcc0fa7..0ca4c90d 100644 --- a/game/core/config_db_cross_validator.gd +++ b/game/core/config_db_cross_validator.gd @@ -11,21 +11,43 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: var st_tab: SettlementTable = loaded.get("settlement") if st == null or rt == null or st_tab == null: return # 单表校验已报错,不连锁 + # ①c F2 投放维度覆盖校验不启用:挖格不掉道具,秘宝 + # 唯一来路=三选一共享池〔节点/关卡完成〕;共享池为「抽到道具位再动态掷定具体道具」,维度覆盖不可 + # 静态校验。 + var effect_ids := _effect_ids_of(st) + _cv_reward_dangling(rt, effect_ids, push_diag) # ① 增益词表悬空 + _cv_reward_treasure(loaded, rt, push_diag) # ①b aux 投放行双表 + _cv_poolmap(st_tab, effect_ids, push_diag) # ② 池映射悬空 + _cv_source_labels(st_tab, push_diag) # ③ 来源标签键集 + _cv_skill_pool(loaded, push_diag) # ⑤ 技能池引用完整性 + _cv_shelf_pool(loaded, push_diag) # ⑥ 商店货架 × 冒险共享池 + _cv_counter_closure(loaded, push_diag) # ⑦ 计数器引用闭合 + + +## 增益状态词表全集(effect_id → true) +func _effect_ids_of(st: StatusEffectTable) -> Dictionary: var effect_ids := {} for row in st.rows: effect_ids[row.get("effect_id", "")] = true - # ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。 - # 例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键 - # (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。 + return effect_ids + + +## ① RewardTable effect_id 悬空(percent 行引用 StatusEffectTable——悬空加载致命)。 +## 例外:gp_gain/gp_loss = GP 战斗内缓冲(escrow)通道的路由键 +## (战斗结算 _apply_gp_row 兑付——不入增益状态容器),词表封闭按字面豁免悬空引用校验。 +func _cv_reward_dangling(rt: RewardTable, effect_ids: Dictionary, push_diag: Callable) -> void: for row in rt.rows: if String(row.get("cell_type", "")) == RewardTable.TREASURE_ITEM_TYPE: continue # aux 行 effect_id=item_id——归 ①b 双表校验(reward_dangling_treasure),不入增益词表面 var eff: String = row.get("effect_id", "") if eff != "" and eff != "gp_gain" and eff != "gp_loss" and not effect_ids.has(eff): push_diag.call("error", "dangling_effect", "RewardTable %s 引用不存在的 effect_id: %s" % [row.get("reward_id"), eff]) - # ①b RewardTable aux 投放行双表校验(reward_dangling_treasure):aux 行 effect_id = item_id, - # 须命中 TreasureItemTable 活行(非 retired 非 deferred——罗盘退役/失义件不投放)。retired 行被引用=退役 - # 后仍投放(连带错误),同拦。 + + +## ①b RewardTable aux 投放行双表校验(reward_dangling_treasure):aux 行 effect_id = item_id, +## 须命中 TreasureItemTable 活行(非 retired 非 deferred——罗盘退役/失义件不投放)。retired 行被引用=退役 +## 后仍投放(连带错误),同拦。 +func _cv_reward_treasure(loaded: Dictionary, rt: RewardTable, push_diag: Callable) -> void: var treasure_live := {} var treasure_tab = loaded.get("treasure_items") if treasure_tab != null: @@ -39,27 +61,36 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: continue if not treasure_live.has(treasure_eff): push_diag.call("error", "reward_dangling_treasure", "RewardTable %s aux 行引用无效道具 %s(retired/deferred/不存在): %s" % [row.get("reward_id"), treasure_eff, "双表校验"]) - # ①c F2 投放维度覆盖校验不启用:挖格不掉道具,秘宝 - # 唯一来路=三选一共享池〔节点/关卡完成〕;共享池为「抽到道具位再动态掷定具体道具」,维度覆盖不可 - # 静态校验。 - # ② SettlementTable.effect_pool_map 悬空 + + +## ② SettlementTable.effect_pool_map 悬空 +func _cv_poolmap(st_tab: SettlementTable, effect_ids: Dictionary, push_diag: Callable) -> void: for eff in st_tab.core.effect_pool_map.keys(): if not effect_ids.has(eff): push_diag.call("error", "poolmap_dangling", "SettlementTable.effect_pool_map 悬空 effect_id: %s" % eff) - # ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, seal_miss}) + + +## ③ source_label_map 键集封闭性(键 = 增益状态 source_class ∪ {enemy_action, hero_attack, seal_miss}) +func _cv_source_labels(st_tab: SettlementTable, push_diag: Callable) -> void: var required_sources := ["safe_cell", "seal_hit", "mine_hit", "clear_reward", "hero_attack", "enemy_action", "seal_miss"] for s in required_sources: if not st_tab.core.source_label_map.has(s): push_diag.call("error", "segorder_violation", "source_label_map 缺必需键: %s" % s) - # ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验) + + +## ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验) +func _cv_skill_pool(loaded: Dictionary, push_diag: Callable) -> void: var en: EnemyTable = loaded.get("enemy") var skt = loaded.get("skill") if skt != null and en != null: for e in validate_skill_pool_links(skt, en, loaded.get("status_effects")): push_diag.call("error", e.category, e.message) - # ⑥ 商店货架 × 冒险共享池跨表校验(M-4——次数上限域): - # pool_shelf kind 须命中 adventure.CURIO_KINDS 且为有限库存种(cap ∈ [1,99]——gp 无上限种/牌库外种 - # 入货架 = 库存账本无锚,加载致命;gp 袋负交易切边/规则改写型特性三轴切边由 ShopTable 词表封闭另拦) + + +## ⑥ 商店货架 × 冒险共享池跨表校验(M-4——次数上限域): +## pool_shelf kind 须命中 adventure.CURIO_KINDS 且为有限库存种(cap ∈ [1,99]——gp 无上限种/牌库外种 +## 入货架 = 库存账本无锚,加载致命;gp 袋负交易切边/规则改写型特性三轴切边由 ShopTable 词表封闭另拦) +func _cv_shelf_pool(loaded: Dictionary, push_diag: Callable) -> void: var adv = loaded.get("adventure") var shp = loaded.get("shop") if adv != null and shp != null: @@ -78,34 +109,42 @@ 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") + +## ⑦ 计数器引用闭合(卡② V9——读点 id 必命中同名写入行,否则加载致命): +## 声明集 = feats + treasure 各行 triggers 内 kind=counter_add 的 do.counter_id; +## 读点 = when fact=counter 的 id、do.value_counter、kind=counter_spend 的 counter_id。 +func _cv_counter_closure(loaded: Dictionary, push_diag: Callable) -> void: var trig_rows: Array[Variant] = [] + var feats_tab = loaded.get("feats") if feats_tab != null: for r in feats_tab.rows: trig_rows.append(r) + var treasure_tab = loaded.get("treasure_items") if treasure_tab != null: for r in treasure_tab.rows: trig_rows.append(r) + var counter_decls := _counter_decls_of(trig_rows) + _cv_counter_reads(trig_rows, counter_decls, push_diag) + + +## 计数器声明集收集:任一 do.counter_id 行(counter_add 直写、forgive_seal_miss 经 _apply_counter_hit 写——皆写入行) +func _counter_decls_of(trig_rows: Array) -> Dictionary: var counter_decls := {} for row in trig_rows: for tr_v in (row.get("triggers", []) as Array): var dd: Dictionary[Variant, Variant] = (tr_v as Dictionary).get("do", {}) as Dictionary[Variant, Variant] - # 声明集=任一 do.counter_id 行(counter_add 直写、forgive_seal_miss 经 _apply_counter_hit 写——皆写入行) if dd.has("counter_id"): counter_decls[String(dd.counter_id)] = true + return counter_decls + + +## 计数器读点校验:when counter id/do.value_counter/counter_spend 的 counter_id 必命中声明集 +func _cv_counter_reads(trig_rows: Array, counter_decls: Dictionary, push_diag: Callable) -> void: 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[Variant, Variant] = tr_v as Dictionary[Variant, Variant] - for c_v in (tr.get("when", []) as Array): - var c: Dictionary[Variant, Variant] = c_v as Dictionary[Variant, Variant] - 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]) + _check_counter_when(rid, tr, counter_decls, push_diag) var do2: Dictionary[Variant, Variant] = tr.get("do", {}) as Dictionary[Variant, Variant] 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)]) @@ -113,6 +152,16 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void: push_diag.call("error", "counter_dangling_ref", "%s counter_spend 无同名写入行: %s" % [rid, String(do2.counter_id)]) +## when counter 读点校验(fact=counter 条件的 id 必命中声明集) +func _check_counter_when(rid: String, tr: Dictionary, counter_decls: Dictionary, push_diag: Callable) -> void: + for c_v in (tr.get("when", []) as Array): + var c: Dictionary[Variant, Variant] = c_v as Dictionary[Variant, Variant] + 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]) + + # 池模板 × 技能行跨表校验(加载期致命;返回错误列表——ConfigDB 诊断通道与冒烟直验共用): # skill_id 悬空致命 + 池构成纪律(每池恰一行普攻、增益技 ≤1、妨害技 ≤1——proposal §2.1 结构保证 + # proposal-status-vocabulary §1.2)+ 施放类 side 守卫(增益引用 enemy 侧 / 妨害引用 hero 侧状态行)。 @@ -141,33 +190,25 @@ func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, errs.append_array(_pool_cast_side_errs(skill_table, status_table)) # 施放类 side 守卫(buff→敌方侧/debuff→主角侧,防反向挂接——全表恰一次) for row in enemy_table.rows: var eid: String = String(row.get("enemy_id", "")) - var basic_n := 0 - var buff_n := 0 - var debuff_n := 0 - var uncond_special_n := 0 + var pool_n := {"basic": 0, "buff": 0, "debuff": 0, "uncond_special": 0} for entry in row.get("pool", []): var sid: String = String(entry.get("skill_id", "")) if not skill_types.has(sid): errs.append({"category": "dangling_skill", "message": "EnemyTable %s 池引用不存在的 skill_id: %s(悬空致命)" % [eid, sid]}) continue - match skill_types[sid]: - "basic": - basic_n += 1 - "buff": - buff_n += 1 - "debuff": - debuff_n += 1 + if pool_n.has(skill_types[sid]): + pool_n[skill_types[sid]] += 1 # 无条件特殊技计数(普攻不计;trigger 缺省行计数——触发条件批 2026-10-26 池构成纪律) - if skill_types[sid] != "basic" and not (skill_table.row_by_id(sid) if skill_table.has_method("row_by_id") else {}).is_empty(): + if skill_types[sid] != "basic": var srow_c: Dictionary[Variant, Variant] = skill_table.row_by_id(sid) if skill_table.has_method("row_by_id") else {} - if not srow_c.has("trigger"): - uncond_special_n += 1 - if basic_n != 1: - errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, basic_n]}) - if buff_n > 1: - errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, buff_n]}) - if debuff_n > 1: - errs.append({"category": "pool_composition", "message": "EnemyTable %s 池妨害技 > 1 (%d)——每池妨害 ≤1 纪律(新效应词汇批 2026-09-19)" % [eid, debuff_n]}) - if uncond_special_n < 1: + if not srow_c.is_empty() and not srow_c.has("trigger"): + pool_n["uncond_special"] += 1 + if pool_n["basic"] != 1: + errs.append({"category": "pool_composition", "message": "EnemyTable %s 池普攻行数 ≠ 1 (%d)——普攻冷却恒 0 = 空池不可能的结构前提" % [eid, pool_n["basic"]]}) + if pool_n["buff"] > 1: + errs.append({"category": "pool_composition", "message": "EnemyTable %s 池增益技 > 1 (%d)——每池增益 ≤1 纪律(纠偏三)" % [eid, pool_n["buff"]]}) + if pool_n["debuff"] > 1: + errs.append({"category": "pool_composition", "message": "EnemyTable %s 池妨害技 > 1 (%d)——每池妨害 ≤1 纪律(新效应词汇批 2026-09-19)" % [eid, pool_n["debuff"]]}) + if pool_n["uncond_special"] < 1: errs.append({"category": "pool_composition", "message": "EnemyTable %s 池无条件特殊技行数 = 0——每池至少一行无条件特殊技纪律(触发条件批 2026-10-26)" % [eid]}) return errs diff --git a/game/core/config_db_resolver.gd b/game/core/config_db_resolver.gd index 34852bdd..37668e44 100644 --- a/game/core/config_db_resolver.gd +++ b/game/core/config_db_resolver.gd @@ -43,11 +43,7 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary] return [] var rows: Array[Dictionary] = [] rows.assign(_enemy_rows_get.call()) - var level_keys: Array[int] = [] - for row in rows: - var lv := int(row.get("level_index", 0)) - if not level_keys.has(lv): - level_keys.append(lv) + var level_keys: Array[int] = _level_keys_of(rows) if level_keys.is_empty(): _diag.call("fatal", "structural", "enemy 行集为空——锚行校验应已拦截") return [] @@ -58,12 +54,7 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary] _warn_resolve("enemy", "resolve_level_clamp", "enemy 请求 (%d,%d) clamp 解析(超出最大关卡取最大值)→ 关卡 %d" % [L, D, l_star], l_star, D) elif l_star != L: _warn_resolve("enemy", "resolve_level_ceil", "enemy 请求 (%d,%d) ceil 解析(向上取整保证偏难)→ 关卡 %d" % [L, D, l_star], l_star, D) - var band_keys: Array[int] = [] - for row in rows: - if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind: - var bv := int(row.get("depth_band", -1)) - if not band_keys.has(bv): - band_keys.append(bv) + var band_keys: Array[int] = _band_keys_of(rows, l_star, row_kind) if band_keys.is_empty(): return [] band_keys.sort() @@ -73,6 +64,32 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary] _warn_resolve("enemy", "resolve_depth_clamp", "enemy 请求 (%d,%d) clamp 解析(超出顶带取顶带)→ (%d,band %d)" % [L, D, l_star, d_star], l_star, d_star) elif d_star != D: _warn_resolve("enemy", "resolve_depth_ceil", "enemy 请求 (%d,%d) ceil 解析(向上取整保证偏难)→ (%d,band %d)" % [L, D, l_star, d_star], l_star, d_star) + return _band_rows(rows, l_star, row_kind, d_star) + + +## 行集关卡键收集(纯行扫描——告警发射序不变) +func _level_keys_of(rows: Array) -> Array[int]: + var level_keys: Array[int] = [] + for row in rows: + var lv := int(row.get("level_index", 0)) + if not level_keys.has(lv): + level_keys.append(lv) + return level_keys + + +## 带键收集(纯行扫描——l_star × row_kind 的 depth_band 去重集) +func _band_keys_of(rows: Array, l_star: int, row_kind: String) -> Array[int]: + var band_keys: Array[int] = [] + for row in rows: + if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind: + var bv := int(row.get("depth_band", -1)) + if not band_keys.has(bv): + band_keys.append(bv) + return band_keys + + +## 带行收集(纯行扫描——命中带全部行、按表文件行序) +func _band_rows(rows: Array, l_star: int, row_kind: String, d_star: int) -> Array[Dictionary]: var all_out: Array[Dictionary] = [] for row in rows: if int(row.get("level_index", 0)) == l_star and String(row.get("row_kind", "")) == row_kind \ @@ -108,11 +125,7 @@ func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Variant if L < 1 or D < 0: _diag.call("fatal", "request_oob", "%s 请求越域 (%d,%d)——进度坐标低于合法域(关卡序号≥1、节点深度≥0)" % [table_id, L, D]) return {} - var level_keys: Array[int] = [] - for row in rows: - var lv := int(row.get("level_index", 0)) - if not level_keys.has(lv): - level_keys.append(lv) + var level_keys: Array[int] = _level_keys_of(rows) if level_keys.is_empty(): _diag.call("fatal", "structural", "%s 行集为空——锚行校验应已拦截" % table_id) return {} @@ -121,40 +134,59 @@ func _resolve_progress(rows: Array, L: int, D: int, table_id: String) -> Variant var l_clamped := l_star < 0 if l_clamped: l_star = int(level_keys[-1]) # 超出最大行 → 取最大行封顶(可取范围内向上取整,超出用最大值) + var depth_keys: Array[int] = _depth_keys_of(rows, l_star) + depth_keys.sort() + var d_star := _ceil_key(depth_keys, D) + var d_clamped := d_star < 0 + if d_clamped: + d_star = int(depth_keys[-1]) + var missing := _missing_label(l_clamped, d_clamped, l_star != L, d_star != D) + if missing != "": + _emit_missing_warning(table_id, missing, [l_star, d_star], [L, D]) + for row in rows: + if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star: + return row + return {} + + +## 层键收集(纯行扫描——l_star 行的 node_depth 去重集) +func _depth_keys_of(rows: Array, l_star: int) -> Array[int]: var depth_keys: Array[int] = [] for row in rows: if int(row.get("level_index", 0)) == l_star: var dv := int(row.get("node_depth", 0)) if not depth_keys.has(dv): depth_keys.append(dv) - depth_keys.sort() - var d_star := _ceil_key(depth_keys, D) - var d_clamped := d_star < 0 - if d_clamped: - d_star = int(depth_keys[-1]) - var missing := "" + return depth_keys + + +## 解析偏移形态标签(clamp/ceil × level/depth/both 六形态——空串 = 精确命中; +## l_ceil/d_ceil = 解析值 ≠ 请求值) +func _missing_label(l_clamped: bool, d_clamped: bool, l_ceil: bool, d_ceil: bool) -> String: + var label := "" if l_clamped and d_clamped: - missing = "both_clamp" + label = "both_clamp" elif l_clamped: - missing = "level_clamp" + label = "level_clamp" elif d_clamped: - missing = "depth_clamp" - elif l_star != L and d_star != D: - missing = "both_ceil" - elif l_star != L: - missing = "level_ceil" - elif d_star != D: - missing = "depth_ceil" - if missing != "": - var dedup_key := "%s|%s|%d|%d" % [table_id, missing, l_star, d_star] - if not _warn_dedup.has(dedup_key): - _warn_dedup[dedup_key] = true - var verb := "clamp 解析(超出最大行取最大值)" if missing.contains("clamp") else "ceil 解析(向上取整保证偏难)" - _diag.call("warning", "resolve_" + missing, "%s 请求 (%d,%d) %s → (%d,%d) [%s]——2026-09-18 拍板" % [table_id, L, D, verb, l_star, d_star, missing]) - for row in rows: - if int(row.get("level_index", 0)) == l_star and int(row.get("node_depth", 0)) == d_star: - return row - return {} + label = "depth_clamp" + elif l_ceil and d_ceil: + label = "both_ceil" + elif l_ceil: + label = "level_ceil" + elif d_ceil: + label = "depth_ceil" + return label + + +## 偏移告警发射(四元组去重注册表语义逐字——R1 既有去重; +## req/resolved = [L, D]/[l_star, d_star] 进度坐标对) +func _emit_missing_warning(table_id: String, missing: String, resolved: Array, req: Array) -> void: + var dedup_key := "%s|%s|%d|%d" % [table_id, missing, resolved[0], resolved[1]] + if not _warn_dedup.has(dedup_key): + _warn_dedup[dedup_key] = true + var verb := "clamp 解析(超出最大行取最大值)" if missing.contains("clamp") else "ceil 解析(向上取整保证偏难)" + _diag.call("warning", "resolve_" + missing, "%s 请求 (%d,%d) %s → (%d,%d) [%s]——2026-09-18 拍板" % [table_id, req[0], req[1], verb, resolved[0], resolved[1], missing]) # 取 sorted_keys 中第一个 >= request 的键;无(请求高于最大行)返回 -1 → 调用方 Fatal diff --git a/game/data/schemas/board_gen_table.gd b/game/data/schemas/board_gen_table.gd index 664c4ab2..2401f512 100644 --- a/game/data/schemas/board_gen_table.gd +++ b/game/data/schemas/board_gen_table.gd @@ -19,63 +19,92 @@ func validate() -> Array: var levels := {} var anchor_ok := {} for row in rows: - var L: int = int(row.get("level_index", 0)) - var D: int = int(row.get("node_depth", -1)) - var key := "%d_%d" % [L, D] - if levels.has(key): - errs.append({"category": "dup_key", "message": "BoardGen 键重复: %s" % key}) - levels[key] = true - if L < 1: - errs.append({"category": "domain", "message": "level_index 须 ≥1"}) - if D < 0: - errs.append({"category": "domain", "message": "node_depth 须 ≥0"}) - var w: int = int(row.get("board_width", 0)) - var h: int = int(row.get("board_height", 0)) - if w < 8 or w > 64 or h < 8 or h > 64: - errs.append({"category": "domain", "message": "(%d,%d) 尺寸越域 [8,64]" % [L, D]}) - var d: float = float(row.get("mine_density", 0.0)) - if d <= 0.0 or d > 0.5: - errs.append({"category": "domain", "message": "(%d,%d) mine_density 越域 (0,0.5]" % [L, D]}) - var t: float = float(row.get("inferability_target", 0.0)) - if t < TARGET_ABS_FLOOR or t > 1.0: - errs.append({"category": "domain", "message": "(%d,%d) inferability_target 越域 [0.85,1](P1 绝对锚)" % [L, D]}) - if not SOLVER_RULES.has(row.get("solver_rules", "")): - errs.append({"category": "enum", "message": "(%d,%d) solver_rules 出域" % [L, D]}) - if row.get("solver_rules", "") != "single_subset": - errs.append({"category": "frozen", "message": "(%d,%d) solver_rules 冻结 single_subset" % [L, D]}) - var ms: int = int(row.get("min_safe_count", 0)) - if ms < 1 or ms > 999: - errs.append({"category": "domain", "message": "(%d,%d) min_safe_count 越域 [1,999]" % [L, D]}) - var att: int = int(row.get("max_gen_attempts", 0)) - if att < 1 or att > 32: - errs.append({"category": "domain", "message": "(%d,%d) max_gen_attempts 越域 [1,32]" % [L, D]}) - # 布雷数下限(R3)——零雷板拒绝(−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS) - var n_mines := int(round(d * float(w * h - BoardData.SPAWN_AREA_CELLS))) - if n_mines < 1: - errs.append({"category": "cross_field", "message": "(%d,%d) 布雷数下限违例 round(d×(W×H−9)) ≥ 1" % [L, D]}) - # F2b min_safe 可行性 - if w * h - n_mines < ms: - errs.append({"category": "cross_field", "message": "(%d,%d) min_safe_feasible 违例" % [L, D]}) - # F2c 成本可行性 - if att * w * h > COST_BUDGET: - errs.append({"category": "cross_field", "message": "(%d,%d) cost_feasible 违例 attempts×W×H ≤ %d" % [L, D, COST_BUDGET]}) - # 图类型词表 v1.0(dungeon=地牢基线 / underdark=幽暗地域;扩词走本表修订) - var bt: String = str(row.get("board_type", "")) - if not BOARD_TYPE_VOCABULARY.has(bt): - errs.append({"category": "cross_field", "message": "(%d,%d) board_type 越词表: %s" % [L, D, bt]}) - # 初始展开收窄两参数(加载期校验违例致命;缺省 shave=1 现行投值 / cap=0 闸关防御口径) - var sr: int = int(row.get("shave_ring_radius", 1)) - if sr < 0 or sr > 2: - errs.append({"category": "domain", "message": "(%d,%d) shave_ring_radius 越域 [0,2]: %d" % [L, D, sr]}) - var r0_cap: int = int(row.get("r0_max_cap", 0)) - if r0_cap > 0: - # 护栏(需求卡片 §二):cap ≥ 出生格数 9(BoardData.SPAWN_AREA_CELLS)、cap ≤ 板格数一半 - if r0_cap < BoardData.SPAWN_AREA_CELLS: - errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 低于出生保障面 %d: %d" % [L, D, BoardData.SPAWN_AREA_CELLS, r0_cap]}) - if r0_cap > (w * h) / 2: - errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 超板格数一半 %d: %d" % [L, D, (w * h) / 2, r0_cap]}) - anchor_ok[L] = anchor_ok.get(L, false) or D == 0 + errs.append_array(_validate_row(row, levels, anchor_ok)) + errs.append_array(_validate_row_constraints(row)) + errs.append_array(_validate_shave_params(row)) for L in anchor_ok.keys(): if not anchor_ok[L]: errs.append({"category": "anchor", "message": "level %d 缺 depth-0 锚行(EC15)" % L}) return errs + + +## 行域:键唯一与进度/尺寸/密度/推理目标/solver 规则冻结(levels/anchor_ok 引用累计) +func _validate_row(row: Dictionary, levels: Dictionary, anchor_ok: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var L: int = int(row.get("level_index", 0)) + var D: int = int(row.get("node_depth", -1)) + var key := "%d_%d" % [L, D] + if levels.has(key): + errs.append({"category": "dup_key", "message": "BoardGen 键重复: %s" % key}) + levels[key] = true + if L < 1: + errs.append({"category": "domain", "message": "level_index 须 ≥1"}) + if D < 0: + errs.append({"category": "domain", "message": "node_depth 须 ≥0"}) + var w: int = int(row.get("board_width", 0)) + var h: int = int(row.get("board_height", 0)) + if w < 8 or w > 64 or h < 8 or h > 64: + errs.append({"category": "domain", "message": "(%d,%d) 尺寸越域 [8,64]" % [L, D]}) + var d: float = float(row.get("mine_density", 0.0)) + if d <= 0.0 or d > 0.5: + errs.append({"category": "domain", "message": "(%d,%d) mine_density 越域 (0,0.5]" % [L, D]}) + var t: float = float(row.get("inferability_target", 0.0)) + if t < TARGET_ABS_FLOOR or t > 1.0: + errs.append({"category": "domain", "message": "(%d,%d) inferability_target 越域 [0.85,1](P1 绝对锚)" % [L, D]}) + if not SOLVER_RULES.has(row.get("solver_rules", "")): + errs.append({"category": "enum", "message": "(%d,%d) solver_rules 出域" % [L, D]}) + if row.get("solver_rules", "") != "single_subset": + errs.append({"category": "frozen", "message": "(%d,%d) solver_rules 冻结 single_subset" % [L, D]}) + anchor_ok[L] = anchor_ok.get(L, false) or D == 0 + return errs + + +## 约束域:min_safe/生成尝试预算/布雷数下限与可行性/成本护栏/图类型词表 +func _validate_row_constraints(row: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var L: int = int(row.get("level_index", 0)) + var D: int = int(row.get("node_depth", -1)) + var w: int = int(row.get("board_width", 0)) + var h: int = int(row.get("board_height", 0)) + var d: float = float(row.get("mine_density", 0.0)) + var ms: int = int(row.get("min_safe_count", 0)) + if ms < 1 or ms > 999: + errs.append({"category": "domain", "message": "(%d,%d) min_safe_count 越域 [1,999]" % [L, D]}) + var att: int = int(row.get("max_gen_attempts", 0)) + if att < 1 or att > 32: + errs.append({"category": "domain", "message": "(%d,%d) max_gen_attempts 越域 [1,32]" % [L, D]}) + # 布雷数下限(R3)——零雷板拒绝(−9 = 出生区格数,常量单点 BoardData.SPAWN_AREA_CELLS) + var n_mines := int(round(d * float(w * h - BoardData.SPAWN_AREA_CELLS))) + if n_mines < 1: + errs.append({"category": "cross_field", "message": "(%d,%d) 布雷数下限违例 round(d×(W×H−9)) ≥ 1" % [L, D]}) + # F2b min_safe 可行性 + if w * h - n_mines < ms: + errs.append({"category": "cross_field", "message": "(%d,%d) min_safe_feasible 违例" % [L, D]}) + # F2c 成本可行性 + if att * w * h > COST_BUDGET: + errs.append({"category": "cross_field", "message": "(%d,%d) cost_feasible 违例 attempts×W×H ≤ %d" % [L, D, COST_BUDGET]}) + # 图类型词表 v1.0(dungeon=地牢基线 / underdark=幽暗地域;扩词走本表修订) + var bt: String = str(row.get("board_type", "")) + if not BOARD_TYPE_VOCABULARY.has(bt): + errs.append({"category": "cross_field", "message": "(%d,%d) board_type 越词表: %s" % [L, D, bt]}) + return errs + + +## 初始展开收窄域(加载期校验违例致命;缺省 shave=1 现行投值 / cap=0 闸关防御口径) +func _validate_shave_params(row: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var L: int = int(row.get("level_index", 0)) + var D: int = int(row.get("node_depth", -1)) + var w: int = int(row.get("board_width", 0)) + var h: int = int(row.get("board_height", 0)) + var sr: int = int(row.get("shave_ring_radius", 1)) + if sr < 0 or sr > 2: + errs.append({"category": "domain", "message": "(%d,%d) shave_ring_radius 越域 [0,2]: %d" % [L, D, sr]}) + var r0_cap: int = int(row.get("r0_max_cap", 0)) + if r0_cap > 0: + # 护栏(需求卡片 §二):cap ≥ 出生格数 9(BoardData.SPAWN_AREA_CELLS)、cap ≤ 板格数一半 + if r0_cap < BoardData.SPAWN_AREA_CELLS: + errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 低于出生保障面 %d: %d" % [L, D, BoardData.SPAWN_AREA_CELLS, r0_cap]}) + if r0_cap > (w * h) / 2: + errs.append({"category": "cross_field", "message": "(%d,%d) r0_max_cap 超板格数一半 %d: %d" % [L, D, (w * h) / 2, r0_cap]}) + return errs diff --git a/game/data/schemas/level_gen_params.gd b/game/data/schemas/level_gen_params.gd index 20df9628..c3e963f6 100644 --- a/game/data/schemas/level_gen_params.gd +++ b/game/data/schemas/level_gen_params.gd @@ -17,50 +17,13 @@ func validate() -> Array: var seen := {} var layers_by_level := {} for row in rows: - var L: int = int(row.get("level_index", 0)) - var key := "level %d" % L - if seen.has(L): - errs.append({"category": "dup_key", "message": "LevelGenParams 键重复: %d" % L}) - seen[L] = true - if L < 1: - errs.append({"category": "domain", "message": "%s 关卡序号越域(≥1)" % key}) - var ks := (row as Dictionary).keys() - ks.sort() - var expect := ROW_KEYS.duplicate() - expect.sort() - if ks != expect: - errs.append({"category": "structural", "message": "%s 字段键集非 ROW_KEYS 闭集" % key}) + var head := _validate_row_head(row, seen) + errs.append_array(head["errs"]) + if not head["ok"]: continue - var layers: int = int(row.get("layers", 0)) - if layers < 6: - errs.append({"category": "domain", "message": "%s 层深 %d 低于下限 6(层深 L1≥6 已锁)" % [key, layers]}) - layers_by_level[L] = layers - var bd: float = float(row.get("branch_density", -1.0)) - if bd < 0.0 or bd > 1.0: - errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]}) - var ratio_sum := 0.0 - for rk in RATIO_KEYS: - var v: float = float(row.get(rk, -1.0)) - if v < 0.0 or v > 1.0: - errs.append({"category": "domain", "message": "%s %s=%f 越域 [0,1]" % [key, rk, v]}) - ratio_sum += v - if not is_equal_approx(ratio_sum, 1.0): - errs.append({"category": "domain", "message": "%s 配比四类合计 %f ≠ 1(已锁)" % [key, ratio_sum]}) - var entry_count: int = int(row.get("entry_count", 0)) - if entry_count < 1: - errs.append({"category": "domain", "message": "%s 入口个数 %d 越域(≥1)" % [key, entry_count]}) - var exit_count: int = int(row.get("exit_count", 0)) - if exit_count < 1 or exit_count > 3: - errs.append({"category": "domain", "message": "%s 出口个数 %d 越域 [1,3](已锁范围)" % [key, exit_count]}) - if L == 1 and exit_count != 1: - errs.append({"category": "domain", "message": "%s 出口个数 %d ≠ 1(L1 出口恒 1 已锁)" % [key, exit_count]}) - var bf: int = int(row.get("battle_floor", -1)) - if bf < 0: - errs.append({"category": "domain", "message": "%s 战斗保底 %d 越域(≥0)" % [key, bf]}) - var fmin: int = int(row.get("fork_ways_min", 0)) - var fmax: int = int(row.get("fork_ways_max", 0)) - if fmin != 2 or fmax != 3: - errs.append({"category": "domain", "message": "%s 岔口去路数 (%d,%d) ≠ (2,3)(已锁)" % [key, fmin, fmax]}) + errs.append_array(_validate_row_shape(row, head["L"], head["key"], layers_by_level)) + errs.append_array(_validate_row_counts(row, head["L"], head["key"])) + # 层深递进尾段(L1 Array: return errs +## 行头域:level_index 查重/关卡序号越域/字段键集闭集(ok=跳过行剩余判据) +func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] + var L: int = int(row.get("level_index", 0)) + var key := "level %d" % L + if seen.has(L): + errs.append({"category": "dup_key", "message": "LevelGenParams 键重复: %d" % L}) + seen[L] = true + if L < 1: + errs.append({"category": "domain", "message": "%s 关卡序号越域(≥1)" % key}) + var ks := (row as Dictionary).keys() + ks.sort() + var expect := ROW_KEYS.duplicate() + expect.sort() + if ks != expect: + errs.append({"category": "structural", "message": "%s 字段键集非 ROW_KEYS 闭集" % key}) + return {"errs": errs, "L": L, "key": key, "ok": false} + return {"errs": errs, "L": L, "key": key, "ok": true} + + +## 行形态域:层深下限(layers_by_level 引用登记——层深递进尾段取数)/分支密度/配比四类合计 1 +func _validate_row_shape(row: Dictionary, L: int, key: String, layers_by_level: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var layers: int = int(row.get("layers", 0)) + if layers < 6: + errs.append({"category": "domain", "message": "%s 层深 %d 低于下限 6(层深 L1≥6 已锁)" % [key, layers]}) + layers_by_level[L] = layers + var bd: float = float(row.get("branch_density", -1.0)) + if bd < 0.0 or bd > 1.0: + errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]}) + var ratio_sum := 0.0 + for rk in RATIO_KEYS: + var v: float = float(row.get(rk, -1.0)) + if v < 0.0 or v > 1.0: + errs.append({"category": "domain", "message": "%s %s=%f 越域 [0,1]" % [key, rk, v]}) + ratio_sum += v + if not is_equal_approx(ratio_sum, 1.0): + errs.append({"category": "domain", "message": "%s 配比四类合计 %f ≠ 1(已锁)" % [key, ratio_sum]}) + return errs + + +## 行计数域:入口/出口(L1 恒 1)/战斗保底/岔口去路数锁值 +func _validate_row_counts(row: Dictionary, L: int, key: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var entry_count: int = int(row.get("entry_count", 0)) + if entry_count < 1: + errs.append({"category": "domain", "message": "%s 入口个数 %d 越域(≥1)" % [key, entry_count]}) + var exit_count: int = int(row.get("exit_count", 0)) + if exit_count < 1 or exit_count > 3: + errs.append({"category": "domain", "message": "%s 出口个数 %d 越域 [1,3](已锁范围)" % [key, exit_count]}) + if L == 1 and exit_count != 1: + errs.append({"category": "domain", "message": "%s 出口个数 %d ≠ 1(L1 出口恒 1 已锁)" % [key, exit_count]}) + var bf: int = int(row.get("battle_floor", -1)) + if bf < 0: + errs.append({"category": "domain", "message": "%s 战斗保底 %d 越域(≥0)" % [key, bf]}) + var fmin: int = int(row.get("fork_ways_min", 0)) + var fmax: int = int(row.get("fork_ways_max", 0)) + if fmin != 2 or fmax != 3: + errs.append({"category": "domain", "message": "%s 岔口去路数 (%d,%d) ≠ (2,3)(已锁)" % [key, fmin, fmax]}) + return errs + + # 参数档单点读取(键 = level_index,缺行返回空字典——沿 level_row() 体例) func row_by_level_index(level_index: int) -> Dictionary: for row in rows: diff --git a/game/data/schemas/meta_table.gd b/game/data/schemas/meta_table.gd index 7f68bd4c..779a0e8f 100644 --- a/game/data/schemas/meta_table.gd +++ b/game/data/schemas/meta_table.gd @@ -18,7 +18,15 @@ func validate() -> Array: for family in ["boss_keystone", "conversion", "attune_refresh_upgrade"]: if get(family).is_empty(): errs.append({"category": "empty_table", "message": "MetaTable 行族为空: %s" % family}) - # boss_keystone:level_index 唯一 + 连续从 1 + 两档域 + first ≥ repeat(首杀全量、重复小额恒定) + errs.append_array(_validate_boss_keystone()) + errs.append_array(_validate_conversion()) + errs.append_array(_validate_attune_refresh()) + return errs + + +## boss_keystone 行族域:level_index 唯一 + 连续从 1 + 两档域 + first ≥ repeat(首杀全量、重复小额恒定) +func _validate_boss_keystone() -> Array[Dictionary]: + var errs: Array[Dictionary] = [] var seen_li := {} for row in boss_keystone: var li: int = int(row.get("level_index", 0)) @@ -36,9 +44,13 @@ func validate() -> Array: if li_at != i + 1: errs.append({"category": "structural", "message": "boss_keystone level_index 不连续(须从 1 连续递增): 位 %d = %d" % [i + 1, li_at]}) break - # 无 workshop_items 行族(解锁混合制——条件谓词挂 treasure_items 行内; - # 本表不校验秘宝解锁,单点=TreasureItemTable.validate) - # conversion:attr 词表与 forge 一致 + 唯一 + 覆盖恰三 + 平移量域 [0,100] + return errs + + +## conversion 行族域(属性换算):attr 词表与 forge 一致 + 唯一 + 覆盖恰三 + 平移量域 [0,100] +## (无 workshop_items 行族——解锁混合制:条件谓词挂 treasure_items 行内;本表不校验秘宝解锁,单点=TreasureItemTable.validate) +func _validate_conversion() -> Array[Dictionary]: + var errs: Array[Dictionary] = [] var seen_conv := {} for row in conversion: var cattr: String = row.get("attr", "") @@ -54,7 +66,12 @@ func validate() -> Array: for conv_key in FORGE_ATTRS: if not seen_conv.has(conv_key): errs.append({"category": "coverage", "message": "conversion 缺属性行: %s(三行与 forge 词表一致)" % conv_key}) - # attune_refresh_upgrade:level 唯一 + 连续从 1 + charges/cost 域 + return errs + + +## attune_refresh_upgrade 行族域(Y 刷新升级):level 唯一 + 连续从 1 + charges/cost 域 +func _validate_attune_refresh() -> Array[Dictionary]: + var errs: Array[Dictionary] = [] var seen_al := {} for row in attune_refresh_upgrade: var al: int = int(row.get("level", -1)) diff --git a/game/data/schemas/skill_table.gd b/game/data/schemas/skill_table.gd index 335d9664..aa12eda8 100644 --- a/game/data/schemas/skill_table.gd +++ b/game/data/schemas/skill_table.gd @@ -31,73 +31,108 @@ func validate() -> Array: var seen := {} var has_basic := false for row in rows: - var sid: String = row.get("skill_id", "") - if sid == "": - errs.append({"category": "domain", "message": "skill_id 缺失/空"}) + var head := _validate_row_head(row, seen) + errs.append_array(head["errs"]) + if not head["ok"]: continue - if seen.has(sid): - errs.append({"category": "dup_key", "message": "skill_id 重复: %s" % sid}) - seen[sid] = true - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % sid}) - var type: String = row.get("type", "") - if not SKILL_TYPES.has(type): - errs.append({"category": "enum", "message": "%s type 未知枚举: %s(域 %s)" % [sid, type, str(SKILL_TYPES)]}) - continue - _validate_trigger(sid, row, errs) # trigger(触发条件,可省略——四键白名单/词表出域/类型分派/普攻禁配;加载期报错止动) - if type == "basic" and row.has("trigger"): - errs.append({"category": "cross_field", "message": "%s 普攻行禁配 trigger(普攻恒可摇的结构前提)" % sid}) - # 冷却域(全类型):施放后连续 N 拍不可摇中 - var cd: int = int(row.get("cooldown_beats", -1)) - if cd < 0 or cd > 99: - errs.append({"category": "domain", "message": "%s cooldown_beats 越域 [0,99]: %d" % [sid, cd]}) - # 普通攻击两条域校验锁死特殊性:倍率恒 1、冷却恒 0——想配的就是伤害技(proposal §2.2) + var sid: String = head["sid"] + var type: String = head["type"] + _validate_common_fields(sid, row, type, errs) if type == "basic": has_basic = true - _validate_basic_row(sid, row, cd, errs) - # 伤害技:存倍率不存固定值——伤害 = 施放者攻击基线 × 技能倍率(三乘法链第一乘); - # 可选 rider_mult = 必中附加分量(后置加法、不吃骰不出暴击——proposal-status-vocabulary §2) + _validate_basic_row(sid, row, int(row.get("cooldown_beats", -1)), errs) if type == "damage": - if not row.has("damage_mult"): - errs.append({"category": "cross_field", "message": "%s 伤害技缺 damage_mult(存倍率不存固定值)" % sid}) - else: - var m: float = float(row.damage_mult) - if m < MULT_DOMAIN_MIN or m > MULT_DOMAIN_MAX: - errs.append({"category": "domain", "message": "%s damage_mult 越域 [%.1f, %.1f]: %s" % [sid, MULT_DOMAIN_MIN, MULT_DOMAIN_MAX, str(m)]}) - if row.has("buff_ref") or row.has("buff_duration"): - errs.append({"category": "cross_field", "message": "%s 伤害技不带增益字段" % sid}) - if row.has("rider_mult"): - var rm: float = float(row.rider_mult) - if rm <= 0.0 or rm > RIDER_MULT_MAX: - errs.append({"category": "domain", "message": "%s rider_mult 越域 (0, %.1f]: %s" % [sid, RIDER_MULT_MAX, str(rm)]}) - # 增益技:0 伤害动作(无倍率),效果引用 + 持续回合;悬空引用由 ConfigDB 跨表校验兜底 + _validate_damage_row(sid, row, errs) if type == "buff": - if row.has("damage_mult"): - errs.append({"category": "cross_field", "message": "%s 增益技为 0 伤害动作,不带 damage_mult" % sid}) - if row.has("rider_mult"): - errs.append({"category": "cross_field", "message": "%s 增益技不带必中附加分量(rider_mult 仅伤害技)" % sid}) - if String(row.get("buff_ref", "")) == "": - errs.append({"category": "cross_field", "message": "%s 增益技缺 buff_ref(效果引用)" % sid}) - var bd: int = int(row.get("buff_duration", 0)) - if bd < 1 or bd > 99: - errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, bd]}) - # 妨害技:0 伤害施放动作,对主角施加负面状态—— - # 字段复用 buff_ref/buff_duration(落点由状态行 target_side 路由,ConfigDB side 守卫兜底);池纪律妨害 ≤1 在跨表通道 + _validate_buff_row(sid, row, errs) if type == "debuff": - if row.has("damage_mult"): - errs.append({"category": "cross_field", "message": "%s 妨害技为 0 伤害动作,不带 damage_mult" % sid}) - if row.has("rider_mult"): - errs.append({"category": "cross_field", "message": "%s 妨害技不带必中附加分量(rider_mult 仅伤害技)" % sid}) - if String(row.get("buff_ref", "")) == "": - errs.append({"category": "cross_field", "message": "%s 妨害技缺 buff_ref(效果引用)" % sid}) - var dbd: int = int(row.get("buff_duration", 0)) - if dbd < 1 or dbd > 99: - errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, dbd]}) + _validate_debuff_row(sid, row, errs) + _validate_table_invariants(has_basic, errs) + return errs + + +## 行头域:skill_id 非空与查重/display_name/type 枚举(ok=跳过行剩余判据) +func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] + var sid: String = row.get("skill_id", "") + if sid == "": + errs.append({"category": "domain", "message": "skill_id 缺失/空"}) + return {"errs": errs, "sid": sid, "type": "", "ok": false} + if seen.has(sid): + errs.append({"category": "dup_key", "message": "skill_id 重复: %s" % sid}) + seen[sid] = true + return {"errs": errs, "sid": sid, "type": "", "ok": false} + seen[sid] = true + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % sid}) + var type: String = row.get("type", "") + if not SKILL_TYPES.has(type): + errs.append({"category": "enum", "message": "%s type 未知枚举: %s(域 %s)" % [sid, type, str(SKILL_TYPES)]}) + return {"errs": errs, "sid": sid, "type": type, "ok": false} + return {"errs": errs, "sid": sid, "type": type, "ok": true} + + +## 公共域(全类型):trigger 触发条件校验/普攻行禁配 trigger/冷却域 +func _validate_common_fields(sid: String, row: Dictionary, type: String, errs: Array[Dictionary]) -> void: + _validate_trigger(sid, row, errs) # trigger(触发条件,可省略——四键白名单/词表出域/类型分派/普攻禁配;加载期报错止动) + if type == "basic" and row.has("trigger"): + errs.append({"category": "cross_field", "message": "%s 普攻行禁配 trigger(普攻恒可摇的结构前提)" % sid}) + # 冷却域(全类型):施放后连续 N 拍不可摇中 + var cd: int = int(row.get("cooldown_beats", -1)) + if cd < 0 or cd > 99: + errs.append({"category": "domain", "message": "%s cooldown_beats 越域 [0,99]: %d" % [sid, cd]}) + + +## 伤害技域:存倍率不存固定值——伤害 = 施放者攻击基线 × 技能倍率(三乘法链第一乘); +## 可选 rider_mult = 必中附加分量(后置加法、不吃骰不出暴击——proposal-status-vocabulary §2) +func _validate_damage_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void: + if not row.has("damage_mult"): + errs.append({"category": "cross_field", "message": "%s 伤害技缺 damage_mult(存倍率不存固定值)" % sid}) + else: + var m: float = float(row.damage_mult) + if m < MULT_DOMAIN_MIN or m > MULT_DOMAIN_MAX: + errs.append({"category": "domain", "message": "%s damage_mult 越域 [%.1f, %.1f]: %s" % [sid, MULT_DOMAIN_MIN, MULT_DOMAIN_MAX, str(m)]}) + if row.has("buff_ref") or row.has("buff_duration"): + errs.append({"category": "cross_field", "message": "%s 伤害技不带增益字段" % sid}) + if row.has("rider_mult"): + var rm: float = float(row.rider_mult) + if rm <= 0.0 or rm > RIDER_MULT_MAX: + errs.append({"category": "domain", "message": "%s rider_mult 越域 (0, %.1f]: %s" % [sid, RIDER_MULT_MAX, str(rm)]}) + + +## 增益技域:0 伤害动作(无倍率),效果引用 + 持续回合;悬空引用由 ConfigDB 跨表校验兜底 +func _validate_buff_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void: + if row.has("damage_mult"): + errs.append({"category": "cross_field", "message": "%s 增益技为 0 伤害动作,不带 damage_mult" % sid}) + if row.has("rider_mult"): + errs.append({"category": "cross_field", "message": "%s 增益技不带必中附加分量(rider_mult 仅伤害技)" % sid}) + if String(row.get("buff_ref", "")) == "": + errs.append({"category": "cross_field", "message": "%s 增益技缺 buff_ref(效果引用)" % sid}) + var bd: int = int(row.get("buff_duration", 0)) + if bd < 1 or bd > 99: + errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, bd]}) + + +## 妨害技域:0 伤害施放动作,对主角施加负面状态—— +## 字段复用 buff_ref/buff_duration(落点由状态行 target_side 路由,ConfigDB side 守卫兜底);池纪律妨害 ≤1 在跨表通道 +func _validate_debuff_row(sid: String, row: Dictionary, errs: Array[Dictionary]) -> void: + if row.has("damage_mult"): + errs.append({"category": "cross_field", "message": "%s 妨害技为 0 伤害动作,不带 damage_mult" % sid}) + if row.has("rider_mult"): + errs.append({"category": "cross_field", "message": "%s 妨害技不带必中附加分量(rider_mult 仅伤害技)" % sid}) + if String(row.get("buff_ref", "")) == "": + errs.append({"category": "cross_field", "message": "%s 妨害技缺 buff_ref(效果引用)" % sid}) + var dbd: int = int(row.get("buff_duration", 0)) + if dbd < 1 or dbd > 99: + errs.append({"category": "domain", "message": "%s buff_duration 越域 [1,99]: %d" % [sid, dbd]}) + + +## 表不变量域:普攻行存在性(池构成纪律的结构前提)与保底旋钮值域 +func _validate_table_invariants(has_basic: bool, errs: Array[Dictionary]) -> void: if not has_basic: errs.append({"category": "structural", "message": "SkillTable 无普攻行(basic)——池构成纪律的结构前提"}) if pity_threshold < 1 or pity_threshold > 99: errs.append({"category": "domain", "message": "pity_threshold 越域 [1,99]: %d" % pity_threshold}) - return errs # trigger 域校验(敌人系统 v1.2 触发条件:四键白名单/词表出域/类型分派——枚举事实算子限域 eq/neq diff --git a/game/data/schemas/status_effect_table.gd b/game/data/schemas/status_effect_table.gd index 6811a235..4e1568cb 100644 --- a/game/data/schemas/status_effect_table.gd +++ b/game/data/schemas/status_effect_table.gd @@ -18,30 +18,12 @@ func validate() -> Array: return errs var seen := {} for row in rows: - var eid: String = row.get("effect_id", "") - if eid == "": - errs.append({"category": "domain", "message": "effect_id 缺失/空"}) + var head := _validate_row_head(row, seen) + errs.append_array(head["errs"]) + if not head["ok"]: continue - if seen.has(eid): - errs.append({"category": "dup_key", "message": "effect_id 重复: %s" % eid}) - seen[eid] = true - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % eid}) - if not TARGET_SIDES.has(row.get("target_side", "")): - errs.append({"category": "enum", "message": "%s target_side 出域" % eid}) - var unit: String = row.get("unit", "") - if not UNITS.has(unit): - errs.append({"category": "enum", "message": "%s unit 出域" % eid}) - if not STACK_POLICIES.has(row.get("stack_policy", "")): - errs.append({"category": "enum", "message": "%s stack_policy 出域" % eid}) - var m: int = int(row.get("magnitude", 0)) - # 跨字段:percent ∈ [-100,100] 且 ≠0 / flat ≥ 1 / ac ∈ [-20,20] 且 ≠0(AC 阈值轴——幅度域独立,防呆上限 20) - if unit == "percent" and (m == 0 or m < -100 or m > 100): - errs.append({"category": "cross_field", "message": "%s percent magnitude 域错: %d" % [eid, m]}) - if unit == "flat" and m < 1: - errs.append({"category": "cross_field", "message": "%s flat magnitude 须 ≥1: %d" % [eid, m]}) - if unit == "ac" and (m == 0 or m < -20 or m > 20): - errs.append({"category": "cross_field", "message": "%s ac magnitude 域错([-20,20] 且 ≠0): %d" % [eid, m]}) + var eid: String = head["eid"] + errs.append_array(_validate_magnitude_domain(row, eid, head["unit"])) var dur: int = int(row.get("duration_settlements", 0)) if dur < 1 or dur > 99: errs.append({"category": "domain", "message": "%s duration_settlements 越域 [1,99]: %d" % [eid, dur]}) @@ -51,6 +33,44 @@ func validate() -> Array: return errs +## 行头域:effect_id 非空与查重/display_name/target_side/unit/stack_policy 词表 +func _validate_row_head(row: Dictionary, seen: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] + var eid: String = row.get("effect_id", "") + if eid == "": + errs.append({"category": "domain", "message": "effect_id 缺失/空"}) + return {"errs": errs, "eid": eid, "unit": "", "ok": false} + if seen.has(eid): + errs.append({"category": "dup_key", "message": "effect_id 重复: %s" % eid}) + seen[eid] = true + return {"errs": errs, "eid": eid, "unit": "", "ok": false} + seen[eid] = true + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % eid}) + if not TARGET_SIDES.has(row.get("target_side", "")): + errs.append({"category": "enum", "message": "%s target_side 出域" % eid}) + var unit: String = row.get("unit", "") + if not UNITS.has(unit): + errs.append({"category": "enum", "message": "%s unit 出域" % eid}) + if not STACK_POLICIES.has(row.get("stack_policy", "")): + errs.append({"category": "enum", "message": "%s stack_policy 出域" % eid}) + return {"errs": errs, "eid": eid, "unit": unit, "ok": true} + + +## 幅度量纲域(三段量纲判据——percent/flat/ac 各自值域; +## ac = AC 阈值轴效果,非池百分比非定点值——幅度域独立,防呆上限 20) +func _validate_magnitude_domain(row: Dictionary, eid: String, unit: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var m: int = int(row.get("magnitude", 0)) + if unit == "percent" and (m == 0 or m < -100 or m > 100): + errs.append({"category": "cross_field", "message": "%s percent magnitude 域错: %d" % [eid, m]}) + if unit == "flat" and m < 1: + errs.append({"category": "cross_field", "message": "%s flat magnitude 须 ≥1: %d" % [eid, m]}) + if unit == "ac" and (m == 0 or m < -20 or m > 20): + errs.append({"category": "cross_field", "message": "%s ac magnitude 域错([-20,20] 且 ≠0): %d" % [eid, m]}) + return errs + + func row_by_id(effect_id: String) -> Dictionary: for row in rows: if row.get("effect_id", "") == effect_id: diff --git a/game/data/schemas/variation_table.gd b/game/data/schemas/variation_table.gd index 65c4c7b6..1b00199e 100644 --- a/game/data/schemas/variation_table.gd +++ b/game/data/schemas/variation_table.gd @@ -26,57 +26,82 @@ func validate() -> Array: var errs: Array[Dictionary] = [] var seen := {} for row in rows: - var vid: String = row.get("variant_id", "") - if vid == "": - errs.append({"category": "domain", "message": "variant_id 缺失/空"}) + var head := _validate_row(row, seen) + errs.append_array(head["errs"]) + if not head["ok"]: continue - if seen.has(vid): - errs.append({"category": "dup_key", "message": "variant_id 重复: %s" % vid}) + var vid: String = head["vid"] + errs.append_array(_validate_effect_interlocks(row, vid)) + errs.append_array(_validate_global_interlocks(row, vid)) + return errs + + +## 行域:variant_id 非空与查重/display_name/unlock_level/四词表(scope/effect/trigger/placement) +func _validate_row(row: Dictionary, seen: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] + var vid: String = row.get("variant_id", "") + if vid == "": + errs.append({"category": "domain", "message": "variant_id 缺失/空"}) + return {"errs": errs, "vid": vid, "ok": false} + if seen.has(vid): + errs.append({"category": "dup_key", "message": "variant_id 重复: %s" % vid}) seen[vid] = true - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % vid}) - var ul: int = int(row.get("unlock_level", 0)) - if ul < 1 or ul > 999: - errs.append({"category": "domain", "message": "%s unlock_level 越域 [1,999]: %d" % [vid, ul]}) - if not SCOPES.has(row.get("scope", "")): - errs.append({"category": "enum", "message": "%s scope 出域: %s" % [vid, row.get("scope")]}) - if not EFFECTS.has(row.get("effect", "")): - errs.append({"category": "enum", "message": "%s effect 出域: %s" % [vid, row.get("effect")]}) - if not TRIGGERS.has(row.get("trigger", "")): - errs.append({"category": "enum", "message": "%s trigger 出域: %s" % [vid, row.get("trigger")]}) - if not PLACEMENTS.has(row.get("placement", "")): - errs.append({"category": "enum", "message": "%s placement 出域: %s" % [vid, row.get("placement")]}) - # 跨字段互锁(加载期致命——枚举语义一致性立闸): - # 伤害型 = 挖开即伤 ⇒ 触发 on_open + 只落未开格(closed_only)+ 伤害量为加法核小整数 - if row.get("effect", "") == "flat_damage": - if row.get("trigger", "") != "on_open": - errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ trigger 必为 on_open" % vid}) - if row.get("placement", "") != "closed_only": - errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ placement 必为 closed_only(只出现在未打开的格子)" % vid}) - var ev: int = int(row.get("effect_value", 0)) - if ev < 1 or ev > 9: - errs.append({"category": "domain", "message": "%s effect_value 越域 [1,9](加法核小整数): %d" % [vid, ev]}) - # 信息型 = 被打开且显示数字时起作用 ⇒ 触发 on_number_shown - if row.get("effect", "") == "hide_number" and row.get("trigger", "") != "on_number_shown": - errs.append({"category": "cross_field", "message": "%s hide_number ⇒ trigger 必为 on_number_shown" % vid}) - # 信息遮蔽型只允许落安全格(遮蔽对象是数字;危险格无数字可遮)——placement=any 时由读取方 - # 呈现面按 kind=safe 自然短路,此处不设布置约束 - # 全局作用域型互锁(resource_face ⟺ scope=global 三条 + 新值域; - # 判定键=effect/scope 词值,既有六 region 行(flat_damage/hide_number)不入本组——零误伤) - if row.get("effect", "") == "resource_face": - if row.get("scope", "") != "global": - errs.append({"category": "cross_field", "message": "%s resource_face ⇒ scope 必为 global(整图作用域)" % vid}) - # resource_face 行 effect_value = 生存族权重下调百分比(0=中性合法态——off 锚用) - var gev: int = int(row.get("effect_value", 0)) - if gev < 0 or gev > 50: - errs.append({"category": "domain", "message": "%s effect_value 越域 [0,50](资源面偏斜百分比): %d" % [vid, gev]}) - if row.get("scope", "") == "global": - if row.get("effect", "") != "resource_face": - errs.append({"category": "cross_field", "message": "%s scope=global ⇒ effect 必为 resource_face" % vid}) - if row.get("placement", "") != "any": - errs.append({"category": "cross_field", "message": "%s scope=global ⇒ placement 必为 any(整图无格子布置域)" % vid}) - if row.get("trigger", "") != "always": - errs.append({"category": "cross_field", "message": "%s scope=global ⇒ trigger 必为 always(整图恒在,无触发时机)" % vid}) + return {"errs": errs, "vid": vid, "ok": false} + seen[vid] = true + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % vid}) + var ul: int = int(row.get("unlock_level", 0)) + if ul < 1 or ul > 999: + errs.append({"category": "domain", "message": "%s unlock_level 越域 [1,999]: %d" % [vid, ul]}) + if not SCOPES.has(row.get("scope", "")): + errs.append({"category": "enum", "message": "%s scope 出域: %s" % [vid, row.get("scope")]}) + if not EFFECTS.has(row.get("effect", "")): + errs.append({"category": "enum", "message": "%s effect 出域: %s" % [vid, row.get("effect")]}) + if not TRIGGERS.has(row.get("trigger", "")): + errs.append({"category": "enum", "message": "%s trigger 出域: %s" % [vid, row.get("trigger")]}) + if not PLACEMENTS.has(row.get("placement", "")): + errs.append({"category": "enum", "message": "%s placement 出域: %s" % [vid, row.get("placement")]}) + return {"errs": errs, "vid": vid, "ok": true} + + +## 效果互锁域(加载期致命——枚举语义一致性立闸): +## 伤害型 = 挖开即伤 ⇒ 触发 on_open + 只落未开格(closed_only)+ 伤害量为加法核小整数; +## 信息型 = 被打开且显示数字时起作用 ⇒ 触发 on_number_shown +## (信息遮蔽型只允许落安全格——遮蔽对象是数字,危险格无数字可遮;placement=any 时由读取方 +## 呈现面按 kind=safe 自然短路,此处不设布置约束) +func _validate_effect_interlocks(row: Dictionary, vid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + if row.get("effect", "") == "flat_damage": + if row.get("trigger", "") != "on_open": + errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ trigger 必为 on_open" % vid}) + if row.get("placement", "") != "closed_only": + errs.append({"category": "cross_field", "message": "%s flat_damage ⇒ placement 必为 closed_only(只出现在未打开的格子)" % vid}) + var ev: int = int(row.get("effect_value", 0)) + if ev < 1 or ev > 9: + errs.append({"category": "domain", "message": "%s effect_value 越域 [1,9](加法核小整数): %d" % [vid, ev]}) + if row.get("effect", "") == "hide_number" and row.get("trigger", "") != "on_number_shown": + errs.append({"category": "cross_field", "message": "%s hide_number ⇒ trigger 必为 on_number_shown" % vid}) + return errs + + +## 全局作用域互锁域(resource_face ⟺ scope=global;判定键=effect/scope 词值, +## 既有六 region 行(flat_damage/hide_number)不入本组——零误伤); +## resource_face 行 effect_value = 生存族权重下调百分比(0=中性合法态——off 锚用) +func _validate_global_interlocks(row: Dictionary, vid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + if row.get("effect", "") == "resource_face": + if row.get("scope", "") != "global": + errs.append({"category": "cross_field", "message": "%s resource_face ⇒ scope 必为 global(整图作用域)" % vid}) + var gev: int = int(row.get("effect_value", 0)) + if gev < 0 or gev > 50: + errs.append({"category": "domain", "message": "%s effect_value 越域 [0,50](资源面偏斜百分比): %d" % [vid, gev]}) + if row.get("scope", "") == "global": + if row.get("effect", "") != "resource_face": + errs.append({"category": "cross_field", "message": "%s scope=global ⇒ effect 必为 resource_face" % vid}) + if row.get("placement", "") != "any": + errs.append({"category": "cross_field", "message": "%s scope=global ⇒ placement 必为 any(整图无格子布置域)" % vid}) + if row.get("trigger", "") != "always": + errs.append({"category": "cross_field", "message": "%s scope=global ⇒ trigger 必为 always(整图恒在,无触发时机)" % vid}) return errs