From f9c9caf56b2c1c2c9d8b5686b896dee8376f2f0d Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 7 Nov 2026 03:05:12 +0800 Subject: [PATCH] =?UTF-8?q?iter54-=E5=8D=A13=EF=BC=9A=E7=B2=BE=E8=8B=B1?= =?UTF-8?q?=E4=B8=93=E5=B1=9E=E6=B1=A0=E6=89=B9=EF=BC=88elite=20=E8=A1=8C?= =?UTF-8?q?=E7=B1=BB=E4=B8=8E=E5=AD=97=E6=AE=B5=E4=BA=92=E9=94=81/dangling?= =?UTF-8?q?=5Fbase/effective=5Fpool=20=E6=B1=A0=E7=BB=A7=E6=89=BF=E5=90=88?= =?UTF-8?q?=E5=B9=B6/ENCOUNTER=5FROW=5FKINDS=20=E5=AF=BB=E5=9D=80=E9=97=A8?= =?UTF-8?q?=E6=9A=82=E4=B8=8D=E6=8E=A5=E7=BA=BF/goblin=5Felite=20=E8=90=BD?= =?UTF-8?q?=E8=A1=A8=EF=BC=8B=E5=AD=98=E9=87=8F=E5=86=92=E7=83=9F=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=EF=BC=8B=E5=9B=BE=E9=89=B4=E5=AF=BC=E5=87=BA=E8=AE=A4?= =?UTF-8?q?=E5=BE=97=E7=B2=BE=E8=8B=B1=E8=A1=8C=EF=BC=89=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=AF=B9=E7=85=A7=E8=A1=A8=20R1-R7=20=E5=85=A8=E7=BB=BF?= =?UTF-8?q?=EF=BC=88elite=5Frow=5Ftest=20=E5=85=AD=E7=BB=84/=E4=BA=94?= =?UTF-8?q?=E4=BB=B6=E5=AD=98=E9=87=8F=E5=A5=97=E4=BB=B6=E7=9B=B4=E8=B7=91?= =?UTF-8?q?=E7=BB=BF/=E7=94=9F=E4=BA=A7=E8=A1=A8=20error=20=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=200=EF=BC=89=EF=BC=8CT3.8=20=E9=87=8C=E7=A8=8B?= =?UTF-8?q?=E7=A2=91=E5=87=BA=E5=8F=A3=20--code=20=E5=85=AD=E9=97=B8?= =?UTF-8?q?=E5=85=A8=E7=BB=BF=E3=80=90=E9=AA=8C=E6=94=B6=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E3=80=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/core/config_db_cross_validator.gd | 2 +- game/core/config_db_resolver.gd | 2 +- game/data/schemas/enemy_table.gd | 131 +++++++- game/data/tables/enemy.tres | 22 +- game/sim/compendium_dump.gd | 19 +- game/tests/run_all.gd | 1 + .../round_flow_skill_pool_test.gd | 18 +- .../unit/core/level_shop_economy_test.gd | 3 +- game/tests/unit/data_config/config_db_test.gd | 6 +- game/tests/unit/data_config/elite_row_test.gd | 305 ++++++++++++++++++ .../data_config/table_structure_guard_test.gd | 16 +- reports/iter54-反哺点-2026-11-07.md | 6 +- reports/iter54-实现报告-2026-11-07.md | 44 ++- tools/compendium_gen.py | 5 +- 14 files changed, 540 insertions(+), 40 deletions(-) create mode 100644 game/tests/unit/data_config/elite_row_test.gd diff --git a/game/core/config_db_cross_validator.gd b/game/core/config_db_cross_validator.gd index 0ca4c90d..9b1e3a53 100644 --- a/game/core/config_db_cross_validator.gd +++ b/game/core/config_db_cross_validator.gd @@ -191,7 +191,7 @@ func validate_skill_pool_links(skill_table: SkillTable, enemy_table: EnemyTable, for row in enemy_table.rows: var eid: String = String(row.get("enemy_id", "")) var pool_n := {"basic": 0, "buff": 0, "debuff": 0, "uncond_special": 0} - for entry in row.get("pool", []): + for entry in enemy_table.effective_pool(row): # 合并池(elite=基础池+附加技;battle/boss=自身 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]}) diff --git a/game/core/config_db_resolver.gd b/game/core/config_db_resolver.gd index f8fb164b..db2ef49e 100644 --- a/game/core/config_db_resolver.gd +++ b/game/core/config_db_resolver.gd @@ -38,7 +38,7 @@ func resolve_enemy_rowset(L: int, D: int, row_kind: String) -> Array[Dictionary] if L < 1 or D < 0: _diag.call("fatal", "request_oob", "enemy 请求越域 (%d,%d)——进度坐标低于合法域(关卡序号≥1、节点深度≥0)" % [L, D]) return [] - if not EnemyTable.ROW_KINDS.has(row_kind): + if not EnemyTable.ENCOUNTER_ROW_KINDS.has(row_kind): # 开战寻址行类门——elite 行静置合法、任何寻址拿不到它(敌人系统.md:151) _diag.call("fatal", "request_oob", "enemy 请求行类非法 (%d,%d,\"%s\")——row_kind 域 {battle, boss}" % [L, D, row_kind]) return [] var rows: Array[Dictionary] = [] diff --git a/game/data/schemas/enemy_table.gd b/game/data/schemas/enemy_table.gd index f99d9ab4..0f944f8c 100644 --- a/game/data/schemas/enemy_table.gd +++ b/game/data/schemas/enemy_table.gd @@ -8,8 +8,10 @@ extends Resource @export var rows: Array[Dictionary] = [] # Array[Dictionary] -# 行类封闭枚举(对齐关卡节点 type 词表):battle = 战斗节点配池,boss = 终点行集 -const ROW_KINDS := ["battle", "boss"] +# 行类封闭枚举:表内允许出现的行类(battle/boss 对齐关卡节点 type 词表,elite=精英行) +const ROW_KINDS := ["battle", "boss", "elite"] +# 开战寻址可读的行类——elite 接线前不在此列(敌人系统 v1.9 暂不接线;resolver 行类门单点消费) +const ENCOUNTER_ROW_KINDS := ["battle", "boss"] # 护甲类型封闭枚举(克制矩阵本体在 SettlementTable; # 语义键 {light, ward, heavy} = 轻甲 / 魔法护甲 / 重甲) @@ -49,6 +51,7 @@ func validate() -> Array: 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_elite_fields(row, eid2)) errs.append_array(_validate_row_types(row, eid2)) # 行集构成纪律②(行序即契约,敌人系统 v0.15):同带键多行必须 # 文件内相邻。迭代中记录上一行行集键;当前行集键曾出现过但 ≠ 上一行行集键 = 同带键行被隔断,报错。 @@ -67,6 +70,7 @@ func validate() -> Array: boss_ok[L2] = true if not anchor_ok.has(L2): anchor_ok[L2] = false + errs.append_array(_validate_elite_refs()) errs.append_array(_validate_anchor_rows(anchor_ok, boss_ok)) return errs @@ -85,7 +89,7 @@ func _validate_row_base(row: Dictionary, seen: Dictionary) -> Array[Dictionary]: 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]}) + errs.append({"category": "domain", "message": "%s row_kind 未知枚举: \"%s\"(域 {battle, boss, elite})" % [eid, rk]}) if eid == "": errs.append({"category": "domain", "message": "enemy_id 空"}) if String(row.get("display_name", "")) == "": @@ -136,25 +140,99 @@ func _validate_row_atk_range(row: Dictionary, eid: String) -> Array[Dictionary]: ## 非空池 + 条目 {skill_id 非空, weight > 0} + 池内 skill_id 不重复。 ## 普攻冷却恒 0(SkillTable schema 锁死)→ 可摇集合恒非空,"空池不可能"由此结构保证。 func _validate_row_pool(row: Dictionary, eid: String) -> Array[Dictionary]: + var no_errs: Array[Dictionary] = [] + if String(row.get("row_kind", "")) == "elite": + return no_errs # elite 行不带 pool 键(pool_extra 承载附加技——条目域校验随 _validate_elite_fields) var errs: Array[Dictionary] = [] var pool: Array[Variant] = row.get("pool", []) if pool.is_empty(): errs.append({"category": "domain", "message": "%s pool 空池(禁——技能池行必带池模板)" % eid}) + errs.append_array(_validate_pool_entries(pool, eid, "pool")) + return errs + + +## 池条目域共用校验(pool 与 pool_extra 共用——权重上限 9999 不出现第二份,契约 3 单点); +## field 承载报错定位段("pool"=既有文案逐字不变,"pool_extra"=附加技清单定位)。 +func _validate_pool_entries(entries: Array, eid: String, field: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] var pool_ids := {} - for entry_v in pool: + for entry_v in entries: if not (entry_v is Dictionary): - errs.append({"category": "domain", "message": "%s pool 条目非字典: %s" % [eid, str(entry_v)]}) + errs.append({"category": "domain", "message": "%s %s 条目非字典: %s" % [eid, field, 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}) + errs.append({"category": "domain", "message": "%s %s 条目 skill_id 空" % [eid, field]}) elif pool_ids.has(sid): - errs.append({"category": "dup_key", "message": "%s pool 内 skill_id 重复: %s" % [eid, sid]}) + errs.append({"category": "dup_key", "message": "%s %s 内 skill_id 重复: %s" % [eid, field, 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]}) + errs.append({"category": "domain", "message": "%s %s 权重越域 (0,9999]: %d" % [eid, field, w]}) + return errs + + +## 精英行字段互锁(敌人系统 v1.9 :281/:282/:283):elite 行必备 base_ref/pool_extra、禁 pool、 +## enemy_id 不与 base_ref 同名;非 elite 行禁带 base_ref/pool_extra。base_ref 引用存在性归 _validate_elite_refs。 +func _validate_elite_fields(row: Dictionary, eid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + if String(row.get("row_kind", "")) == "elite": + var base_ref := String(row.get("base_ref", "")) + if base_ref == "": + errs.append({"category": "cross_field", "message": "%s elite 行 base_ref 缺或空(基础敌种引用必带)" % eid}) + if base_ref == eid: + errs.append({"category": "cross_field", "message": "%s elite 行 base_ref 与自身 enemy_id 同名(禁)" % eid}) + if not row.has("pool_extra") or (row.get("pool_extra") as Array).is_empty(): + errs.append({"category": "cross_field", "message": "%s elite 行 pool_extra 缺键或空(附加技清单非空必带)" % eid}) + if row.has("pool"): + errs.append({"category": "cross_field", "message": "%s elite 行带 pool 键(禁——池走继承+附加)" % eid}) + if row.has("pool_extra") and (row.get("pool_extra") as Array).size() > 0: + errs.append_array(_validate_pool_entries(row.get("pool_extra"), eid, "pool_extra")) + else: + if row.has("base_ref"): + errs.append({"category": "cross_field", "message": "%s 非 elite 行带 base_ref 键(禁——基础敌种引用为 elite 行专属)" % eid}) + if row.has("pool_extra"): + errs.append({"category": "cross_field", "message": "%s 非 elite 行带 pool_extra 键(禁——附加技清单为 elite 行专属)" % eid}) + return errs + + +## 精英行跨行引用校验(敌人系统 v1.9 :148/:282):base_ref 指向同关 battle 行类敌种(缺/空已由 +## _validate_elite_fields 报——此处跳过);指向 elite 行判别同门 _min_band_row;附加技与基础池 +## skill_id 交集非空报 dup_key(敌人系统.md:149 附加技与基础池重复拒绝)。 +func _validate_elite_refs() -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + for row in rows: + if String(row.get("row_kind", "")) != "elite": + continue + var base_ref := String(row.get("base_ref", "")) + if base_ref == "": + continue + var level := int(row.get("level_index", 0)) + var eid := String(row.get("enemy_id", "")) + var base := _min_band_row(level, base_ref, "battle") + if base.is_empty(): + if not _min_band_row(level, base_ref, "elite").is_empty(): + errs.append({"category": "dangling_base", "message": "%s base_ref 指向 elite 行: %s(基础敌种须为 battle 行类)" % [eid, base_ref]}) + else: + errs.append({"category": "dangling_base", "message": "%s base_ref 同关 battle 行类无此敌种: %s" % [eid, base_ref]}) + continue + errs.append_array(_extra_overlap_errs(row, base, eid)) + return errs + + +## 附加技与基础池重叠判(_validate_elite_refs 交集段提出——分支预算内聚) +func _extra_overlap_errs(row: Dictionary, base_row: Dictionary, eid: String) -> Array[Dictionary]: + var errs: Array[Dictionary] = [] + var base_ids := {} + for entry_v in base_row.get("pool", []): + if entry_v is Dictionary: + base_ids[String(entry_v.get("skill_id", ""))] = true + for entry_v in row.get("pool_extra", []): + if entry_v is Dictionary: + var sid := String(entry_v.get("skill_id", "")) + if sid != "" and base_ids.has(sid): + errs.append({"category": "dup_key", "message": "%s pool_extra 与基础池 skill_id 重复: %s" % [eid, sid]}) return errs @@ -193,6 +271,43 @@ func _validate_anchor_rows(anchor_ok: Dictionary, boss_ok: Dictionary) -> Array[ return errs +# 基础行查找单点(按 (关, 敌种, 行类) 取深度带最小行——精英有效池与「指向 elite 判别」共用, +# 禁第二份查找算法):无匹配返回空字典。行字段一律 row.get 读取(夹具行可缺键)。 +func _min_band_row(level: int, eid: String, kind: String) -> Dictionary: + var best := {} + var best_band := -1 + for row in rows: + if int(row.get("level_index", -1)) != level or String(row.get("row_kind", "")) != kind \ + or String(row.get("enemy_id", "")) != eid: + continue + var band := int(row.get("depth_band", -1)) + if best.is_empty() or band < best_band: + best = row + best_band = band + return best + + +## 精英有效池(按需现算、零缓存零写回——敌人系统.md:149 继承不复制):非 elite 行 = 自身 pool +## 字典条目副本;elite 行 = 基础行(同关 battle 行类基础敌种深度带最小行)pool 条目 + 本行 +## pool_extra 条目(均只取 is Dictionary 条目并 duplicate——返回值改写不回灌表内行)。 +func effective_pool(row: Dictionary) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + if String(row.get("row_kind", "")) != "elite": + for entry_v in row.get("pool", []): + if entry_v is Dictionary: + out.append((entry_v as Dictionary).duplicate()) + return out + var base := _min_band_row(int(row.get("level_index", 0)), String(row.get("base_ref", "")), "battle") + if not base.is_empty(): + for entry_v in base.get("pool", []): + if entry_v is Dictionary: + out.append((entry_v as Dictionary).duplicate()) + for entry_v in row.get("pool_extra", []): + if entry_v is Dictionary: + out.append((entry_v as Dictionary).duplicate()) + return out + + # 深度带切法单点(唯一实现,禁第二份算法):band_keys = 该 (level_index, row_kind) 的 # depth_band 键升序数组;返回首个 ≥ D 的键(请求落带取偏难一侧)。 # D 高于全部键 → 返回 -1——resolver 侧 clamp 顶带+warning,跨表校验侧判「顶 clamp 消解」违例; diff --git a/game/data/tables/enemy.tres b/game/data/tables/enemy.tres index b27a62ad..d3a2ab18 100644 --- a/game/data/tables/enemy.tres +++ b/game/data/tables/enemy.tres @@ -207,6 +207,25 @@ rows = [{ "armor_type": "light", "armor_class": 8 }, { +"level_index": 1, +"row_kind": "elite", +"depth_band": 0, +"enemy_id": "goblin_elite", + "display_name": "精英·地精", + "unit_count": 1, + "hp": 20, + "atk": 2, + "atk_min": 1, + "atk_max": 3, +"base_ref": "goblin", +"pool_extra": [{ +"skill_id": "heavy", +"weight": 2 +}], +"attack_type": "normal", +"armor_type": "light", +"armor_class": 2 +}, { "level_index": 2, "row_kind": "battle", "depth_band": 0, @@ -943,6 +962,7 @@ rows = [{ ; 不做差异化(pierce/magic 属新设计另立项)。全局平移效应=主角穿重甲受伤 −1 / 魔法护甲 +1 / 轻甲 0, ; 锻造甲路线首次具备战斗意义;克制矩阵数值零触碰(settlement.tres armor_effectiveness 不改)。 ; -; 行键 = (level_index, row_kind, depth_band, enemy_id);row_kind ∈ {battle, boss}——battle=战斗节点配池,boss=终点行集; +; 行键 = (level_index, row_kind, depth_band, enemy_id);row_kind ∈ {battle, boss, elite}——battle=战斗节点配池,boss=终点行集,elite=精英行(独立 enemy_id、池走继承+附加,暂不接线任何节点寻址); ; depth_band=节点深度所在的带键,请求落带取首个 ≥ 深度的键(偏难一侧);越顶带由 resolver clamp、跨表校验判为调参事故。 ; (1,3) 大地精行 2026-11-04 用户裁退役,改地精+熊地精混编(行序熊地精在前、行值整行复用 (1,2)/(1,0)、静态骰和 3);沿革见 git 提交史 +; 精英专属池批首只精英=地精精英(1,elite,0,goblin_elite),行值 provisional(投值所有权归难度曲线与Boss 统一修正),来源=2026-11-06 用户点头草案 v0 diff --git a/game/sim/compendium_dump.gd b/game/sim/compendium_dump.gd index c30c0206..de52c0f5 100644 --- a/game/sim/compendium_dump.gd +++ b/game/sim/compendium_dump.gd @@ -3,8 +3,9 @@ ## 运行:Godot_console.exe --headless --path . --script res://sim/compendium_dump.gd -- [out=<绝对路径>] [tables_root=] ## out 缺省 = 仓库根 sim-data/compendium/dump.json(res:// 的上一级);目录不存在则创建。 ## tables_root 缺省 = res://data/tables;fail-fast 路径验证时可指向临时表目录。 -## 导出形态:顶层 counts 带两表行数计数 + enemy_rows/skill_rows 行字典原样(零新增字段、零改名; -## 表内可省略字段缺席即缺席)。本件零随机、零数值硬编码、零判定逻辑。 +## 导出形态:顶层 counts 带两表行数计数 + enemy_rows/skill_rows 行字典原样,elite 行另补合并后的 +## pool 供展示(表内行零改写;battle/boss 行=自身 pool 原样;表内可省略字段缺席即缺席)。 +## 本件零随机、零数值硬编码、零判定逻辑。 ## fail-fast:表加载失败(缺表/行域校验/跨表悬空引用)、行缺关键字段、pool.skill_id 在技能表无对应行 ## → 逐条报错并以退出码 1 中止,禁软回退。 extends SceneTree @@ -29,15 +30,23 @@ func _initialize() -> void: return var enemy_rows: Array = db.combat.enemy.rows var skill_rows: Array = db.combat.skill.rows - var errs := _validate_rows(enemy_rows, skill_rows) + # 导出行数组:逐行 duplicate——elite 行副本写合并后的 pool(按需现算,表内行不被改写); + # battle/boss 行=自身 pool 原样(读法等价面) + var export_enemy_rows: Array = [] + for row_v in enemy_rows: + var row: Dictionary = (row_v as Dictionary).duplicate() + if String(row.get("row_kind", "")) == "elite": + row["pool"] = db.combat.enemy.effective_pool(row_v) + export_enemy_rows.append(row) + var errs := _validate_rows(export_enemy_rows, skill_rows) if not errs.is_empty(): for e in errs: push_error("compendium_dump: %s" % e) quit(1) return var payload := { - "counts": {"enemy_rows": enemy_rows.size(), "skill_rows": skill_rows.size()}, - "enemy_rows": enemy_rows, + "counts": {"enemy_rows": export_enemy_rows.size(), "skill_rows": skill_rows.size()}, + "enemy_rows": export_enemy_rows, "skill_rows": skill_rows, } var out_path := String(opt.out) diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 02b76ac2..4c333a4e 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -179,6 +179,7 @@ func _suites() -> Array: {"path": "res://tests/unit/core/run_time_fatal_test.gd", "expect": 1, "negative": true}, # 卡② wr5/wr6/wr7 运行期违约通道(fatal 封印传导/未知标签/未播种) {"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0}, {"path": "res://tests/unit/data_config/table_structure_guard_test.gd", "expect": 0}, # 表结构守卫宿主(adventure 配额与同调目录/enemy 带键行序/AC 单轨与掷点参数/variation 门控互锁/difficulty 修饰/reward 九型/meta 行族) + {"path": "res://tests/unit/data_config/elite_row_test.gd", "expect": 0}, # 精英行校验/有效池合并/继承不复制/合并池纪律/暂不接线(精英专属池批) {"path": "res://tests/unit/data_config/rng_stream_boot_test.gd", "expect": 0}, # 启动初始化与统一 RNG 流宿主(data/tables 加载链/统一流黄金向量 m=42/derive_at 恒等/open_stream 取号序/new_adventure 计数器重置——批 B 自 smoke_all 迁入;源 :70 事件日志落盘条按裁决一转办跨系统闭环宿主) {"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 秘宝结构冻结断言面 {"path": "res://tests/unit/core/node_map_generator_test.gd", "expect": 0}, # 节点图生成器产出合格/可复现/岔路取舍/恰一店/归属锁定/战斗保底/出口结构/RNG 纯流面(卡① V1·V3·V5–V7·V9·V10) diff --git a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd index de318317..a0cbe34a 100644 --- a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd +++ b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd @@ -527,7 +527,7 @@ func _smoke_skill_pool_data() -> void: and is_equal_approx(float(venom.rider_mult), 0.75)) var venom_sole_ok := true for row in _db.combat.enemy.rows: - for entry in row.pool: + for entry in _db.combat.enemy.effective_pool(row): if String(entry.skill_id) == "venom_burst" and String(row.enemy_id) != "drider": venom_sole_ok = false Kit.check("毒爆独占性(仅蛛化卓尔精灵池引用——Boss 专属池独占技纪律)", venom_sole_ok) @@ -538,7 +538,7 @@ func _smoke_skill_pool_data() -> void: and int(snare.buff_duration) == 2 and int(snare.cooldown_beats) == 4) var snare_sole_ok := true for row in _db.combat.enemy.rows: - for entry in row.pool: + for entry in _db.combat.enemy.effective_pool(row): if String(entry.skill_id) == "web_snare" and String(row.enemy_id) != "drider": snare_sole_ok = false Kit.check("蛛网束缚独占性(仅蛛化卓尔精灵池引用——实装面收窄,其余敌种不动)", snare_sole_ok) @@ -557,7 +557,7 @@ func _smoke_skill_pool_data() -> void: var spit_sole_ok := true # 毒吐只挂巨蜘蛛族且首遇带不投(Q6——教学位池零新招) var bash_sole_ok := true # 盾击只挂灰矮人族三行 for row in _db.combat.enemy.rows: - for entry in row.pool: + for entry in _db.combat.enemy.effective_pool(row): var sid := String(entry.skill_id) if sid == "venom_spit" and not (String(row.enemy_id) == "giant_spider" and int(row.level_index) == 2 \ and int(row.depth_band) > spider_lead_band): @@ -608,6 +608,8 @@ func _smoke_skill_pool_data() -> void: "3_boss_11_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, "3_boss_12_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, "3_boss_13_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, + # 精英专属池批:首只精英=地精精英(有效池=基础池 goblin 9/1/1+附加技 heavy 2——expect_pools 比对读 effective_pool) + "1_elite_0_goblin_elite": {"attack": 9, "lunge": 1, "desperate_guard": 1, "heavy": 2}, } var pools_ok := true for row in _db.combat.enemy.rows: @@ -616,15 +618,15 @@ func _smoke_skill_pool_data() -> void: pools_ok = false continue var got := {} - for entry in row.pool: + for entry in _db.combat.enemy.effective_pool(row): got[String(entry.skill_id)] = int(entry.weight) if got != expect_pools[rk]: pools_ok = false - Kit.check("二十九行十种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1/1〔触发条件批+孤注守势 w1〕 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1/2〔触发条件批+趁隙偷袭 w2〕 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2——半血解锁收编技能行 trigger 闩型——迭代14④ T5/触发条件批迁移〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕;迭代 35 覆盖批八行为顶档池克隆,逐行同检)", pools_ok) + Kit.check("三十行十一种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1/1〔触发条件批+孤注守势 w1〕 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1/2〔触发条件批+趁隙偷袭 w2〕 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2——半血解锁收编技能行 trigger 闩型——迭代14④ T5/触发条件批迁移〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕;迭代 35 覆盖批八行为顶档池克隆,逐行同检)", pools_ok) var basic_count_ok := true for row in _db.combat.enemy.rows: var n := 0 - for entry in row.pool: + for entry in _db.combat.enemy.effective_pool(row): var srow: Dictionary = _db.combat.skill.row_by_id(String(entry.skill_id)) if not srow.is_empty() and String(srow.type) == "basic": n += 1 @@ -637,7 +639,7 @@ func _smoke_skill_pool_data() -> void: for row in _db.combat.enemy.rows: var n_buff := 0 var n_debuff := 0 - for entry in row.pool: + for entry in _db.combat.enemy.effective_pool(row): var srow: Dictionary = _db.combat.skill.row_by_id(String(entry.skill_id)) if srow.is_empty(): continue @@ -652,7 +654,7 @@ func _smoke_skill_pool_data() -> void: var uncond_ok := true for row in _db.combat.enemy.rows: var n_uncond := 0 - for entry in row.pool: + for entry in _db.combat.enemy.effective_pool(row): var srow_u: Dictionary = _db.combat.skill.row_by_id(String(entry.skill_id)) if not srow_u.is_empty() and String(srow_u.type) != "basic" and not srow_u.has("trigger"): n_uncond += 1 diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index 6d7eb724..dd1ade26 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -711,6 +711,7 @@ func _smoke_level_graph() -> void: # ⑫ 混编行集运行面正反断言:注入带键 (1,battle,5)/(1,battle,6) = # L1 节点图与全部既有扫描面之外的新带键(带命中精确取注入行集——新 L 会被 ceil 封顶到 L3 不可用), # 断言后立即还原表,后续断言面零污染。R3 fail-fast 正反验证 + R6 行序运行面验证。 + var base_n: int = _db.combat.enemy.rows.size() # 注入前基线行数(生产表行数不写死——精英行随批入表) var mix_row_a := {"level_index": 1, "row_kind": "battle", "depth_band": 5, "enemy_id": "goblin", "display_name": "地精", "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]} var mix_row_b := {"level_index": 1, "row_kind": "battle", "depth_band": 5, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 2, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]} _db.combat.enemy.rows.append(mix_row_a) @@ -729,7 +730,7 @@ func _smoke_level_graph() -> void: _db.combat.enemy.rows.remove_at(_db.combat.enemy.rows.size() - 1) _db.combat.enemy.rows.remove_at(_db.combat.enemy.rows.size() - 1) _db.combat.enemy.rows.remove_at(_db.combat.enemy.rows.size() - 1) - Kit.check("混编断言段注入行全量还原(表回 29 行——L1 混编批后的基线行数;首批混编投放基线 21 行+覆盖 8 行)", int(_db.combat.enemy.rows.size()) == 29) + Kit.check("混编断言段注入行全量还原(表回注入前基线行数 %d——相对基数断言,生产表行数不写死)" % base_n, int(_db.combat.enemy.rows.size()) == base_n) # ⑬ 真图类型可见性(信息迷雾 v2.1):L2 入口停点纯核心面读数——候选 ⊆ 可见集 / # 终点恒可见 / 入口 BFS 层距 > N 的非豁免节点入雾。N 取数据表(代码零硬编码)。 diff --git a/game/tests/unit/data_config/config_db_test.gd b/game/tests/unit/data_config/config_db_test.gd index 670dc200..058bbc43 100644 --- a/game/tests/unit/data_config/config_db_test.gd +++ b/game/tests/unit/data_config/config_db_test.gd @@ -256,16 +256,16 @@ func _test_rowset_discipline() -> void: if String(e.get("category", "")) == "domain" and String(e.get("message", "")).contains("unit_count"): dom_hit = true Kit.check("单行数量域 [1,3] 域闸不回归(UNIT_COUNT_MAX 语义 = 单种数)", dom_hit) - # 负向:row_kind 封闭枚举(非法值拒——键面换代后新增判据) + # 负向:row_kind 封闭枚举(非法值拒——键面换代后新增判据;elite 随精英专属池批转合法,非法样本换 miniboss) var t_kind = EnemyTableScript.new() - t_kind.rows.assign([mk_row.call(1, "elite", 5, "goblin"), mk_row.call(1, "battle", 0, "goblin"), + t_kind.rows.assign([mk_row.call(1, "miniboss", 5, "goblin"), mk_row.call(1, "battle", 0, "goblin"), mk_row.call(1, "boss", 9, "ogre_chief")]) errs = t_kind.validate() var kind_hit := false for e in errs: if String(e.get("category", "")) == "domain" and String(e.get("message", "")).contains("row_kind"): kind_hit = true - Kit.check("非法 row_kind 被拒(封闭枚举 {battle, boss})〔fv7〕", kind_hit) + Kit.check("非法 row_kind 被拒(封闭枚举 {battle, boss, elite})〔fv7〕", kind_hit) # 负向:anchor 两半——缺 battle band 0 锚行、缺 Boss 行集,各自报 anchor var t_no_anchor = EnemyTableScript.new() t_no_anchor.rows.assign([mk_row.call(1, "battle", 5, "goblin"), mk_row.call(1, "boss", 9, "ogre_chief")]) diff --git a/game/tests/unit/data_config/elite_row_test.gd b/game/tests/unit/data_config/elite_row_test.gd new file mode 100644 index 00000000..b915df38 --- /dev/null +++ b/game/tests/unit/data_config/elite_row_test.gd @@ -0,0 +1,305 @@ +# tests/unit/data_config/elite_row_test.gd — 精英行单元套(精英专属池批) +# 断言面 = 精英行加载期校验(字段互锁/引用校验/错误类别)·有效池合并(基础池取深度带最小行 +# +附加技)·继承不复制(改基础池传到精英、改写返回不回灌)·合并池过池构成纪律(跨表件)· +# 暂不接线(寻址行类门只放 battle/boss,elite 行静置合法)·生产表 goblin_elite 行(随 T3 落表追加)。 +# 断言从公开入口进出:EnemyTable.validate()/effective_pool()/validate_skill_pool_links()/ +# resolve_enemy_rowset()/ConfigDB.load_tables()。夹具一律 rows.assign([...])(定型数组直赋会触发 +# 脚本错误静默中断——config_db_test 同款坑注)。 +# 运行:godot --headless --path game --script res://tests/unit/data_config/elite_row_test.gd(expect 0) +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const EnemyTableScript := preload("res://data/schemas/enemy_table.gd") +const ValidatorScript := preload("res://core/config_db_cross_validator.gd") +const SkillTableScript := preload("res://data/schemas/skill_table.gd") +const StatusTableScript := preload("res://data/schemas/status_effect_table.gd") +const ResolverScript := preload("res://core/config_db_resolver.gd") + +var _diags: Array[String] = [] # T2.4 夹具 resolver 诊断捕获 + + +func _init() -> void: + _test_elite_validation_rejects() + _test_effective_pool_merge() + _test_base_pool_change_propagates() + _test_merged_pool_composition() + _test_elite_unreachable_by_encounter() + _test_production_goblin_elite() + quit(Kit.finish("elite_row_test", "PASS — 精英行校验/有效池/继承不复制/合并池纪律/暂不接线/生产表落行 全过")) + + +# 行构造助手(照 config_db_test mk_row 写全字段;elite 行形态=去掉 pool、加 base_ref/pool_extra) +func _mk_row(l: int, kind: String, band: int, eid: String) -> Dictionary: + return {"level_index": l, "row_kind": kind, "depth_band": band, "enemy_id": eid, "display_name": eid + "名", + "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]} + + +func _mk_elite_row(l: int, band: int, eid: String, base_ref: String, extra: Array) -> Dictionary: + return {"level_index": l, "row_kind": "elite", "depth_band": band, "enemy_id": eid, "display_name": eid + "名", + "unit_count": 1, "hp": 20, "atk": 2, "base_ref": base_ref, "pool_extra": extra} + + +# 合法夹具(三行——battle 锚/boss/elite 引用同关 battle 敌种) +func _legal_rows() -> Array: + var goblin := _mk_row(1, "battle", 0, "goblin") + goblin["pool"] = [{"skill_id": "attack", "weight": 9}, {"skill_id": "lunge", "weight": 1}] + return [goblin, _mk_row(1, "boss", 9, "ogre_chief"), + _mk_elite_row(1, 0, "goblin_elite", "goblin", [{"skill_id": "heavy", "weight": 2}])] + + +# category 计数(错误列表按类别筛) +func _count_cat(errs: Array, cat: String) -> int: + var n := 0 + for e in errs: + if String((e as Dictionary).get("category", "")) == cat: + n += 1 + return n + + +# ---- R1:elite 行类合法与字段互锁、引用校验(合法零错+十一违例各自「含」预期类别)---- +func _test_elite_validation_rejects() -> void: +# oracle: design/gdd/systems/战斗循环/敌人系统.md:325 elite 行字段互锁与基础引用违例加载期返回错误 + var t_ok = EnemyTableScript.new() + t_ok.rows.assign(_legal_rows()) + var errs: Array = t_ok.validate() + Kit.check("合法夹具 validate 零错(battle 锚+boss+elite 引用在位——实得 %s)" % str(errs), errs.is_empty()) + # ① elite 删 base_ref → cross_field + var rows1 := _legal_rows() + rows1[2].erase("base_ref") + Kit.check("① elite 删 base_ref 报 cross_field〔精英批 V1〕", _count_cat(_validate_rows(rows1), "cross_field") >= 1) + # ② base_ref 与自身同名 → cross_field + var rows2 := _legal_rows() + rows2[2]["base_ref"] = "goblin_elite" + Kit.check("② base_ref 与 enemy_id 同名报 cross_field〔精英批 V1〕", _count_cat(_validate_rows(rows2), "cross_field") >= 1) + # ③ pool_extra 为空数组 → cross_field + var rows3 := _legal_rows() + rows3[2]["pool_extra"] = [] + Kit.check("③ pool_extra 空数组报 cross_field〔精英批 V1〕", _count_cat(_validate_rows(rows3), "cross_field") >= 1) + # ④ elite 删 pool_extra 键 → cross_field + var rows4 := _legal_rows() + rows4[2].erase("pool_extra") + Kit.check("④ elite 删 pool_extra 键报 cross_field〔精英批 V1〕", _count_cat(_validate_rows(rows4), "cross_field") >= 1) + # ⑤ elite 加 pool → cross_field + var rows5 := _legal_rows() + rows5[2]["pool"] = [{"skill_id": "attack", "weight": 9}] + Kit.check("⑤ elite 带 pool 报 cross_field〔精英批 V1〕", _count_cat(_validate_rows(rows5), "cross_field") >= 1) + # ⑥ battle 行加 base_ref → cross_field + var rows6 := _legal_rows() + rows6[0]["base_ref"] = "ogre_chief" + Kit.check("⑥ battle 行带 base_ref 报 cross_field〔精英批 V1〕", _count_cat(_validate_rows(rows6), "cross_field") >= 1) + # ⑦ battle 行加 pool_extra → cross_field + var rows7 := _legal_rows() + rows7[0]["pool_extra"] = [{"skill_id": "heavy", "weight": 2}] + Kit.check("⑦ battle 行带 pool_extra 报 cross_field〔精英批 V1〕", _count_cat(_validate_rows(rows7), "cross_field") >= 1) + # ⑧ base_ref="hobgoblin"(同关无此 battle 敌种)→ dangling_base + var rows8 := _legal_rows() + rows8[2]["base_ref"] = "hobgoblin" + Kit.check("⑧ base_ref 悬空报 dangling_base〔精英批 V1〕", _count_cat(_validate_rows(rows8), "dangling_base") >= 1) + # ⑨ 再加 elite2 行、base_ref 改指它 → dangling_base 且 message 含「elite」 + var rows9 := _legal_rows() + rows9.append(_mk_elite_row(1, 0, "goblin_elite2", "goblin", [{"skill_id": "heavy", "weight": 2}])) + rows9[2]["base_ref"] = "goblin_elite2" + var errs9: Array = _validate_rows(rows9) + var has_elite_base := false + for e in errs9: + if String((e as Dictionary).get("category", "")) == "dangling_base" \ + and String((e as Dictionary).get("message", "")).contains("elite"): + has_elite_base = true + Kit.check("⑨ base_ref 指向 elite 行报 dangling_base 且 message 含 elite〔精英批 V1〕", has_elite_base) + # ⑩ pool_extra 含基础池已有 lunge → dup_key + var rows10 := _legal_rows() + rows10[2]["pool_extra"] = [{"skill_id": "heavy", "weight": 2}, {"skill_id": "lunge", "weight": 1}] + Kit.check("⑩ 附加技与基础池重复报 dup_key〔精英批 V1〕", _count_cat(_validate_rows(rows10), "dup_key") >= 1) + # ⑪ pool_extra 条目权重 0 → domain 且 message 含 pool_extra + var rows11 := _legal_rows() + rows11[2]["pool_extra"] = [{"skill_id": "heavy", "weight": 0}] + var errs11: Array = _validate_rows(rows11) + var has_extra_domain := false + for e in errs11: + if String((e as Dictionary).get("category", "")) == "domain" \ + and String((e as Dictionary).get("message", "")).contains("pool_extra"): + has_extra_domain = true + Kit.check("⑪ pool_extra 权重越域报 domain 且定位含 pool_extra〔精英批 V1〕", has_extra_domain) + + +func _validate_rows(rows: Array) -> Array: + var t = EnemyTableScript.new() + t.rows.assign(rows) + return t.validate() + + +# ---- R2:有效池=基础池+附加技,基础池取深度带最小行(深带行在前防取首行假绿)---- +func _test_effective_pool_merge() -> void: +# oracle: design/gdd/systems/战斗循环/敌人系统.md:149 精英有效池=同关战斗行类基础敌种深度带最小行的池+附加技 + var rows := [ + _mk_row(1, "battle", 3, "goblin"), # 深带行在前(取首行的错误实现必红) + _mk_row(1, "battle", 0, "goblin"), + _mk_row(1, "boss", 9, "ogre_chief"), + _mk_elite_row(1, 0, "goblin_elite", "goblin", [{"skill_id": "heavy", "weight": 2}]), + ] + rows[0]["pool"] = [{"skill_id": "attack", "weight": 5}, {"skill_id": "charge", "weight": 2}] + rows[1]["pool"] = [{"skill_id": "attack", "weight": 9}, {"skill_id": "lunge", "weight": 1}] + var t = EnemyTableScript.new() + t.rows.assign(rows) + var got := {} + for e in t.effective_pool(rows[3]): + got[String(e.get("skill_id", ""))] = int(e.get("weight", 0)) + Kit.check("精英有效池=浅带基础池+附加技({attack9,lunge1,heavy2}——非深带行池)〔精英批 V2〕", + got.get("attack", 0) == 9 and got.get("lunge", 0) == 1 and got.get("heavy", 0) == 2 and got.size() == 3) + var got_b0 := {} + for e in t.effective_pool(rows[1]): + got_b0[String(e.get("skill_id", ""))] = int(e.get("weight", 0)) + Kit.check("battle 行有效池=自身 pool(读法等价面——{attack9,lunge1})〔精英批 V2〕", + got_b0.get("attack", 0) == 9 and got_b0.get("lunge", 0) == 1 and got_b0.size() == 2) + + +# ---- R3:继承不复制——基础池改表传到精英、改写返回数组不回灌 ---- +func _test_base_pool_change_propagates() -> void: +# oracle: design/gdd/systems/战斗循环/敌人系统.md:149 继承不复制——基础池调参自动传到精英 + var rows := [ + _mk_row(1, "battle", 3, "goblin"), + _mk_row(1, "battle", 0, "goblin"), + _mk_row(1, "boss", 9, "ogre_chief"), + _mk_elite_row(1, 0, "goblin_elite", "goblin", [{"skill_id": "heavy", "weight": 2}]), + ] + rows[0]["pool"] = [{"skill_id": "attack", "weight": 5}, {"skill_id": "charge", "weight": 2}] + rows[1]["pool"] = [{"skill_id": "attack", "weight": 9}, {"skill_id": "lunge", "weight": 1}] + var t = EnemyTableScript.new() + t.rows.assign(rows) + var first: Array = t.effective_pool(rows[3]) + for e in first: + if String((e as Dictionary).get("skill_id", "")) == "attack": + e["weight"] = 99 # 改写返回数组条目——表内行不得被回灌 + rows[1]["pool"][0]["weight"] = 7 # 基础行(depth_band==0)attack 权重改 7 + var got := {} + for e in t.effective_pool(rows[3]): + got[String(e.get("skill_id", ""))] = int(e.get("weight", 0)) + Kit.check("基础池改权重 7 传到精英有效池(按需现算零缓存)〔精英批 V3〕", got.get("attack", 0) == 7) + Kit.check("改写返回数组不回灌表内行(基础行 attack 仍=7)〔精英批 V3〕", + int(rows[1]["pool"][0]["weight"]) == 7) + + +# ---- R4:合并池过池构成纪律(跨表件校验对象=合并后的池)---- +func _test_merged_pool_composition() -> void: +# oracle: design/gdd/systems/战斗循环/敌人系统.md:150 池构成纪律校验对象是合并后的池 + var skills := SkillTableScript.new() + skills.rows.assign([ + {"skill_id": "attack", "type": "basic"}, + {"skill_id": "lunge", "type": "damage"}, + {"skill_id": "heavy", "type": "damage"}, + {"skill_id": "harden", "type": "buff"}, + {"skill_id": "charge", "type": "buff"}, + ]) + var status := StatusTableScript.new() + status.rows.assign([]) # 空状态表——buff 无 buff_ref 可解析时 cast_side 不报(cross_validator :179-180) + # 合规精英:基础 attack/lunge+附加 heavy → 恰一普攻/增益≤1/无条件特殊≥1 全合规 + var enemy_ok = EnemyTableScript.new() + enemy_ok.rows.assign([ + _mk_row(1, "battle", 0, "goblin"), + _mk_row(1, "boss", 9, "ogre_chief"), + _mk_elite_row(1, 0, "goblin_elite", "goblin", [{"skill_id": "heavy", "weight": 2}]), + ]) + enemy_ok.rows[0]["pool"] = [{"skill_id": "attack", "weight": 9}, {"skill_id": "lunge", "weight": 1}] + enemy_ok.rows[1]["pool"] = [{"skill_id": "attack", "weight": 9}, {"skill_id": "lunge", "weight": 1}] # boss 行自带合规池(纪律对全行生效——本组只数精英违规) + var errs_ok: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemy_ok, status) + Kit.check("合规精英合并池 pool_composition=0(实得 %s)" % str(errs_ok), _count_cat(errs_ok, "pool_composition") == 0) + # 附加第二普攻:基础 attack+附加 attack → basic=2 → pool_composition=1 且 message 含 goblin_elite + var enemy_dup_basic = EnemyTableScript.new() + enemy_dup_basic.rows.assign([ + _mk_row(1, "battle", 0, "goblin"), + _mk_row(1, "boss", 9, "ogre_chief"), + _mk_elite_row(1, 0, "goblin_elite", "goblin", [{"skill_id": "attack", "weight": 2}]), + ]) + enemy_dup_basic.rows[0]["pool"] = [{"skill_id": "attack", "weight": 9}, {"skill_id": "lunge", "weight": 1}] + enemy_dup_basic.rows[1]["pool"] = [{"skill_id": "attack", "weight": 9}, {"skill_id": "lunge", "weight": 1}] # boss 行自带合规池 + var errs_dup: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemy_dup_basic, status) + Kit.check("附加第二普攻报 pool_composition=1 且 message 含 goblin_elite〔精英批 V4〕", + _count_cat(errs_dup, "pool_composition") == 1 and _msgs(errs_dup).any(func(m: String) -> bool: return m.contains("goblin_elite"))) + # 附加第二增益:基础 attack/lunge/harden+附加 charge → buff=2 → pool_composition=1 + var enemy_dup_buff = EnemyTableScript.new() + enemy_dup_buff.rows.assign([ + _mk_row(1, "battle", 0, "goblin"), + _mk_row(1, "boss", 9, "ogre_chief"), + _mk_elite_row(1, 0, "goblin_elite", "goblin", [{"skill_id": "charge", "weight": 2}]), + ]) + enemy_dup_buff.rows[0]["pool"] = [ + {"skill_id": "attack", "weight": 9}, {"skill_id": "lunge", "weight": 1}, {"skill_id": "harden", "weight": 2}] + enemy_dup_buff.rows[1]["pool"] = [{"skill_id": "attack", "weight": 9}, {"skill_id": "lunge", "weight": 1}] # boss 行自带合规池 + var errs_buff: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemy_dup_buff, status) + Kit.check("合并后增益超 1 报 pool_composition=1 且 message 含 goblin_elite〔精英批 V4〕", + _count_cat(errs_buff, "pool_composition") == 1 and _msgs(errs_buff).any(func(m: String) -> bool: return m.contains("goblin_elite"))) + + +func _msgs(errs: Array) -> Array[String]: + var out: Array[String] = [] + for e in errs: + out.append(String((e as Dictionary).get("message", ""))) + return out + + +# ---- R5:暂不接线——表里有 elite 行时寻址拿不到它(行类门改动特有判据)---- +func _test_elite_unreachable_by_encounter() -> void: +# oracle: design/gdd/systems/战斗循环/敌人系统.md:151 精英行接线前不会被任何节点读到 + _diags.clear() + var resolver = ResolverScript.new( + func(level, category, message) -> void: + _diags.append("%s/%s/%s" % [str(level), str(category), str(message)]), + func() -> Array: return [], + func() -> Array: return [ + _mk_row(1, "battle", 0, "goblin"), + _mk_row(1, "boss", 9, "ogre_chief"), + _mk_elite_row(1, 0, "goblin_elite", "goblin", [{"skill_id": "heavy", "weight": 2}]), + ], + func(): return null, + ) + # ① 请求 elite → 空数组且诊断含 fatal 与 request_oob(只改枚举不改行类门时此请求会返回 goblin_elite——判据绑行类门改动) + var hit: Array = resolver.resolve_enemy_rowset(1, 0, "elite") + var has_fatal_oob := false + for d in _diags: + if d.contains("fatal") and d.contains("request_oob"): + has_fatal_oob = true + Kit.check("请求 elite 返回空且诊断含 fatal/request_oob〔精英批 V5〕", hit.is_empty() and has_fatal_oob) + # ② 同夹具 battle/boss 查询返回行中 elite 计数=0(非空前置:合计行数>0) + var battle_rows: Array = resolver.resolve_enemy_rowset(1, 0, "battle") + var boss_rows: Array = resolver.resolve_enemy_rowset(1, 9, "boss") + var elite_n := 0 + for r in battle_rows + boss_rows: + if String((r as Dictionary).get("row_kind", "")) == "elite": + elite_n += 1 + Kit.check("battle/boss 查询合计>0 且零 elite 行(非空前置在判据内)〔精英批 V5〕", + battle_rows.size() + boss_rows.size() > 0 and elite_n == 0) + + +# ---- R6:生产表地精精英落表(真实加载零错+行值逐位+有效池字典)---- +func _test_production_goblin_elite() -> void: +# oracle: design/gdd/systems/战斗循环/敌人系统.md:147 精英=敌人表行类为 elite 的独立一行 + var db = load("res://core/config_db.gd").new() + var ok: bool = db.load_tables("res://data/tables", true) + Kit.check("生产表 ConfigDB.load_tables 装载(is_ready=%s)〔精英批 V6〕" % str(db.is_ready), ok and db.is_ready) + Kit.check("生产表加载 error 级诊断计数=0(实得 %d)〔精英批 V6〕" % db.diagnostics_count("error"), + db.diagnostics_count("error") == 0) + var elite_rows: Array = [] + for row in db.combat.enemy.rows: + if String(row.get("enemy_id", "")) == "goblin_elite": + elite_rows.append(row) + Kit.check("goblin_elite 行恰 1 条(实得 %d)〔精英批 V6〕" % elite_rows.size(), elite_rows.size() == 1) + if elite_rows.is_empty(): + return + var r: Dictionary = elite_rows[0] + var checks := { + "level_index": 1, "row_kind": "elite", "depth_band": 0, + "display_name": "精英·地精", "hp": 20, "atk": 2, "atk_min": 1, "atk_max": 3, + "armor_class": 2, "armor_type": "light", "unit_count": 1, "base_ref": "goblin", + } + var bad: Array[String] = [] + for k in checks: + if r.get(k) != checks[k]: + bad.append("%s=%s≠%s" % [k, str(r.get(k)), str(checks[k])]) + Kit.check("行值 12 字段逐位相等(失配项 %s)〔精英批 V6〕" % [bad if not bad.is_empty() else "无"], bad.is_empty()) + Kit.check("无 unit_count_max 键、无 pool 键(精英行形态面)〔精英批 V6〕", + not r.has("unit_count_max") and not r.has("pool")) + var got := {} + for e in db.combat.enemy.effective_pool(r): + got[String(e.get("skill_id", ""))] = int(e.get("weight", 0)) + Kit.check("有效池字典={attack9,lunge1,desperate_guard1,heavy2}(实得 %s)〔精英批 V6〕" % str(got), + got.get("attack", 0) == 9 and got.get("lunge", 0) == 1 and got.get("desperate_guard", 0) == 1 and got.get("heavy", 0) == 2 and got.size() == 4) diff --git a/game/tests/unit/data_config/table_structure_guard_test.gd b/game/tests/unit/data_config/table_structure_guard_test.gd index 91c09323..f7247ec6 100644 --- a/game/tests/unit/data_config/table_structure_guard_test.gd +++ b/game/tests/unit/data_config/table_structure_guard_test.gd @@ -1,7 +1,7 @@ # tests/unit/data_config/table_structure_guard_test.gd — 表结构守卫单元测试宿主 # 断言面 = data/tables 各表的结构锚与守卫,七套: # adventure 冒险表(档位配额/件值对/同调目录 12 件/装配→战斗贯通) -# enemy 敌人表(29 行带键行序/混编行集/三 Boss 行) +# enemy 敌人表(30 行带键行序/混编行集/三 Boss 行/精英行) # AC 单轨与掷点参数(status_effect/reward/settlement 三处无双轨/免伤墙护栏/敌 AC 带宽) # variation 地形变异(行数/候选池门控/跨字段互锁负样本) # difficulty 难度表(空表零行为/修饰套乘/越域 clamp) @@ -208,18 +208,18 @@ func _smoke_adventure_table() -> void: v18_ok and locked_n == 12) -# ---- 敌人表深度带键结构(迭代 34 建·L1 混编批换代:29 行键序=覆盖表序·(1,3) 混编行集·Boss 行集带键清单)---- +# ---- 敌人表深度带键结构(迭代 34 建·L1 混编批换代:30 行键序=覆盖表序·(1,3) 混编行集·Boss 行集带键清单·精英行)---- func _smoke_enemy_bands() -> void: # oracle: design/gdd/systems/冒险循环/难度曲线与Boss.md:316 难度与敌人带校验(敌人带组) - print("--- 敌人表深度带键结构(29 行键序 · (1,3) 混编 · Boss 带键覆盖表)---") - # ① 29 行计数 + 键序 = 覆盖表序(文件行序不变——行序即契约;(1,3) 两行相邻、熊地精在前) + print("--- 敌人表深度带键结构(30 行键序 · (1,3) 混编 · Boss 带键覆盖表 · 精英行)---") + # ① 30 行计数 + 键序 = 覆盖表序(文件行序不变——行序即契约;(1,3) 两行相邻、熊地精在前;精英行随精英专属池批) # 迭代 35 生成图深度扩面覆盖批随动:战斗带顶扩到各关最大生成深度(L1→6/L2→9/L3→13)、 # Boss 行按终点深度分带(L1 6/L2 8·9/L3 11·12·13),模板时代带键 4/9/9 全部在册不动 var expect_keys: Array[String] = [ "1|battle|0|goblin", "1|battle|1|goblin", "1|battle|2|bugbear", "1|battle|3|bugbear", "1|battle|3|goblin", "1|battle|6|bugbear", - "1|boss|4|ogre_chief", "1|boss|6|ogre_chief", + "1|boss|4|ogre_chief", "1|boss|6|ogre_chief", "1|elite|0|goblin_elite", "2|battle|0|bugbear", "2|battle|1|bugbear", "2|battle|4|giant_spider", "2|battle|5|bugbear", "2|battle|5|giant_spider", "2|battle|6|giant_spider", "2|battle|7|giant_spider", "2|battle|9|giant_spider", @@ -232,8 +232,8 @@ func _smoke_enemy_bands() -> void: for row in _db.combat.enemy.rows: got_keys.append("%d|%s|%d|%s" % [int(row.get("level_index", -1)), String(row.get("row_kind", "")), int(row.get("depth_band", -1)), String(row.get("enemy_id", ""))]) - Kit.check("敌人表 29 行且键序 = 覆盖表序((1,3) 两行相邻、熊地精在前)〔L1混编 v1〕", - _db.combat.enemy.rows.size() == 29 and got_keys == expect_keys) + Kit.check("敌人表 30 行且键序 = 覆盖表序((1,3) 两行相邻、熊地精在前;精英行接 boss 行后)〔L1混编 v1〕", + _db.combat.enemy.rows.size() == 30 and got_keys == expect_keys) # ② (1,3) 混编构成与行序+静态最坏骰和+行值整行复用(R1-R4) var rowset13: Array[Dictionary] = _db.combat.enemy.resolve_rowset(1, 3, "battle") var ids13: Array[String] = [] @@ -386,6 +386,8 @@ func _smoke_ac_swap_guard() -> void: and l12_pool == {"attack": 6, "charge": 2, "heavy": 2}) var l1_totals := {} for row in _db.combat.enemy.rows: + if String(row.get("row_kind", "")) == "elite": + continue # 曲线护栏管遭遇曲线——elite 不进遭遇(敌人系统.md:151),不跳过则 band0 失真 if int(row.get("level_index", -1)) != 1: continue var cnt_lo: int = int(row.get("unit_count", 1)) diff --git a/reports/iter54-反哺点-2026-11-07.md b/reports/iter54-反哺点-2026-11-07.md index 13387792..363fa90c 100644 --- a/reports/iter54-反哺点-2026-11-07.md +++ b/reports/iter54-反哺点-2026-11-07.md @@ -13,4 +13,8 @@ (未列项核对:camp.md §顶栏 与 art-bible §6.2 —— α0.80/栏高 48/色值零改值,按卡 2 §6「未改值不列」不列;HUD框架 §提示与弹层·其三「四处共用同一件同一序」——实装后句码一致,无发现不列;architecture.md :168 D1 HUD 视图单元接口——CampHudBand 仍为信号外发+setter 只读注入形态、新出口为 UiKit 工厂面(工厂单点先例在案),未失真不列。) -## 卡 3|精英专属池——(随卡 3 推进补齐) +## 卡 3|精英专属池——一条 + +1. **`design/gdd/03-内容规模.md` 第 66 行「精英节点与词缀系统——只预留了字段位」**:精英怪本体已落敌人表精英行(行类 elite+池继承合并附加)——「只预留字段位」的表述落后于实况;建议改成「精英怪本体已落敌人表(行类+池继承附加),精英节点与投放仍未接线」。依据=本批实现(enemy.tres (1,elite,0,goblin_elite) 行+卡 3 对照表 R1–R6 全绿);由文档批落文。 + +(未列项核对:敌人系统.md v1.9——实现与文档零出入(V6 生产表 12 字段逐位+有效池四键精确);数据配置.md 加载期错误类别表——dangling_base 属「悬空引用」既有类别的实例,表述不变,不列。) diff --git a/reports/iter54-实现报告-2026-11-07.md b/reports/iter54-实现报告-2026-11-07.md index e733570b..4feaab37 100644 --- a/reports/iter54-实现报告-2026-11-07.md +++ b/reports/iter54-实现报告-2026-11-07.md @@ -101,6 +101,46 @@ 9. **T1.5 `--quick` 闸壳步以探针直跑代证**:改完即测铁规频率口径(轮内不跑闸壳全套含 --quick)下,T1.4 探针直跑即该件最窄确认(同一判据同源读数),未另起 --quick 闸壳跑批。 10. **T3.0 读数实测 48.0**(卡面 L3 第 5 条警戒项未触发)——顶格落座前提成立,R5⑤ 判据照卡落装。 -## 卡 3|精英专属池——状态:待开工 +## 卡 3|精英专属池——状态:已完成 -(随卡 3 任务卡推进补齐。) +### 各任务卡状态 + +| 任务卡 | 状态 | 摘要 | +|---|---|---| +| T1 敌人表 schema | 完成 | ROW_KINDS 增 elite+ENCOUNTER_ROW_KINDS 行类门常量+_min_band_row+effective_pool+_validate_pool_entries 提取(pool 文案逐字不变)+_validate_elite_fields/_validate_elite_refs(dangling_base 新类别)+_extra_overlap_errs;新套件 elite_row_test 入册;config_db_test fv7 样本换代〔T1+T2 合并提交〕 | +| T2 跨表纪律+寻址门 | 完成 | cross_validator :194 改读 effective_pool(合并池过纪律);resolver :41 行类门改读 ENCOUNTER_ROW_KINDS(文案保持 {battle, boss}) | +| T3 地精精英落表+存量同步 | 完成 | enemy.tres 新行 (1,elite,0,goblin_elite)(卡面 §2 表值逐字段)+尾批注 provisional 来源行;round_flow 七处读法换代+expect_pools 增键+文案换代;guard 行数 30+键序插行+l1_totals skip elite;shop 还原断言改相对基数〔提交 e538e2cb〕 | +| T4 图鉴导出 | 完成 | compendium_dump 导出行数组 elite 副本写 effective_pool(表内行零改写);gen.py 词表三处〔提交 f4865ad8〕 | +| T5 产出物落位 | 完成 | 本报告卡 3 段+反哺点+能力行名实核对 | + +### 实际对照表(R1–R7,补「结果」格) + +| 行 | 验收测试 | 结果 | +|---|---|---| +| R1 elite 行类合法与字段互锁、引用校验 | elite_row_test._test_elite_validation_rejects〔精英批 V1〕 | 绿——合法夹具零错+十一违例各「含」预期类别(cross_field×7/dangling_base×2/dup_key×1/domain+pool_extra 定位×1);config_db_test fv7 样本换代后直跑绿 | +| R2 有效池=基础池+附加技、基础池取深度带最小行 | _test_effective_pool_merge〔精英批 V2〕 | 绿——深带行在前夹具下 {attack9,lunge1,heavy2}(取首行错误实现必红);battle 行读法等价面 {attack9,lunge1} | +| R3 继承不复制 | _test_base_pool_change_propagates〔精英批 V3〕 | 绿——基础池权重改 7 传到精英;改写返回数组条目后表内行不变 | +| R4 合并池过池构成纪律 | _test_merged_pool_composition〔精英批 V4〕 | 绿——合规精英计数 0;附加第二普攻/第二增益两夹具各计 1 且 message 含 goblin_elite | +| R5 暂不接线:寻址拿不到 elite | _test_elite_unreachable_by_encounter〔精英批 V5〕 | 绿——请求 elite 返回空+诊断含 fatal/request_oob;同夹具 battle/boss 查询合计>0 且零 elite 行 | +| R6 地精精英落表且全表加载零错 | _test_production_goblin_elite〔精英批 V6〕 | 绿——error 级诊断计数 0;行恰 1 条、12 字段逐位、无 unit_count_max/pool 键;有效池 {attack9,lunge1,desperate_guard1,heavy2} | +| R7 存量生产表冒烟随新行同步 | round_flow/guard/shop/roll_multiplier/trigger_cond 五件直跑 | 绿——五件 exit=0 零 SCRIPT ERROR;既有断言原判据照旧(精确键集/行数精确等/键序逐位/注入全量还原),仅扩期望集与读法 | + +### 卡 3 批末读数(T3.8 里程碑出口) + +`bash tools/run_all_gates.sh --code`=**【验收通过】闸况与基线一致,无新增红**(冒烟/单元与集成/系统/场景探针/渲染闸/静态扫描六闸全绿)——卡 3 全改动面(schema+core+enemy.tres+存量测试+图鉴工具)全档回归在案。 + +### 实机目检总清单(卡 3) + +**零项**——理由:精英暂不接线(敌人系统.md:151),无玩家可见面(卡 3 §8 测试方式两分法申报在案)。 + +### 遗留读数义务(卡 3) + +- 精英 EV 重锚与数值终值 → 难度曲线与Boss(Alpha 阶段零读数;本批零 MC——卡 3 §5 申报)。 +- 开战快照改读有效池(敌人系统.md:325 半句)→ 待办清单「战斗节点遭遇编排方式」条目承接(本批无入口可验)。 + +### 实现决定记录(卡 3 新增条目) + +11. **T2.3 夹具的 ogre_chief 行补合规池**:卡面 T2.3 夹具规格未点 boss 行池值——_mk_row 默认池(仅普攻)自身违反「至少一行无条件特殊技」纪律,pool_composition 误红。boss 行补 attack+lunge(自身合规),断言只数精英违规——夹具 mechanics 决策(白名单「夹具助手函数的拆法」族),V4 判据面不变。 +12. **round_flow 七处转换的批次内第 4 处豁免核**:卡面申报「另两处遍历(:571-575、:791-794)有前置过滤」;实查另核卡面未列的 :844(resolve_rowset 结果池读取)——经行类门 resolver elite 行结构性不可达,同豁免逻辑,不改不改判据。全文件 row.pool 直取残留恰 1 处(:574)与卡面申报一致。 +13. **expect_pools 键与文案的完成形态**:卡面 T3.4 定「:623 文案改『三十行』」;实改「三十行十一种池模板」——池模板计数同句失真(精英合并池为第 11 种),一并换代。键插入位置=字典尾(顺序无关,判据面=精确键集比对)。 +14. **S13 基线零新键确认**:enemy_table.gd/config_db_cross_validator.gd/config_db_resolver.gd 三件静态扫描零新增键(卡 3 §13 预算全数守住——validate() 局部变量 10/10 零新增、各私有函数分支在预算内),--code 跑闸静态扫描段绿为凭。 diff --git a/tools/compendium_gen.py b/tools/compendium_gen.py index c81b918e..c3fd017a 100644 --- a/tools/compendium_gen.py +++ b/tools/compendium_gen.py @@ -31,7 +31,7 @@ SKILL_REQUIRED = ("skill_id", "display_name", "type", "cooldown_beats") # 以下中文名均为表 schema 封闭枚举的展示词(enemy_table.gd / skill_table.gd),非玩法数值 LEVEL_CN = {1: "第一关", 2: "第二关", 3: "第三关"} -ROW_KIND_CN = {"battle": "战斗行", "boss": "Boss行"} +ROW_KIND_CN = {"battle": "战斗行", "boss": "Boss行", "elite": "精英行"} ATTACK_TYPE_CN = {"normal": "普通", "pierce": "穿刺", "magic": "魔法"} ARMOR_TYPE_CN = {"light": "轻甲", "ward": "魔法护甲", "heavy": "重甲"} SKILL_TYPE_CN = {"basic": "普攻", "damage": "伤害", "buff": "增益", "debuff": "妨害"} @@ -46,6 +46,7 @@ FIELD_CN = { "display_name": "显示名", "unit_count": "单位数", "unit_count_max": "单位数上限", "hp": "生命", "atk": "攻击基线", "atk_min": "攻击下限", "atk_max": "攻击上限", "attack_type": "攻击类型", "armor_type": "护甲类型", "armor_class": "护甲等级", + "base_ref": "基础敌种", "pool_extra": "附加技", "skill_id": "技能ID", "type": "类型", "damage_mult": "伤害倍率", "rider_mult": "必中附加分量", "cooldown_beats": "冷却拍数", "trigger": "触发条件", "buff_ref": "增益引用", "buff_duration": "增益持续", } @@ -119,7 +120,7 @@ def render_enemy_card(row: dict, skill_by_id: dict) -> str: f'{esc(badge)}') out.append('') for key in row: - if key == "pool": + if key in ("pool", "pool_extra"): continue out.append(f'' f'')
{esc(FIELD_CN.get(key, key))} {esc(key)}{attr_value(row, key)}