diff --git a/game/data/schemas/enemy_table.gd b/game/data/schemas/enemy_table.gd index 633fa734..388a3cf3 100644 --- a/game/data/schemas/enemy_table.gd +++ b/game/data/schemas/enemy_table.gd @@ -44,98 +44,147 @@ func validate() -> Array: var anchor_ok := {} var boss_ok := {} for row in rows: - var L: int = int(row.get("level_index", 0)) - var rk: String = String(row.get("row_kind", "")) - var band: int = int(row.get("depth_band", -1)) - var eid: String = row.get("enemy_id", "") - var key := "%d_%s_%d_%s" % [L, rk, band, eid] - if seen.has(key): - errs.append({"category": "dup_key", "message": "EnemyTable 键重复: %s" % key}) - seen[key] = true - if L < 1 or band < 0: - errs.append({"category": "domain", "message": "进度键越域 (%d,band %d)" % [L, band]}) - if not ROW_KINDS.has(rk): - errs.append({"category": "domain", "message": "%s row_kind 未知枚举: \"%s\"(域 {battle, boss})" % [eid, rk]}) - if eid == "": - errs.append({"category": "domain", "message": "enemy_id 空"}) - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % eid}) - var uc: int = int(row.get("unit_count", 0)) - if uc < UNIT_COUNT_MIN or uc > UNIT_COUNT_MAX: - errs.append({"category": "domain", "message": "%s unit_count 越域 [%d,%d]" % [eid, UNIT_COUNT_MIN, UNIT_COUNT_MAX]}) - # unit_count_max(可省略——遭遇随机化批,难度曲线与Boss GDD Core Rule 8):区间 [unit_count, max],缺省 = 定值 - if row.has("unit_count_max"): - var ucm: int = int(row.get("unit_count_max", uc)) - if ucm < uc or ucm > UNIT_COUNT_MAX: - errs.append({"category": "cross_field", "message": "%s unit_count_max(%d) 越域 [unit_count(%d),%d]" % [eid, ucm, uc, UNIT_COUNT_MAX]}) - var hp: int = int(row.get("hp", 0)) - if hp < 1 or hp > 999: - errs.append({"category": "domain", "message": "%s hp 越域 [1,999]" % eid}) - var atk: int = int(row.get("atk", -1)) - if atk < 0 or atk > 999: - errs.append({"category": "domain", "message": "%s atk 越域 [0,999]" % eid}) - # atk_min/atk_max(可省略配对):遭遇骰同流 spawn 期整战持久 roll; - # 中点锚 = 行动经济 条款①同款期望当量纪律(调区间必同步调 atk) - var has_amin: bool = row.has("atk_min") - var has_amax: bool = row.has("atk_max") - if has_amin != has_amax: - errs.append({"category": "cross_field", "message": "%s atk_min/atk_max 必须成对出现" % eid}) - elif has_amin: - var amin: int = int(row.get("atk_min", 0)) - var amax: int = int(row.get("atk_max", 0)) - if amin < 0 or amax > 999 or amin > amax: - errs.append({"category": "domain", "message": "%s atk 区间越域 [0,999] 且 min≤max: %d..%d" % [eid, amin, amax]}) - if atk != int((amin + amax) / 2.0): - errs.append({"category": "cross_field", "message": "%s atk(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [eid, atk, (amin + amax) / 2]}) - # 池模板(单表结构校验;跨表构成纪律在 ConfigDB): - # 非空池 + 条目 {skill_id 非空, weight > 0} + 池内 skill_id 不重复。 - # 普攻冷却恒 0(SkillTable schema 锁死)→ 可摇集合恒非空,"空池不可能"由此结构保证。 - var pool: Array[Variant] = row.get("pool", []) - if pool.is_empty(): - errs.append({"category": "domain", "message": "%s pool 空池(禁——技能池行必带池模板)" % eid}) - var pool_ids := {} - for entry_v in pool: - if not (entry_v is Dictionary): - errs.append({"category": "domain", "message": "%s pool 条目非字典: %s" % [eid, str(entry_v)]}) - continue - var entry: Dictionary[Variant, Variant] = entry_v - var sid: String = String(entry.get("skill_id", "")) - if sid == "": - errs.append({"category": "domain", "message": "%s pool 条目 skill_id 空" % eid}) - elif pool_ids.has(sid): - errs.append({"category": "dup_key", "message": "%s pool 内 skill_id 重复: %s" % [eid, sid]}) - pool_ids[sid] = true - var w: int = int(entry.get("weight", 0)) - if w <= 0 or w > 9999: - errs.append({"category": "domain", "message": "%s pool 权重越域 (0,9999]: %d" % [eid, w]}) - # armor_type(可省略——缺省 light 向后兼容) - var at: String = String(row.get("armor_type", "light")) - if not ARMOR_TYPES.has(at): - errs.append({"category": "enum", "message": "%s armor_type 未知枚举: %s(域 {light, ward, heavy})" % [eid, at]}) - # attack_type(可省略,缺省 normal 向后兼容;写法沿用 armor_type 同款) - var att: String = String(row.get("attack_type", "normal")) - if not ATTACK_TYPES.has(att): - errs.append({"category": "enum", "message": "%s attack_type 未知枚举: %s(域 {normal, pierce, magic})" % [eid, att]}) - # armor_class(可省略——缺省 0 向后兼容;防守方掷点判定阈值基数) - var ac: int = int(row.get("armor_class", 0)) - if ac < 0 or ac > 999: - errs.append({"category": "domain", "message": "%s armor_class 越域 [0,999]: %d" % [eid, ac]}) + errs.append_array(_validate_row_base(row, seen)) + var eid2 := String(row.get("enemy_id", "")) + errs.append_array(_validate_row_numbers(row, eid2)) + errs.append_array(_validate_row_atk_range(row, eid2)) + errs.append_array(_validate_row_pool(row, eid2)) + errs.append_array(_validate_row_types(row, eid2)) # 行集构成纪律②(行序即契约,敌人系统 v0.15):同带键多行必须 # 文件内相邻。迭代中记录上一行行集键;当前行集键曾出现过但 ≠ 上一行行集键 = 同带键行被隔断,报错。 # 同带键多敌种混编合法;同带键同敌种第二行仍由 # dup_key 拦截(键含 enemy_id)。错误类别沿用 rowset_rule(定位面不变)。 - var rowset_key := "%d_%s_%d" % [L, rk, band] + var rowset_key := "%d_%s_%d" % [int(row.get("level_index", 0)), String(row.get("row_kind", "")), int(row.get("depth_band", -1))] if rowset_key != last_rowset_key: - if closed_rowset.has(rowset_key): - errs.append({"category": "rowset_rule", "message": "(%d,%s,%d) 行集行序违例:同带键多行必须文件内相邻(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序)" % [L, rk, band]}) + errs.append_array(_validate_rowset_closed(rowset_key, closed_rowset, row)) closed_rowset[rowset_key] = true last_rowset_key = rowset_key - if rk == "battle" and band == 0: - anchor_ok[L] = true - elif rk == "boss": - boss_ok[L] = true - if not anchor_ok.has(L): - anchor_ok[L] = false + var L2: int = int(row.get("level_index", 0)) + var rk2: String = String(row.get("row_kind", "")) + if rk2 == "battle" and int(row.get("depth_band", -1)) == 0: + anchor_ok[L2] = true + elif rk2 == "boss": + boss_ok[L2] = true + if not anchor_ok.has(L2): + anchor_ok[L2] = false + errs.append_array(_validate_anchor_rows(anchor_ok, boss_ok)) + return errs + + +## 行头基面域:键提取/查重与登记/进度键越域/row_kind 枚举/enemy_id 非空/display_name +func _validate_row_base(row: Dictionary, seen: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var L: int = int(row.get("level_index", 0)) + var rk: String = String(row.get("row_kind", "")) + var band: int = int(row.get("depth_band", -1)) + var eid: String = row.get("enemy_id", "") + var key := "%d_%s_%d_%s" % [L, rk, band, eid] + if seen.has(key): + errs.append({"category": "dup_key", "message": "EnemyTable 键重复: %s" % key}) + seen[key] = true + if L < 1 or band < 0: + errs.append({"category": "domain", "message": "进度键越域 (%d,band %d)" % [L, band]}) + if not ROW_KINDS.has(rk): + errs.append({"category": "domain", "message": "%s row_kind 未知枚举: \"%s\"(域 {battle, boss})" % [eid, rk]}) + if eid == "": + errs.append({"category": "domain", "message": "enemy_id 空"}) + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % eid}) + return errs + + +## 数值域:unit_count(含可省略上限)/hp/atk 基值 +func _validate_row_numbers(row: Dictionary, eid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var uc: int = int(row.get("unit_count", 0)) + if uc < UNIT_COUNT_MIN or uc > UNIT_COUNT_MAX: + errs.append({"category": "domain", "message": "%s unit_count 越域 [%d,%d]" % [eid, UNIT_COUNT_MIN, UNIT_COUNT_MAX]}) + # unit_count_max(可省略——遭遇随机化批,难度曲线与Boss GDD Core Rule 8):区间 [unit_count, max],缺省 = 定值 + if row.has("unit_count_max"): + var ucm: int = int(row.get("unit_count_max", uc)) + if ucm < uc or ucm > UNIT_COUNT_MAX: + errs.append({"category": "cross_field", "message": "%s unit_count_max(%d) 越域 [unit_count(%d),%d]" % [eid, ucm, uc, UNIT_COUNT_MAX]}) + var hp: int = int(row.get("hp", 0)) + if hp < 1 or hp > 999: + errs.append({"category": "domain", "message": "%s hp 越域 [1,999]" % eid}) + var atk: int = int(row.get("atk", -1)) + if atk < 0 or atk > 999: + errs.append({"category": "domain", "message": "%s atk 越域 [0,999]" % eid}) + return errs + + +## atk 区间域(可省略配对):遭遇骰同流 spawn 期整战持久 roll; +## 中点锚 = 行动经济 条款①同款期望当量纪律(调区间必同步调 atk) +func _validate_row_atk_range(row: Dictionary, eid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var atk: int = int(row.get("atk", -1)) + var has_amin: bool = row.has("atk_min") + var has_amax: bool = row.has("atk_max") + if has_amin != has_amax: + errs.append({"category": "cross_field", "message": "%s atk_min/atk_max 必须成对出现" % eid}) + elif has_amin: + var amin: int = int(row.get("atk_min", 0)) + var amax: int = int(row.get("atk_max", 0)) + if amin < 0 or amax > 999 or amin > amax: + errs.append({"category": "domain", "message": "%s atk 区间越域 [0,999] 且 min≤max: %d..%d" % [eid, amin, amax]}) + if atk != int((amin + amax) / 2.0): + errs.append({"category": "cross_field", "message": "%s atk(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [eid, atk, (amin + amax) / 2]}) + return errs + + +## 池模板域(单表结构校验;跨表构成纪律在 ConfigDB): +## 非空池 + 条目 {skill_id 非空, weight > 0} + 池内 skill_id 不重复。 +## 普攻冷却恒 0(SkillTable schema 锁死)→ 可摇集合恒非空,"空池不可能"由此结构保证。 +func _validate_row_pool(row: Dictionary, eid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var pool: Array[Variant] = row.get("pool", []) + if pool.is_empty(): + errs.append({"category": "domain", "message": "%s pool 空池(禁——技能池行必带池模板)" % eid}) + var pool_ids := {} + for entry_v in pool: + if not (entry_v is Dictionary): + errs.append({"category": "domain", "message": "%s pool 条目非字典: %s" % [eid, str(entry_v)]}) + continue + var entry: Dictionary[Variant, Variant] = entry_v + var sid: String = String(entry.get("skill_id", "")) + if sid == "": + errs.append({"category": "domain", "message": "%s pool 条目 skill_id 空" % eid}) + elif pool_ids.has(sid): + errs.append({"category": "dup_key", "message": "%s pool 内 skill_id 重复: %s" % [eid, sid]}) + pool_ids[sid] = true + var w: int = int(entry.get("weight", 0)) + if w <= 0 or w > 9999: + errs.append({"category": "domain", "message": "%s pool 权重越域 (0,9999]: %d" % [eid, w]}) + return errs + + +## 攻防类型域(均可省略——armor_type 缺省 light/attack_type 缺省 normal/armor_class 缺省 0 向后兼容) +func _validate_row_types(row: Dictionary, eid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var at: String = String(row.get("armor_type", "light")) + if not ARMOR_TYPES.has(at): + errs.append({"category": "enum", "message": "%s armor_type 未知枚举: %s(域 {light, ward, heavy})" % [eid, at]}) + var att: String = String(row.get("attack_type", "normal")) + if not ATTACK_TYPES.has(att): + errs.append({"category": "enum", "message": "%s attack_type 未知枚举: %s(域 {normal, pierce, magic})" % [eid, att]}) + # armor_class(防守方掷点判定阈值基数) + var ac: int = int(row.get("armor_class", 0)) + if ac < 0 or ac > 999: + errs.append({"category": "domain", "message": "%s armor_class 越域 [0,999]: %d" % [eid, ac]}) + return errs + + +## 行序违例域:当前行集键曾出现且 ≠ 上一行行集键 = 同带键行被隔断(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序) +func _validate_rowset_closed(rowset_key: String, closed_rowset: Dictionary, row: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + if closed_rowset.has(rowset_key): + errs.append({"category": "rowset_rule", "message": "(%d,%s,%d) 行集行序违例:同带键多行必须文件内相邻(行序即契约——resolver 返回序/生成序/叙述取首行全依赖文件行序)" % [int(row.get("level_index", 0)), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]}) + return errs + + +## 锚行尾段:每 level 必有 (battle, band 0) 锚行与 Boss 行集 +func _validate_anchor_rows(anchor_ok: Dictionary, boss_ok: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] for L in anchor_ok.keys(): if not anchor_ok[L]: errs.append({"category": "anchor", "message": "EnemyTable level %d 缺 (battle, band 0) 锚行" % L}) diff --git a/game/data/schemas/forge_table.gd b/game/data/schemas/forge_table.gd index a9ffb6ec..4c34606c 100644 --- a/game/data/schemas/forge_table.gd +++ b/game/data/schemas/forge_table.gd @@ -45,66 +45,101 @@ func validate() -> Array: for row in routes: # 预扫:route_id→kind 全集(前置反查需前向引用——轻甲行在长枪行之后) kind_by_id[String(row.get("route_id", ""))] = String(row.get("kind", "")) for row in routes: - var rid := String(row.get("route_id", "")) - if not ROUTE_IDS.has(rid): - errs.append({"category": "enum", "message": "锻造路线 route_id 出域 %s: %s" % [ROUTE_IDS, rid]}) - if seen.has(rid): - errs.append({"category": "dup_key", "message": "锻造路线 route_id 重复: %s" % rid}) - seen[rid] = true - var kind := String(row.get("kind", "")) - if not ["weapon", "armor"].has(kind): - errs.append({"category": "enum", "message": "%s kind 出域 {weapon, armor}: %s" % [rid, kind]}) + var head_out := _validate_route_head(row, seen, type_seen) + errs.append_array(head_out["errs"]) + if head_out["skip"]: continue - # 类型轴:武器=attack_type 域、甲=armor_type 域;每型恰一路线(三武器三甲各一) - var axis := String(row.get("type_axis", "")) - var domain: Array[Variant] = ATTACK_TYPES if kind == "weapon" else ARMOR_TYPES - if not domain.has(axis): - errs.append({"category": "enum", "message": "%s type_axis 出域 %s: %s" % [rid, domain, axis]}) - var type_key := "%s|%s" % [kind, axis] - if type_seen.has(type_key): - errs.append({"category": "dup_key", "message": "%s 类型轴重复(每型恰一路线): %s %s" % [kind, axis, rid]}) - type_seen[type_key] = true - # 等级/增量/价格/基础 AC 值域 - var cap_v: int = int(row.get("cap", -1)) - if cap_v < 1 or cap_v > 9: - errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [rid, cap_v]}) - var gain: int = int(row.get("per_level_gain", -1)) - if gain < 0 or gain > 9: - errs.append({"category": "domain", "message": "%s per_level_gain 越域 [0,9]: %d" % [rid, gain]}) - var base_ac: int = int(row.get("base_ac", -1)) - if kind == "armor": - if base_ac < 0 or base_ac > 999: - errs.append({"category": "domain", "message": "%s base_ac 越域 [0,999]: %d" % [rid, base_ac]}) - elif base_ac != 0: - errs.append({"category": "cross_field", "message": "%s 武器行 base_ac 须为 0(甲型基础 AC 只属护甲路线): %d" % [rid, base_ac]}) - var base_price: int = int(row.get("base_price", -1)) - if base_price < 1 or base_price > 999: - errs.append({"category": "domain", "message": "%s base_price 越域 [1,999]: %d" % [rid, base_price]}) - var ratio: int = int(row.get("price_ratio_pct", -1)) - if ratio < 100 or ratio > 500: - errs.append({"category": "domain", "message": "%s price_ratio_pct 越域 [100,500]: %d" % [rid, ratio]}) - var unlock_price: int = int(row.get("unlock_price", -1)) - if unlock_price < 0 or unlock_price > 999: - errs.append({"category": "domain", "message": "%s unlock_price 越域 [0,999]: %d" % [rid, unlock_price]}) - # 解锁通道(并联双通道):初始持有行无前置无解锁价;待解锁行须有一次性解锁价+合法前置 - var initial: bool = bool(row.get("initial_owned", false)) - var prereq := String(row.get("prereq_route", "")) - if initial: - if prereq != "": - errs.append({"category": "cross_field", "message": "%s 初始持有行不得带前置: %s" % [rid, prereq]}) - if unlock_price != 0: - errs.append({"category": "cross_field", "message": "%s 初始持有行解锁价须为 0: %d" % [rid, unlock_price]}) - initial_ids[rid] = kind - else: - if unlock_price < 1: - errs.append({"category": "cross_field", "message": "%s 待解锁行须有一次性解锁价(并联双通道②): %d" % [rid, unlock_price]}) - if not ROUTE_IDS.has(prereq): - errs.append({"category": "enum", "message": "%s prereq_route 出域 %s: %s" % [rid, ROUTE_IDS, prereq]}) - elif prereq == rid: - errs.append({"category": "cross_field", "message": "%s prereq_route 不得自指" % rid}) - elif kind_by_id.has(prereq) and String(kind_by_id[prereq]) != kind: - errs.append({"category": "cross_field", "message": "%s 前置 %s 类型不符(武器←武器/甲←甲): %s vs %s" % [rid, prereq, kind, String(kind_by_id[prereq])]}) - # 初始持有恰两行且一武器一甲(长剑/轻甲) + var rid := String(row.get("route_id", "")) + errs.append_array(_validate_route_values(row, rid)) + errs.append_array(_validate_route_unlock(row, rid, kind_by_id, initial_ids)) + errs.append_array(_validate_initial_coverage(initial_ids)) + return errs + + +## 行头域:route_id 词表与查重/kind 出域(skip=跳过行剩余判据)/类型轴域与每型恰一路线 +func _validate_route_head(row: Dictionary, seen: Dictionary, type_seen: Dictionary) -> Dictionary: + var errs: Array[Dictionary] = [] + var skip := false + var rid := String(row.get("route_id", "")) + if not ROUTE_IDS.has(rid): + errs.append({"category": "enum", "message": "锻造路线 route_id 出域 %s: %s" % [ROUTE_IDS, rid]}) + if seen.has(rid): + errs.append({"category": "dup_key", "message": "锻造路线 route_id 重复: %s" % rid}) + seen[rid] = true + var kind := String(row.get("kind", "")) + if not ["weapon", "armor"].has(kind): + errs.append({"category": "enum", "message": "%s kind 出域 {weapon, armor}: %s" % [rid, kind]}) + skip = true + return {"errs": errs, "skip": skip} + # 类型轴:武器=attack_type 域、甲=armor_type 域;每型恰一路线(三武器三甲各一) + var axis := String(row.get("type_axis", "")) + var domain: Array[Variant] = ATTACK_TYPES if kind == "weapon" else ARMOR_TYPES + if not domain.has(axis): + errs.append({"category": "enum", "message": "%s type_axis 出域 %s: %s" % [rid, domain, axis]}) + var type_key := "%s|%s" % [kind, axis] + if type_seen.has(type_key): + errs.append({"category": "dup_key", "message": "%s 类型轴重复(每型恰一路线): %s %s" % [kind, axis, rid]}) + type_seen[type_key] = true + return {"errs": errs, "skip": skip} + + +## 数值域:等级/增量/价格/基础 AC 值域(甲型基础 AC 只属护甲路线) +func _validate_route_values(row: Dictionary, rid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var kind := String(row.get("kind", "")) + var cap_v: int = int(row.get("cap", -1)) + if cap_v < 1 or cap_v > 9: + errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [rid, cap_v]}) + var gain: int = int(row.get("per_level_gain", -1)) + if gain < 0 or gain > 9: + errs.append({"category": "domain", "message": "%s per_level_gain 越域 [0,9]: %d" % [rid, gain]}) + var base_ac: int = int(row.get("base_ac", -1)) + if kind == "armor": + if base_ac < 0 or base_ac > 999: + errs.append({"category": "domain", "message": "%s base_ac 越域 [0,999]: %d" % [rid, base_ac]}) + elif base_ac != 0: + errs.append({"category": "cross_field", "message": "%s 武器行 base_ac 须为 0(甲型基础 AC 只属护甲路线): %d" % [rid, base_ac]}) + var base_price: int = int(row.get("base_price", -1)) + if base_price < 1 or base_price > 999: + errs.append({"category": "domain", "message": "%s base_price 越域 [1,999]: %d" % [rid, base_price]}) + var ratio: int = int(row.get("price_ratio_pct", -1)) + if ratio < 100 or ratio > 500: + errs.append({"category": "domain", "message": "%s price_ratio_pct 越域 [100,500]: %d" % [rid, ratio]}) + var unlock_price: int = int(row.get("unlock_price", -1)) + if unlock_price < 0 or unlock_price > 999: + errs.append({"category": "domain", "message": "%s unlock_price 越域 [0,999]: %d" % [rid, unlock_price]}) + return errs + + +## 解锁通道域(并联双通道):初始持有行无前置无解锁价;待解锁行须有一次性解锁价+合法前置(武器←武器/甲←甲) +func _validate_route_unlock(row: Dictionary, rid: String, kind_by_id: Dictionary, initial_ids: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var kind := String(row.get("kind", "")) + var unlock_price: int = int(row.get("unlock_price", -1)) + var initial: bool = bool(row.get("initial_owned", false)) + var prereq := String(row.get("prereq_route", "")) + if initial: + if prereq != "": + errs.append({"category": "cross_field", "message": "%s 初始持有行不得带前置: %s" % [rid, prereq]}) + if unlock_price != 0: + errs.append({"category": "cross_field", "message": "%s 初始持有行解锁价须为 0: %d" % [rid, unlock_price]}) + initial_ids[rid] = kind + else: + if unlock_price < 1: + errs.append({"category": "cross_field", "message": "%s 待解锁行须有一次性解锁价(并联双通道②): %d" % [rid, unlock_price]}) + if not ROUTE_IDS.has(prereq): + errs.append({"category": "enum", "message": "%s prereq_route 出域 %s: %s" % [rid, ROUTE_IDS, prereq]}) + elif prereq == rid: + errs.append({"category": "cross_field", "message": "%s prereq_route 不得自指" % rid}) + elif kind_by_id.has(prereq) and String(kind_by_id[prereq]) != kind: + errs.append({"category": "cross_field", "message": "%s 前置 %s 类型不符(武器←武器/甲←甲): %s vs %s" % [rid, prereq, kind, String(kind_by_id[prereq])]}) + return errs + + +## 初始持有覆盖域(两尾段):初始持有恰两行且一武器一甲(长剑/轻甲)+ +## 默认轴=初始持有路线类型轴(Q1 裸体态语义:未锻造=0 加值且类型=普通/轻甲) +func _validate_initial_coverage(initial_ids: Dictionary) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] var init_weapons := 0 var init_armors := 0 for k in initial_ids.keys(): @@ -114,7 +149,6 @@ func validate() -> Array: init_armors += 1 if init_weapons != 1 or init_armors != 1: errs.append({"category": "coverage", "message": "初始持有须恰一武器一甲(现 sword/light): 武器 %d / 甲 %d" % [init_weapons, init_armors]}) - # 默认轴=初始持有路线类型轴(Q1 裸体态语义:未锻造=0 加值且类型=普通/轻甲) if init_weapons == 1 and init_armors == 1: for row in routes: if not bool(row.get("initial_owned", false)): diff --git a/game/data/schemas/meta_tree_table.gd b/game/data/schemas/meta_tree_table.gd index 8e75a3a5..e88ceae2 100644 --- a/game/data/schemas/meta_tree_table.gd +++ b/game/data/schemas/meta_tree_table.gd @@ -39,57 +39,105 @@ func validate() -> Array: errs.append({"category": "dup_key", "message": "node_id 重复: %s" % nid}) continue seen[nid] = true - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % nid}) - if not VEINS.has(String(row.get("vein", ""))): - errs.append({"category": "enum", "message": "%s vein 出域" % nid}) - var kind := String(row.get("kind", "")) - if not KINDS.has(kind): - errs.append({"category": "enum", "message": "%s kind 出域" % nid}) - if not CURRENCIES.has(String(row.get("currency", ""))): - errs.append({"category": "enum", "message": "%s currency 出域" % nid}) - for k in row.keys(): - if not KEY_WHITELIST.has(String(k)): - errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s" % [nid, String(k)]}) - var cap := int(row.get("cap", 1)) - if cap < 1 or cap > 9: - errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [nid, cap]}) - if kind in ["unlock", "socket", "fork", "easter"] and cap != 1: - errs.append({"category": "cross_field", "message": "%s %s 行 cap 恒 1" % [nid, kind]}) - var price := int(row.get("price", 0)) - if price < 0 or price > 9999: - errs.append({"category": "domain", "message": "%s price 越域 [0,9999]: %d" % [nid, price]}) + errs.append_array(_validate_row_head(row, nid)) + errs.append_array(_validate_row_numbers(row, nid)) + errs.append_array(_validate_row_effects(row, nid)) + errs.append_array(_validate_row_fork(row, nid, fork_seen)) + errs.append_array(_validate_requires_dangling(rows, seen)) + errs.append_array(_validate_requires_level(rows, seen)) + # 彩蛋 ≤3 + var easter := 0 + for row in rows: + if String(row.get("kind", "")) == "easter": + easter += 1 + if easter > 3: + errs.append({"category": "load_bearing", "message": "彩蛋节点 %d > 3(上限 3)" % easter}) + return errs + + +## 行头域:命名与枚举判据(display_name/vein/kind/currency/行键白名单) +func _validate_row_head(row: Dictionary, nid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % nid}) + if not VEINS.has(String(row.get("vein", ""))): + errs.append({"category": "enum", "message": "%s vein 出域" % nid}) + var kind := String(row.get("kind", "")) + if not KINDS.has(kind): + errs.append({"category": "enum", "message": "%s kind 出域" % nid}) + if not CURRENCIES.has(String(row.get("currency", ""))): + errs.append({"category": "enum", "message": "%s currency 出域" % nid}) + for k in row.keys(): + if not KEY_WHITELIST.has(String(k)): + errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s" % [nid, String(k)]}) + return errs + + +## 数值域:cap 越域/kind×cap 交叉/price 越域 +func _validate_row_numbers(row: Dictionary, nid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + var kind := String(row.get("kind", "")) + var cap := int(row.get("cap", 1)) + if cap < 1 or cap > 9: + errs.append({"category": "domain", "message": "%s cap 越域 [1,9]: %d" % [nid, cap]}) + if kind in ["unlock", "socket", "fork", "easter"] and cap != 1: + errs.append({"category": "cross_field", "message": "%s %s 行 cap 恒 1" % [nid, kind]}) + var price := int(row.get("price", 0)) + if price < 0 or price > 9999: + errs.append({"category": "domain", "message": "%s price 越域 [0,9999]: %d" % [nid, price]}) + return errs + + +## 效果域:effects 键词表封闭与局外树层级纪律(archetype 禁入) +func _validate_row_effects(row: Dictionary, nid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + var fx: Dictionary[Variant, Variant] = row.get("effects", {}) as Dictionary + for ek in fx.keys(): + if not (STAT_KEYS.has(String(ek)) or RULE_KEYS.has(String(ek))): + errs.append({"category": "zero_flat", "message": "%s effects 键出域(stat 线/rule 词表封闭): %s" % [nid, String(ek)]}) + # 🔴 层级纪律:局外树禁规则改写型特性(archetype 键出域=加载致命) + if fx.has("archetype"): + errs.append({"category": "load_bearing", "message": "%s effects 含 archetype(🔴 层级纪律——特性禁入局外树)" % nid}) + return errs + + +## Fork 结构域:一节点双态结构判据(fork_group 词表/组唯一/主 effects 须空/a/b 双键规则面) +func _validate_row_fork(row: Dictionary, nid: String, fork_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + var kind := String(row.get("kind", "")) + if kind == "fork": + var fg := String(row.get("fork_group", "")) + if not FORK_GROUPS.has(fg): + errs.append({"category": "enum", "message": "%s fork_group 出域" % nid}) + if fork_seen.has(fg): + errs.append({"category": "dup_key", "message": "%s fork 组重复(一组恰一节点): %s" % [nid, fg]}) + fork_seen[fg] = true var fx: Dictionary[Variant, Variant] = row.get("effects", {}) as Dictionary - for ek in fx.keys(): - if not (STAT_KEYS.has(String(ek)) or RULE_KEYS.has(String(ek))): - errs.append({"category": "zero_flat", "message": "%s effects 键出域(stat 线/rule 词表封闭): %s" % [nid, String(ek)]}) - # 🔴 层级纪律:局外树禁规则改写型特性(archetype 键出域=加载致命) - if fx.has("archetype"): - errs.append({"category": "load_bearing", "message": "%s effects 含 archetype(🔴 层级纪律——特性禁入局外树)" % nid}) - # Fork 一节点双态结构:fork 行必带 fork_group(每组恰一节点);主 effects 须空,效果只放 - # a/b 双键且键 ⊆ RULE_KEYS(A/B 只放规则面——层级纪律) - if kind == "fork": - var fg := String(row.get("fork_group", "")) - if not FORK_GROUPS.has(fg): - errs.append({"category": "enum", "message": "%s fork_group 出域" % nid}) - if fork_seen.has(fg): - errs.append({"category": "dup_key", "message": "%s fork 组重复(一组恰一节点): %s" % [nid, fg]}) - fork_seen[fg] = true - if not fx.is_empty(): - errs.append({"category": "cross_field", "message": "%s fork 行主 effects 须空(效果只放 a/b 双键)" % nid}) - for skey in ["effects_a", "effects_b"]: - var sd: Dictionary[Variant, Variant] = row.get(skey, {}) as Dictionary - if sd.is_empty(): - errs.append({"category": "domain", "message": "%s %s 缺失或空(双态节点须双侧效果)" % [nid, skey]}) - for k in sd.keys(): - if not RULE_KEYS.has(String(k)): - errs.append({"category": "zero_flat", "message": "%s %s 键出域(A/B 只放规则面): %s" % [nid, skey, String(k)]}) - # requires 悬空引用 + if not fx.is_empty(): + errs.append({"category": "cross_field", "message": "%s fork 行主 effects 须空(效果只放 a/b 双键)" % nid}) + for skey in ["effects_a", "effects_b"]: + var sd: Dictionary[Variant, Variant] = row.get(skey, {}) as Dictionary + if sd.is_empty(): + errs.append({"category": "domain", "message": "%s %s 缺失或空(双态节点须双侧效果)" % [nid, skey]}) + for k in sd.keys(): + if not RULE_KEYS.has(String(k)): + errs.append({"category": "zero_flat", "message": "%s %s 键出域(A/B 只放规则面): %s" % [nid, skey, String(k)]}) + return errs + + +## requires 悬空引用域 +func _validate_requires_dangling(rows: Array, seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] for row in rows: for req in (row.get("requires", []) as Array): if not seen.has(String(req)): errs.append({"category": "dangling", "message": "%s requires 悬空: %s" % [String(row.get("node_id", "")), String(req)]}) - # requires_level 等级阈值前置(R1 机制位——键须为在册节点、阈值整数 ≥1;类型错即拒) + return errs + + +## requires_level 等级阈值前置域(R1 机制位——键须为在册节点、阈值整数 ≥1;类型错即拒) +func _validate_requires_level(rows: Array, seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] for row in rows: var nid2: String = String(row.get("node_id", "")) var rl_v: Variant = row.get("requires_level", {}) @@ -101,13 +149,6 @@ func validate() -> Array: errs.append({"category": "domain", "message": "%s requires_level 阈值须 ≥1: %s=%d" % [nid2, String(req_id), int(rl_v[req_id])]}) elif row.has("requires_level"): errs.append({"category": "domain", "message": "%s requires_level 须为 Dictionary {node_id: n}" % nid2}) - # 彩蛋 ≤3 - var easter := 0 - for row in rows: - if String(row.get("kind", "")) == "easter": - easter += 1 - if easter > 3: - errs.append({"category": "load_bearing", "message": "彩蛋节点 %d > 3(上限 3)" % easter}) return errs diff --git a/game/data/schemas/reward_table.gd b/game/data/schemas/reward_table.gd index 65ef00f1..610e1845 100644 --- a/game/data/schemas/reward_table.gd +++ b/game/data/schemas/reward_table.gd @@ -30,84 +30,129 @@ func validate() -> Array: if seen.has(rid): errs.append({"category": "dup_key", "message": "reward_id 重复: %s" % rid}) seen[rid] = true - var ctype: String = row.get("cell_type", "") - if not CELL_TYPES.has(ctype): - errs.append({"category": "enum", "message": "%s cell_type 出域(五型封闭): %s" % [rid, ctype]}) - var pol: String = row.get("polarity", "") - if not POLARITIES.has(pol): - errs.append({"category": "enum", "message": "%s polarity 出域: %s" % [rid, pol]}) - var atk_type: String = String(row.get("attack_type", "")) - if atk_type != "" and not ["normal", "pierce", "magic"].has(atk_type): - errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]}) - # aux 投放行专属校验:极性恒正/加权入池/effect_id=item_id 非空(悬空在 config_db 跨表层 - # 对秘宝词表校验——reward_dangling_treasure)/无 power 无区间(数值语义不复用,道具效果读 TreasureItemTable) - if ctype == TREASURE_ITEM_TYPE: - # 负向行 = 危险格极性字典占位(雷安同池摇号下危险格可摇中 aux 身份——负路由零兑付兜底, - # effect_id 空/power 0/weight 0 不入池) - if pol == "negative": - if String(row.get("effect_id", "")) != "" or int(row.get("power", 0)) != 0 or int(row.get("weight", 0)) != 0: - errs.append({"category": "cross_field", "message": "%s aux 负占位行仅允许 空effect_id/0power/0weight" % rid}) - continue - if String(row.get("effect_id", "")) == "": - errs.append({"category": "domain", "message": "%s aux 正行 effect_id 空(应=item_id)" % rid}) - if int(row.get("power", 0)) != 0 or row.has("power_min"): - errs.append({"category": "cross_field", "message": "%s aux 正行禁 power/区间数值(道具数值读 TreasureItemTable)" % rid}) - var pool: String = row.get("pool_tag", "") - var pool_reserved: bool = pool.begins_with("clear_") and pool != "clear_default" - if not POOL_TAGS.has(pool) and not pool_reserved: - errs.append({"category": "enum", "message": "%s pool_tag 未知: %s" % [rid, pool]}) - elif pool_reserved: - errs.append({"category": "enum", "message": "%s pool_tag clear_ 命名空间预留拒绝: %s" % [rid, pool]}) - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % rid}) - var power: int = int(row.get("power", 0)) - var weight: int = int(row.get("weight", -1)) - var tier: int = int(row.get("tier", 0)) - if weight < 0 or weight > 999: - errs.append({"category": "domain", "message": "%s weight 越域 [0,999]" % rid}) - if tier < 1 or tier > 8: - errs.append({"category": "domain", "message": "%s tier 越域 [1,8]" % rid}) - var stacks: int = int(row.get("stacks", 1)) - if stacks < 1 or stacks > 16: - errs.append({"category": "domain", "message": "%s stacks 越域 [1,16]: %d" % [rid, stacks]}) - # 跨字段:effect_id 非空 = percent 行(引用悬空校验归 ConfigDB 跨表通道);空 = flat 行。 - # flat 行 power ≥ 1(直付/直兑行:治疗/群伤/直伤/行动点)——action 型 = 排队点数行(power = ±点数量级)。 - var eff: String = row.get("effect_id", "") - if power < 0 or power > 999: - errs.append({"category": "domain", "message": "%s power 越域 [0,999]: %d" % [rid, power]}) - if eff == "" and power < 1: - errs.append({"category": "cross_field", "message": "%s flat 行 power ≥ 1" % rid}) - # 区间配对(缺省 = 定值行向后兼容):必成对 · percent 行禁止 · power = 中点锚 - var has_min: bool = row.has("power_min") - var has_max: bool = row.has("power_max") - if has_min != has_max: - errs.append({"category": "cross_field", "message": "%s power_min/power_max 必须成对出现" % rid}) - elif has_min: - var pmin: int = int(row.get("power_min", 0)) - var pmax: int = int(row.get("power_max", 0)) - if pmin < 1 or pmax > 999 or pmin > pmax: - errs.append({"category": "domain", "message": "%s power 域违例 [1,999] 且 min≤max: %d..%d" % [rid, pmin, pmax]}) - if eff != "": - errs.append({"category": "cross_field", "message": "%s percent 行禁区间字段" % rid}) - if power != int((pmin + pmax) / 2.0): - # 中点锚:power = 区间中点(行动经济 条款①期望当量锚——调区间必同步调 power) - errs.append({"category": "cross_field", "message": "%s power(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [rid, power, (pmin + pmax) / 2]}) + errs.append_array(_validate_row_axes(row, rid)) + var aux_out := _validate_row_aux(row, rid) + errs.append_array(aux_out["errs"]) + if aux_out["skip"]: + continue + errs.append_array(_validate_row_pool_tag(row, rid)) + errs.append_array(_validate_row_values(row, rid)) + errs.append_array(_validate_row_power_range(row, rid)) # 字典完整性 / 池护栏累计(cell 池行) - if pool == "cell" and CELL_TYPES.has(ctype) and POLARITIES.has(pol): - cell_dict["%s|%s" % [ctype, pol]] = true - if pol == "positive" and weight > 0: + if String(row.get("pool_tag", "")) == "cell" and CELL_TYPES.has(String(row.get("cell_type", ""))) and POLARITIES.has(String(row.get("polarity", ""))): + cell_dict["%s|%s" % [String(row.get("cell_type", "")), String(row.get("polarity", ""))]] = true + if String(row.get("polarity", "")) == "positive" and int(row.get("weight", -1)) > 0: cell_positive_live += 1 - if pool == "clear_default" and eff == "" and power >= 1: + if String(row.get("pool_tag", "")) == "clear_default" and String(row.get("effect_id", "")) == "" and int(row.get("power", 0)) >= 1: clear_value_ok = true - # ① 字典完整性:五型 × 正负两行齐备(缺任一即致命) + errs.append_array(_validate_table_closure(cell_dict, cell_positive_live, clear_value_ok)) + return errs + + +## 轴域:cell_type 五型封闭/polarity 两极/attack_type 可省略枚举 +func _validate_row_axes(row: Dictionary, rid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var ctype: String = row.get("cell_type", "") + if not CELL_TYPES.has(ctype): + errs.append({"category": "enum", "message": "%s cell_type 出域(五型封闭): %s" % [rid, ctype]}) + var pol: String = row.get("polarity", "") + if not POLARITIES.has(pol): + errs.append({"category": "enum", "message": "%s polarity 出域: %s" % [rid, pol]}) + var atk_type: String = String(row.get("attack_type", "")) + if atk_type != "" and not ["normal", "pierce", "magic"].has(atk_type): + errs.append({"category": "enum", "message": "%s attack_type 出域 {normal, pierce, magic}: %s" % [rid, atk_type]}) + return errs + + +## aux 投放行域(掉落投放行):极性恒正/加权入池/effect_id=item_id 非空(悬空在 config_db 跨表层 +## 对秘宝词表校验——reward_dangling_treasure)/无 power 无区间(数值语义不复用,道具效果读 TreasureItemTable); +## 负占位行返回 skip=true(跳过行剩余判据——原 continue 语义) +func _validate_row_aux(row: Dictionary, rid: String) -> Dictionary: + var errs: Array[Dictionary] = [] + var skip := false + if String(row.get("cell_type", "")) == TREASURE_ITEM_TYPE: + # 负向行 = 危险格极性字典占位(雷安同池摇号下危险格可摇中 aux 身份——负路由零兑付兜底, + # effect_id 空/power 0/weight 0 不入池) + if String(row.get("polarity", "")) == "negative": + if String(row.get("effect_id", "")) != "" or int(row.get("power", 0)) != 0 or int(row.get("weight", 0)) != 0: + errs.append({"category": "cross_field", "message": "%s aux 负占位行仅允许 空effect_id/0power/0weight" % rid}) + skip = true + return {"errs": errs, "skip": skip} + if String(row.get("effect_id", "")) == "": + errs.append({"category": "domain", "message": "%s aux 正行 effect_id 空(应=item_id)" % rid}) + if int(row.get("power", 0)) != 0 or row.has("power_min"): + errs.append({"category": "cross_field", "message": "%s aux 正行禁 power/区间数值(道具数值读 TreasureItemTable)" % rid}) + return {"errs": errs, "skip": skip} + + +## 池标签域:pool_tag 词表与 clear_ 命名空间预留拒绝/display_name 非空 +func _validate_row_pool_tag(row: Dictionary, rid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var pool: String = row.get("pool_tag", "") + var pool_reserved: bool = pool.begins_with("clear_") and pool != "clear_default" + if not POOL_TAGS.has(pool) and not pool_reserved: + errs.append({"category": "enum", "message": "%s pool_tag 未知: %s" % [rid, pool]}) + elif pool_reserved: + errs.append({"category": "enum", "message": "%s pool_tag clear_ 命名空间预留拒绝: %s" % [rid, pool]}) + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % rid}) + return errs + + +## 数值域:weight/tier/stacks/power 越域与 flat 行 power ≥ 1 +## (effect_id 非空 = percent 行(引用悬空校验归 ConfigDB 跨表通道);空 = flat 行。 +## flat 行 power ≥ 1(直付/直兑行:治疗/群伤/直伤/行动点)——action 型 = 排队点数行(power = ±点数量级)) +func _validate_row_values(row: Dictionary, rid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var power: int = int(row.get("power", 0)) + var weight: int = int(row.get("weight", -1)) + var tier: int = int(row.get("tier", 0)) + if weight < 0 or weight > 999: + errs.append({"category": "domain", "message": "%s weight 越域 [0,999]" % rid}) + if tier < 1 or tier > 8: + errs.append({"category": "domain", "message": "%s tier 越域 [1,8]" % rid}) + var stacks: int = int(row.get("stacks", 1)) + if stacks < 1 or stacks > 16: + errs.append({"category": "domain", "message": "%s stacks 越域 [1,16]: %d" % [rid, stacks]}) + var eff: String = row.get("effect_id", "") + if power < 0 or power > 999: + errs.append({"category": "domain", "message": "%s power 越域 [0,999]: %d" % [rid, power]}) + if eff == "" and power < 1: + errs.append({"category": "cross_field", "message": "%s flat 行 power ≥ 1" % rid}) + return errs + + +## 区间配对域(缺省 = 定值行向后兼容):必成对 · percent 行禁止 · power = 中点锚 +func _validate_row_power_range(row: Dictionary, rid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var power: int = int(row.get("power", 0)) + var eff: String = row.get("effect_id", "") + var has_min: bool = row.has("power_min") + var has_max: bool = row.has("power_max") + if has_min != has_max: + errs.append({"category": "cross_field", "message": "%s power_min/power_max 必须成对出现" % rid}) + elif has_min: + var pmin: int = int(row.get("power_min", 0)) + var pmax: int = int(row.get("power_max", 0)) + if pmin < 1 or pmax > 999 or pmin > pmax: + errs.append({"category": "domain", "message": "%s power 域违例 [1,999] 且 min≤max: %d..%d" % [rid, pmin, pmax]}) + if eff != "": + errs.append({"category": "cross_field", "message": "%s percent 行禁区间字段" % rid}) + if power != int((pmin + pmax) / 2.0): + # 中点锚:power = 区间中点(行动经济 条款①期望当量锚——调区间必同步调 power) + errs.append({"category": "cross_field", "message": "%s power(%d) ≠ 区间中点(%d)——中点恒等期望当量锚" % [rid, power, (pmin + pmax) / 2]}) + return errs + + +## 表闭合域(三尾段):①九型字典完整性(类型×极性齐备)②池空护栏 ③clear_default 恒正当量 +func _validate_table_closure(cell_dict: Dictionary, cell_positive_live: int, clear_value_ok: bool) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] for ctype in CELL_TYPE_KEYS: for pol in POLARITIES: if not cell_dict.has("%s|%s" % [ctype, pol]): errs.append({"category": "cross_field", "message": "九型字典缺行: %s %s——(类型, 极性) 齐备为加载前提" % [ctype, pol]}) - # ② 池空护栏:cell 池 ≥1 正向行 weight>0(摇号池非空) if cell_positive_live < 1: errs.append({"category": "cross_field", "message": "cell 池零活正向行(weight>0)——摇号池空"}) - # ③ clear_default 恒正当量(行动经济 条款④——清图奖励 > 0 的行级兑现口径) if not clear_value_ok: errs.append({"category": "cross_field", "message": "clear_default 池无正当量活行(清图奖励 > 0 违例)"}) return errs diff --git a/game/data/schemas/treasure_item_table.gd b/game/data/schemas/treasure_item_table.gd index 20acd403..ceaeccf0 100644 --- a/game/data/schemas/treasure_item_table.gd +++ b/game/data/schemas/treasure_item_table.gd @@ -73,136 +73,195 @@ func validate() -> Array: errs.append({"category": "dup_key", "message": "item_id 重复: %s" % iid}) continue seen[iid] = true - if String(row.get("display_name", "")) == "": - errs.append({"category": "domain", "message": "%s display_name 空" % iid}) - var retired: bool = bool(row.get("retired", false)) - var deferred: bool = bool(row.get("deferred", false)) - # dimension_tags:五维词表 + 非空(退役/失义行仍须登记维度——语义面保留) - var tags: Array[Variant] = row.get("dimension_tags", []) - if tags.is_empty(): - errs.append({"category": "domain", "message": "%s dimension_tags 空" % iid}) - for tag in tags: - if not DIMENSIONS.has(String(tag)): - errs.append({"category": "enum", "message": "%s dimension_tag 出域 %s: %s" % [iid, str(DIMENSIONS), str(tag)]}) - var kinds: Array[Variant] = row.get("effect_kinds", []) - var target: String = String(row.get("target", "none")) - if not TARGETS.has(target): - errs.append({"category": "enum", "message": "%s target 出域 %s: %s" % [iid, str(TARGETS), target]}) - var phase: String = String(row.get("use_phase", "active_dig")) - if not USE_PHASES.has(phase): - errs.append({"category": "enum", "message": "%s use_phase 出域 %s: %s" % [iid, str(USE_PHASES), phase]}) - # 🔴 零裸 flat 结构护栏:键白名单封闭(同调批 T1——机制轴禁无条件属性加项) - for k in row.keys(): - if not KEY_WHITELIST.has(String(k)): - errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s(KEY_WHITELIST 外键禁入)" % [iid, String(k)]}) - # triggers 事件订阅行(被动件形态;与 effect_kinds 互斥校验 - # 面=passive 行(单形态——效果行为只走订阅);active_charged 行两形合法并存 - # (effect_kinds=主动分发通道 + triggers=受理闸订阅,掘进蓄能先例)) - if row.has("triggers"): - if (row.get("effect_kinds", []) as Array).size() > 0 and String(row.get("form", "")) != "active_charged" and not bool(row.get("retired", false)): - errs.append({"category": "domain", "message": "%s triggers 与 effect_kinds 并存(换代单形态——被动件迁移期禁双挂)" % iid}) - EffectEngine.validate_trigger_row(iid, row, row.get("triggers", []) as Array, errs) - var form: String = String(row.get("form", "")) - if not FORMS.has(form): - errs.append({"category": "enum", "message": "%s form 出域 %s: %s" % [iid, str(FORMS), form]}) - if retired or deferred: + errs.append_array(_validate_row_head(row, iid)) + if bool(row.get("retired", false)) or bool(row.get("deferred", false)): # 退役/失义行:免 effect_kinds 非空与承重护栏校验(登记面保留,不入投放与 F2) continue - # ---- 活行校验 ---- - if kinds.is_empty() and not row.has("triggers"): - errs.append({"category": "domain", "message": "%s effect_kinds 空(活行;失义件应标 deferred,被动件应挂 triggers 订阅)" % iid}) var kind_seen := {} - for k in kinds: - var kind: String = String(k) - if not EFFECT_KINDS.has(kind): - errs.append({"category": "enum", "message": "%s effect_kind 出域 %s: %s" % [iid, str(EFFECT_KINDS), kind]}) - if kind_seen.has(kind): - errs.append({"category": "dup_key", "message": "%s effect_kind 重复: %s" % [iid, kind]}) - kind_seen[kind] = true - # 承重护栏①真视窄域:真视类活行 target 必须 area_3x3 或 cell(一次最多 3×3,禁全图看雷; - # reveal_tiles 与 cell 档纳入同护栏,线香单格=cell ⊂ 3×3 契约内) - for rv in REVEAL_KINDS: - if kind_seen.has(String(rv)) and not (target == "area_3x3" or target == "cell"): - errs.append({"category": "load_bearing", "message": "%s 真视窄域护栏:%s 行 target 必须 area_3x3 或 cell,实得 %s(秘宝 GDD 结构冻结)" % [iid, String(rv), target]}) - # 承重护栏②board_wide 仅恢复推理类:全图域行禁替推理效果 - if target == "board_wide" and kind_seen.has("mine_reveal"): - errs.append({"category": "load_bearing", "message": "%s board_wide 行 effect_kinds 不得含 mine_reveal(全图域仅恢复推理原料类——Alpha 重定后落位)" % iid}) - # magnitude:MAGNITUDE_KINDS 行消费(≥1);其余活行恒 0(同调批 T1——消费面自 ap_grant 扩至 N 值 kind) - var mag: int = int(row.get("magnitude", 0)) - if mag < 0 or mag > 999: - errs.append({"category": "domain", "message": "%s magnitude 越域 [0,999]: %d" % [iid, mag]}) - var mag_consuming := false - for k in kind_seen.keys(): - if MAGNITUDE_KINDS.has(String(k)): - mag_consuming = true - if mag_consuming and mag < 1: - errs.append({"category": "cross_field", "message": "%s N 值 kind 行 magnitude 应 ≥1,实得 %d" % [iid, mag]}) - if not mag_consuming and mag != 0 and not row.has("triggers"): - errs.append({"category": "cross_field", "message": "%s 非 N 值 kind 活行 magnitude 应 0(trigger 行 magnitude=value_from 取数源豁免),实得 %d" % [iid, mag]}) - # ---- 同调行约束(form 非空——装配面;过渡行 form="" 不校验)---- + errs.append_array(_validate_row_active(row, iid, kind_seen)) + var form: String = String(row.get("form", "")) if form != "": - if int(row.get("slot_cost", 1)) != 1: - errs.append({"category": "load_bearing", "message": "%s 同调占位统一 1 槽(slot_cost 恒 1——底稿裁决 5,schema 强制)" % iid}) - # 档位必填(槽位档位约束数据面:件档≤格档判定的取数源) - var tier: String = String(row.get("tier", "")) - if not TIERS.has(tier): - errs.append({"category": "domain", "message": "%s 同调行 tier 必填且 ∈ %s,实得 %s" % [iid, str(TIERS), tier]}) - var charges: int = int(row.get("charges_per_adventure", 0)) - if form == "passive" and charges != 0: - errs.append({"category": "cross_field", "message": "%s passive 行 charges_per_adventure 应 0,实得 %d" % [iid, charges]}) - if form == "active_charged" and charges < 0: - errs.append({"category": "domain", "message": "%s active_charged 行 charges_per_adventure 越域 ≥0,实得 %d" % [iid, charges]}) - if form == "active_charged" and charges == 0: - # 充能 0=积点制/滑耗制合法态(试炼镐转化获取,无固定充能上限): - # 必须带使用闸——ap_charge_safe 滑耗(安全格积点)或 treasure_used+counter_spend 受理闸,二者缺一即孤儿行 - var has_gate := kind_seen.has("ap_charge_safe") - if not has_gate and row.has("triggers"): - for tr_v in (row.get("triggers", []) as Array): - if String((tr_v as Dictionary).get("do", {}).get("kind", "")) == "counter_spend": - has_gate = true - break - if not has_gate: - errs.append({"category": "cross_field", "message": "%s active_charged 行 charges=0(积点制)须带 ap_charge_safe 或 counter_spend 受理闸,实得二者皆无" % iid}) - # 充能重置周期(adventure 缺省/battle 每战/level 每关;仅 active_charged 行可设) - var creset: String = String(row.get("charge_reset", "adventure")) - if not CHARGE_RESETS.has(creset): - errs.append({"category": "enum", "message": "%s charge_reset 出域 %s: %s" % [iid, str(CHARGE_RESETS), creset]}) - if form != "active_charged" and row.has("charge_reset") and creset != "adventure": - errs.append({"category": "cross_field", "message": "%s 非 active_charged 行 charge_reset 仅可缺省(adventure),实得 %s" % [iid, creset]}) - # 解锁混合制字段校验(契约 6:钥石购买价与条件谓词互斥——每件单解锁方式,混合不做) - var price: int = int(row.get("unlock_keystone_cost", 0)) - var ureq_count: int = (row.get("unlock_requires", []) as Array).size() - if price != 0 and ureq_count > 0: - errs.append({"category": "cross_field", "message": "%s 解锁方式互斥:unlock_keystone_cost 与 unlock_requires 不得并存(每件单解锁方式,契约 6)" % iid}) - if price < 0 or price > 999: - errs.append({"category": "domain", "message": "%s unlock_keystone_cost 越域 [0,999](0=非购买件缺省),实得 %d" % [iid, price]}) - var eff_rounds: int = int(row.get("effective_rounds", 0)) - if eff_rounds < 0 or eff_rounds > 999: - errs.append({"category": "domain", "message": "%s effective_rounds 越域 [0,999]: %d" % [iid, eff_rounds]}) - if kind_seen.has("ap_rounds_front") and eff_rounds < 1: - errs.append({"category": "cross_field", "message": "%s ap_rounds_front 行 effective_rounds 应 ≥1(P4 甲表值化),实得 %d" % [iid, eff_rounds]}) - if not kind_seen.has("ap_rounds_front") and eff_rounds != 0 and not row.has("triggers"): - errs.append({"category": "cross_field", "message": "%s 非 ap_rounds_front 行 effective_rounds 应 0(trigger 行=when value_from 取数源豁免),实得 %d" % [iid, eff_rounds]}) - var charge_n: int = int(row.get("charge_n", 0)) - if charge_n < 0 or charge_n > 999: - errs.append({"category": "domain", "message": "%s charge_n 越域 [0,999]: %d" % [iid, charge_n]}) - if kind_seen.has("ap_charge_safe") and charge_n < 1: - errs.append({"category": "cross_field", "message": "%s ap_charge_safe 行 charge_n 应 ≥1(每次充能需 N 安全格),实得 %d" % [iid, charge_n]}) - if not kind_seen.has("ap_charge_safe") and charge_n != 0: - errs.append({"category": "cross_field", "message": "%s 非 ap_charge_safe 行 charge_n 应 0,实得 %d" % [iid, charge_n]}) - # unlock_requires 谓词行:空 = 已解锁(首批全解锁合法态,底稿 §2.2);非空键 ∈ 计数键封闭枚举 - var ureqs: Array[Variant] = row.get("unlock_requires", []) - var ukeys := {} - for req in ureqs: - var ukey: String = String(req.get("key", "")) - if not UNLOCK_COUNTER_KEYS.has(ukey): - errs.append({"category": "enum", "message": "%s unlock_requires 计数键出域 %s: %s" % [iid, str(UNLOCK_COUNTER_KEYS), ukey]}) - if ukeys.has(ukey): - errs.append({"category": "dup_key", "message": "%s unlock_requires 计数键重复: %s" % [iid, ukey]}) - ukeys[ukey] = true - var uth: int = int(req.get("threshold", 0)) - if uth < 1 or uth > 999: - errs.append({"category": "domain", "message": "%s unlock_requires 阈值越域 [1,999]: %d" % [iid, uth]}) + errs.append_array(_validate_row_attunement(row, iid, kind_seen)) + errs.append_array(_validate_row_unlock_costs(row, iid)) + errs.append_array(_validate_row_effect_numbers(row, iid, kind_seen)) + errs.append_array(_validate_row_unlock_requires(row, iid)) + return errs + + +## 行头域:display_name/维度标签/target/use_phase/行键白名单/triggers 订阅形态/form 词表 +func _validate_row_head(row: Dictionary, iid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + if String(row.get("display_name", "")) == "": + errs.append({"category": "domain", "message": "%s display_name 空" % iid}) + # dimension_tags:五维词表 + 非空(退役/失义行仍须登记维度——语义面保留) + var tags: Array[Variant] = row.get("dimension_tags", []) + if tags.is_empty(): + errs.append({"category": "domain", "message": "%s dimension_tags 空" % iid}) + for tag in tags: + if not DIMENSIONS.has(String(tag)): + errs.append({"category": "enum", "message": "%s dimension_tag 出域 %s: %s" % [iid, str(DIMENSIONS), str(tag)]}) + var target: String = String(row.get("target", "none")) + if not TARGETS.has(target): + errs.append({"category": "enum", "message": "%s target 出域 %s: %s" % [iid, str(TARGETS), target]}) + var phase: String = String(row.get("use_phase", "active_dig")) + if not USE_PHASES.has(phase): + errs.append({"category": "enum", "message": "%s use_phase 出域 %s: %s" % [iid, str(USE_PHASES), phase]}) + # 🔴 零裸 flat 结构护栏:键白名单封闭(同调批 T1——机制轴禁无条件属性加项) + for k in row.keys(): + if not KEY_WHITELIST.has(String(k)): + errs.append({"category": "zero_flat", "message": "%s 行键出白名单: %s(KEY_WHITELIST 外键禁入)" % [iid, String(k)]}) + errs.append_array(_validate_row_triggers(row, iid)) + var form: String = String(row.get("form", "")) + if not FORMS.has(form): + errs.append({"category": "enum", "message": "%s form 出域 %s: %s" % [iid, str(FORMS), form]}) + return errs + + +## 订阅形态域:triggers 事件订阅行判据与行级触发词表校验(被动件形态;与 effect_kinds 互斥校验 +## 面=passive 行(单形态——效果行为只走订阅);active_charged 行两形合法并存 +## (effect_kinds=主动分发通道 + triggers=受理闸订阅,掘进蓄能先例)) +func _validate_row_triggers(row: Dictionary, iid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + if row.has("triggers"): + if (row.get("effect_kinds", []) as Array).size() > 0 and String(row.get("form", "")) != "active_charged" and not bool(row.get("retired", false)): + errs.append({"category": "domain", "message": "%s triggers 与 effect_kinds 并存(换代单形态——被动件迁移期禁双挂)" % iid}) + EffectEngine.validate_trigger_row(iid, row, row.get("triggers", []) as Array, errs) + return errs + + +## 活行域:effect_kinds 非空与词表/真视窄域与 board_wide 承重护栏/magnitude 语义(kind_seen 引用填充——同调域消费) +func _validate_row_active(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + var kinds: Array[Variant] = row.get("effect_kinds", []) + var target: String = String(row.get("target", "none")) + if kinds.is_empty() and not row.has("triggers"): + errs.append({"category": "domain", "message": "%s effect_kinds 空(活行;失义件应标 deferred,被动件应挂 triggers 订阅)" % iid}) + for k in kinds: + var kind: String = String(k) + if not EFFECT_KINDS.has(kind): + errs.append({"category": "enum", "message": "%s effect_kind 出域 %s: %s" % [iid, str(EFFECT_KINDS), kind]}) + if kind_seen.has(kind): + errs.append({"category": "dup_key", "message": "%s effect_kind 重复: %s" % [iid, kind]}) + kind_seen[kind] = true + # 承重护栏①真视窄域:真视类活行 target 必须 area_3x3 或 cell(一次最多 3×3,禁全图看雷; + # reveal_tiles 与 cell 档纳入同护栏,线香单格=cell ⊂ 3×3 契约内) + for rv in REVEAL_KINDS: + if kind_seen.has(String(rv)) and not (target == "area_3x3" or target == "cell"): + errs.append({"category": "load_bearing", "message": "%s 真视窄域护栏:%s 行 target 必须 area_3x3 或 cell,实得 %s(秘宝 GDD 结构冻结)" % [iid, String(rv), target]}) + # 承重护栏②board_wide 仅恢复推理类:全图域行禁替推理效果 + if target == "board_wide" and kind_seen.has("mine_reveal"): + errs.append({"category": "load_bearing", "message": "%s board_wide 行 effect_kinds 不得含 mine_reveal(全图域仅恢复推理原料类——Alpha 重定后落位)" % iid}) + errs.append_array(_validate_row_magnitude(row, iid, kind_seen)) + return errs + + +## N 值语义域:magnitude 越域与 MAGNITUDE_KINDS 行消费(≥1);其余活行恒 0(同调批 T1——消费面自 ap_grant 扩至 N 值 kind) +func _validate_row_magnitude(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + var mag: int = int(row.get("magnitude", 0)) + if mag < 0 or mag > 999: + errs.append({"category": "domain", "message": "%s magnitude 越域 [0,999]: %d" % [iid, mag]}) + var mag_consuming := false + for k in kind_seen.keys(): + if MAGNITUDE_KINDS.has(String(k)): + mag_consuming = true + if mag_consuming and mag < 1: + errs.append({"category": "cross_field", "message": "%s N 值 kind 行 magnitude 应 ≥1,实得 %d" % [iid, mag]}) + if not mag_consuming and mag != 0 and not row.has("triggers"): + errs.append({"category": "cross_field", "message": "%s 非 N 值 kind 活行 magnitude 应 0(trigger 行 magnitude=value_from 取数源豁免),实得 %d" % [iid, mag]}) + return errs + + +## 同调充能与档位域(form 非空——装配面;过渡行 form="" 不校验):槽位统一/档位必填/充能值域与积点制受理闸/充能重置周期 +func _validate_row_attunement(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + if int(row.get("slot_cost", 1)) != 1: + errs.append({"category": "load_bearing", "message": "%s 同调占位统一 1 槽(slot_cost 恒 1——底稿裁决 5,schema 强制)" % iid}) + # 档位必填(槽位档位约束数据面:件档≤格档判定的取数源) + var tier: String = String(row.get("tier", "")) + if not TIERS.has(tier): + errs.append({"category": "domain", "message": "%s 同调行 tier 必填且 ∈ %s,实得 %s" % [iid, str(TIERS), tier]}) + var charges: int = int(row.get("charges_per_adventure", 0)) + var form: String = String(row.get("form", "")) + if form == "passive" and charges != 0: + errs.append({"category": "cross_field", "message": "%s passive 行 charges_per_adventure 应 0,实得 %d" % [iid, charges]}) + if form == "active_charged" and charges < 0: + errs.append({"category": "domain", "message": "%s active_charged 行 charges_per_adventure 越域 ≥0,实得 %d" % [iid, charges]}) + if form == "active_charged" and charges == 0: + # 充能 0=积点制/滑耗制合法态(试炼镐转化获取,无固定充能上限): + # 必须带使用闸——ap_charge_safe 滑耗(安全格积点)或 treasure_used+counter_spend 受理闸,二者缺一即孤儿行 + errs.append_array(_validate_row_charge_gate(row, iid, kind_seen)) + # 充能重置周期(adventure 缺省/battle 每战/level 每关;仅 active_charged 行可设) + var creset: String = String(row.get("charge_reset", "adventure")) + if not CHARGE_RESETS.has(creset): + errs.append({"category": "enum", "message": "%s charge_reset 出域 %s: %s" % [iid, str(CHARGE_RESETS), creset]}) + if form != "active_charged" and row.has("charge_reset") and creset != "adventure": + errs.append({"category": "cross_field", "message": "%s 非 active_charged 行 charge_reset 仅可缺省(adventure),实得 %s" % [iid, creset]}) + return errs + + +## 积点制受理闸域:charges=0 的 active_charged 行必须带使用闸(ap_charge_safe 滑耗或 counter_spend 受理闸) +func _validate_row_charge_gate(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + var has_gate := kind_seen.has("ap_charge_safe") + if not has_gate and row.has("triggers"): + for tr_v in (row.get("triggers", []) as Array): + if String((tr_v as Dictionary).get("do", {}).get("kind", "")) == "counter_spend": + has_gate = true + break + if not has_gate: + errs.append({"category": "cross_field", "message": "%s active_charged 行 charges=0(积点制)须带 ap_charge_safe 或 counter_spend 受理闸,实得二者皆无" % iid}) + return errs + + +## 解锁互斥域(form 非空行,契约 6):钥石购买价与条件谓词互斥——每件单解锁方式,混合不做 +func _validate_row_unlock_costs(row: Dictionary, iid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + # 解锁混合制字段校验(契约 6:钥石购买价与条件谓词互斥——每件单解锁方式,混合不做) + var price: int = int(row.get("unlock_keystone_cost", 0)) + var ureq_count: int = (row.get("unlock_requires", []) as Array).size() + if price != 0 and ureq_count > 0: + errs.append({"category": "cross_field", "message": "%s 解锁方式互斥:unlock_keystone_cost 与 unlock_requires 不得并存(每件单解锁方式,契约 6)" % iid}) + if price < 0 or price > 999: + errs.append({"category": "domain", "message": "%s unlock_keystone_cost 越域 [0,999](0=非购买件缺省),实得 %d" % [iid, price]}) + return errs + + +## 效果数值域(form 非空行):effective_rounds 与 charge_n 的值域及 kind 交叉 +func _validate_row_effect_numbers(row: Dictionary, iid: String, kind_seen: Dictionary) -> Array[Variant]: + var errs: Array[Variant] = [] + var eff_rounds: int = int(row.get("effective_rounds", 0)) + if eff_rounds < 0 or eff_rounds > 999: + errs.append({"category": "domain", "message": "%s effective_rounds 越域 [0,999]: %d" % [iid, eff_rounds]}) + if kind_seen.has("ap_rounds_front") and eff_rounds < 1: + errs.append({"category": "cross_field", "message": "%s ap_rounds_front 行 effective_rounds 应 ≥1(P4 甲表值化),实得 %d" % [iid, eff_rounds]}) + if not kind_seen.has("ap_rounds_front") and eff_rounds != 0 and not row.has("triggers"): + errs.append({"category": "cross_field", "message": "%s 非 ap_rounds_front 行 effective_rounds 应 0(trigger 行=when value_from 取数源豁免),实得 %d" % [iid, eff_rounds]}) + var charge_n: int = int(row.get("charge_n", 0)) + if charge_n < 0 or charge_n > 999: + errs.append({"category": "domain", "message": "%s charge_n 越域 [0,999]: %d" % [iid, charge_n]}) + if kind_seen.has("ap_charge_safe") and charge_n < 1: + errs.append({"category": "cross_field", "message": "%s ap_charge_safe 行 charge_n 应 ≥1(每次充能需 N 安全格),实得 %d" % [iid, charge_n]}) + if not kind_seen.has("ap_charge_safe") and charge_n != 0: + errs.append({"category": "cross_field", "message": "%s 非 ap_charge_safe 行 charge_n 应 0,实得 %d" % [iid, charge_n]}) + return errs + + +## 谓词行域:unlock_requires 计数键封闭枚举与阈值(空 = 已解锁——首批全解锁合法态,底稿 §2.2) +func _validate_row_unlock_requires(row: Dictionary, iid: String) -> Array[Variant]: + var errs: Array[Variant] = [] + var ureqs: Array[Variant] = row.get("unlock_requires", []) + var ukeys := {} + for req in ureqs: + var ukey: String = String(req.get("key", "")) + if not UNLOCK_COUNTER_KEYS.has(ukey): + errs.append({"category": "enum", "message": "%s unlock_requires 计数键出域 %s: %s" % [iid, str(UNLOCK_COUNTER_KEYS), ukey]}) + if ukeys.has(ukey): + errs.append({"category": "dup_key", "message": "%s unlock_requires 计数键重复: %s" % [iid, ukey]}) + ukeys[ukey] = true + var uth: int = int(req.get("threshold", 0)) + if uth < 1 or uth > 999: + errs.append({"category": "domain", "message": "%s unlock_requires 阈值越域 [1,999]: %d" % [iid, uth]}) return errs