From 61537da889d4c929c906098149e38e8edb5bf2fb Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Thu, 22 Oct 2026 04:17:40 +0800 Subject: [PATCH] =?UTF-8?q?test(=E6=95=8C=E4=BA=BA=E8=A1=A8=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=E5=B8=A6):=20=E5=86=92=E7=83=9F=E4=B8=8E=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=E6=96=AD=E8=A8=80=E6=8C=89=20S=20=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E6=94=B9=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 冒烟全部寻址点改为三参 resolve_enemy_rowset(关卡, 深度, 行类),结算/单元/探针直呼的 spawn 与 begin_battle 显式带行类(battle/boss)——终点战仍按 Boss 行集取数,行为不变。 首遇位断言改为扫表取该 (关卡, battle, 敌种) 的最低深度带,不再写死坐标〔fv21〕;新增 _enemy_bands 结构段验 20 行键序、hobgoblin 留任 (1,battle,3)、三 Boss 行类与带键〔fv11–fv13〕; L2 混编 (2,battle,5) 行序与骰和、巨蜘蛛/bugbear/duergar 首遇带固定数、Boss 行类显式传入 等断言逐条加〔fv18/fv19/fv22–fv24〕尾缀。职业节奏探针与伤害探针同步三参寻址。 变异验证七项全部命中(记录表进实现报告):band_of 反向 floor → fv1/fv3 红;row_kind 枚举 判据注释 → fv7 红;Boss 同带判据删除 → fv15 红;顶带消解判据短路 → fv16 红;巨蜘蛛首遇行 加 unit_count_max → fv22 红;混编两行文件内对调 → fv18/fv11 红;hobgoblin 迁错带键 → fv11/fv12 红(直接迁到带 2 先被加载期同带规则拦下,改用与带 2 行互换带键,表可加载后键位断言红)。 --- game/smoke/damage_probe.gd | 14 +- game/smoke/skill_rhythm_probe.gd | 4 +- game/smoke/smoke_all.gd | 329 ++++++++++++------ .../battle_settlement/skill_unlock_test.gd | 8 +- .../core/config_db_cross_validator_test.gd | 65 +++- .../unit/core/config_db_resolver_test.gd | 82 ++++- game/tests/unit/data_config/config_db_test.gd | 73 ++-- 7 files changed, 410 insertions(+), 165 deletions(-) diff --git a/game/smoke/damage_probe.gd b/game/smoke/damage_probe.gd index cffa9d02..1d7c99c2 100644 --- a/game/smoke/damage_probe.gd +++ b/game/smoke/damage_probe.gd @@ -92,7 +92,7 @@ func _run_boss(db, seed_val: int, dig: bool) -> Dictionary: settlement.step_row.connect(func(row): cap["rows"].append(row)) settlement.settlement_snapshot.connect(func(s): cap["snaps"].append(s)) settlement.battle_finished.connect(func(p): cap["result"] = String(p.get("result", ""))) - if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.hero.max_hp)}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(db.hero.max_hp), "node_type": "boss"}): cap["result"] = "begin_failed" return cap var rounds := 0 @@ -266,7 +266,7 @@ func _encounter_prediction(db, seed_val: int) -> Dictionary: if t != "battle" and t != "boss": continue var depth := int(node.get("node_depth", -1)) - var row: Dictionary = db.resolve_enemy_rowset(1, depth)[0] # 行集首行(行集多行批 T2 机械适配) + var row: Dictionary = db.resolve_enemy_rowset(1, depth, t)[0] # 行集首行(row_kind 随节点类型) var lo := int(row.get("unit_count", 1)) var hi := int(row.get("unit_count_max", lo)) var rolled := lo @@ -404,20 +404,22 @@ func _scan_spawns(db, seed0: int, n_seeds: int) -> Dictionary: for node in _level_row(db, 1).get("nodes", []): var t := String(node.get("type", "")) if t == "battle" or t == "boss": - depths.append(int(node.get("node_depth", -1))) + depths.append({"depth": int(node.get("node_depth", -1)), "kind": t}) var total := 0 var bad := 0 for i in range(n_seeds): var rng = load("res://core/rng_service.gd").new() rng.set_master_seed(seed0 + i) - for depth in depths: + for dep in depths: + var depth: int = int(dep.get("depth", -1)) + var kind: String = String(dep.get("kind", "battle")) total += 1 var settlement = load("res://core/battle_orchestrator.gd").new(db, rng) - if not settlement.begin_battle({"level_index": 1, "node_depth": int(depth), "hero_hp": int(db.hero.max_hp)}): + if not settlement.begin_battle({"level_index": 1, "node_depth": depth, "node_type": kind, "hero_hp": int(db.hero.max_hp)}): bad += 1 continue var n: int = settlement.presentation_snapshot().enemies.size() - var row: Dictionary = db.resolve_enemy_rowset(1, int(depth))[0] # 行集首行(同上) + var row: Dictionary = db.resolve_enemy_rowset(1, depth, kind)[0] # 行集首行(同上,row_kind 随节点类型) var lo := int(row.get("unit_count", 1)) var hi := int(row.get("unit_count_max", lo)) if n < 1 or n < lo or n > hi: diff --git a/game/smoke/skill_rhythm_probe.gd b/game/smoke/skill_rhythm_probe.gd index a4298e91..d6a8ee47 100644 --- a/game/smoke/skill_rhythm_probe.gd +++ b/game/smoke/skill_rhythm_probe.gd @@ -13,8 +13,8 @@ func _initialize() -> void: var db = load("res://core/config_db.gd").new() db.load_tables("res://data/tables", false) - # ① 表侧(行集数组取首行,零内容期行为不变) - var row = db.resolve_enemy_rowset(1, 1)[0] + # ① 表侧(行集数组取首行,战斗节点查 battle 键域) + var row = db.resolve_enemy_rowset(1, 1, "battle")[0] print("[1] row(1,1) display=", row.get("display_name"), " unit_count=", row.get("unit_count"), " pool=", row.get("pool")) diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 7f8cf2e8..80d5abac 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -646,6 +646,7 @@ func _run_core_smoke(db, rng) -> void: _rng = rng _smoke_ac_swap_guard() _smoke_enemy_attack_type() + _smoke_enemy_bands() _smoke_armor_roll() _smoke_pool_wiring() _smoke_board_generator() @@ -964,24 +965,25 @@ func _smoke_adventure() -> void: print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---") var autoplay = load("res://smoke/level_autoplay.gd") # ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行集——覆盖义务断言; - # 精确行集口径——enemy 侧同坐标多行合法,坐标级键聚合天然兼容) + # enemy 侧键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域, + # 同带多行合法,带键聚合天然兼容〔fv24〕) check(int(_db.level.rows.size()) == 3, "序列关数 = 行集关数(三关正式序列生效)") var bg_keys := {} for row in _db.board_gen.rows: bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true var en_keys := {} for row in _db.enemy.rows: - en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true + en_keys["%d_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true var miss := 0 for row in _db.level.rows: for n in _level_active_nodes(int(row.level_index)): var nt := String(n.get("type", "")) if nt != "battle" and nt != "boss": continue - var key := "%d_%d" % [int(row.level_index), int(n.get("node_depth", -1))] - if not bg_keys.has(key) or not en_keys.has(key): + var key := "%d_%s_%d" % [int(row.level_index), nt, int(n.get("node_depth", -1))] + if not bg_keys.has("%d_%d" % [int(row.level_index), int(n.get("node_depth", -1))]) or not en_keys.has(key): miss += 1 - check(miss == 0, "行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行集(缺失 %d)" % miss) + check(miss == 0, "行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行集(缺失 %d——带键 (L,row_kind,depth_band))" % miss) # ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复) var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400) var sum: Dictionary = st["summary"] @@ -2019,7 +2021,7 @@ func _smoke_seal_ap_exhausted() -> void: var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) var rounds: Array = [] settlement.round_started.connect(func(ri): rounds.append(ri)) - check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载封印回归场景)") + check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "回归战斗启动(Boss 行——D0 20HP 级会被盲挖粉尘秒杀,无法承载封印回归场景)") var minesweeper = settlement.minesweeper # 恰花 (AP-1) 点挖掘,留末点给封印(表驱动——AP 从表读不写死) var ap_total: int = int(_db.action_economy.ap_per_round) @@ -2082,6 +2084,55 @@ func _smoke_level() -> void: "模拟后 hero 表恢复基线(atk=%d hp=%d ac=%d——对配置快照)" % [int(_db.hero.atk), int(_db.hero.max_hp), int(_db.hero.armor_class)]) +# ---- 敌人表深度带键结构(迭代 34:20 行键迁移 · 键序=迁移表序 · hobgoblin 留任 · 三 Boss 行 kind/band)---- + +func _smoke_enemy_bands() -> void: + print("--- 敌人表深度带键结构(20 行键序 · hobgoblin 留任 · 三 Boss 行)---") + # ① 20 行计数 + 键序 = 迁移表序(文件行序不变——行序即契约) + var expect_keys: Array[String] = [ + "1|battle|0|goblin", "1|battle|1|goblin", "1|battle|2|bugbear", "1|battle|3|hobgoblin", + "1|boss|4|ogre_chief", + "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|boss|9|drider", + "3|battle|0|duergar", "3|battle|1|hook_horror", "3|battle|4|hook_horror", "3|battle|5|duergar", + "3|battle|6|hook_horror", "3|battle|7|duergar", "3|boss|9|duergar_king", + ] + var got_keys: Array[String] = [] + for row in _db.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", ""))]) + check(_db.enemy.rows.size() == 20 and got_keys == expect_keys, + "敌人表 20 行且键序 = 迁移表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕") + # ② hobgoblin 缺口行留任随迁(契约 9:不删行不补立绘——缺口登记随行) + var hob := {} + for row in _db.enemy.rows: + if String(row.get("enemy_id", "")) == "hobgoblin": + hob = row + check(int(hob.get("level_index", -1)) == 1 and String(hob.get("row_kind", "")) == "battle" \ + and int(hob.get("depth_band", -1)) == 3, + "hobgoblin 行在 (1,battle,3) 留任随迁(缺口行不删不补——补齐归敌种内容批)〔fv12〕〔fv25〕") + check(FileAccess.get_file_as_string("res://data/tables/enemy.tres").contains("hobgoblin 行为缺口行"), + "hobgoblin 缺口登记随行(表文件注释在位——缺口不静默)〔fv25〕") + # ③ 三 Boss 行 kind/band 就位(band = 该关图 Boss 节点深度——单终点图数据驱动) + var boss_ok := true + var boss_detail := "" + for L in [1, 2, 3]: + var bdepth := _boss_depth(L) + var hits := 0 + for row in _db.enemy.rows: + if int(row.get("level_index", -1)) == L and String(row.get("row_kind", "")) == "boss": + hits += 1 + if int(row.get("depth_band", -1)) != bdepth: + boss_ok = false + boss_detail += " L%d:%d≠%d" % [L, int(row.get("depth_band", -1)), bdepth] + if hits != 1: + boss_ok = false + boss_detail += " L%d:boss 行×%d" % [L, hits] + check(boss_ok, "三关 Boss 行各恰一行且 row_kind=boss、band=图 Boss 节点深度(%d/%d/%d%s)〔fv13〕" % [ + _boss_depth(1), _boss_depth(2), _boss_depth(3), boss_detail]) + + # ---- 敌方攻击类型轴(表列/单位快照/单据载荷/矩阵命中证词)---- # 克制矩阵命中证词 = 同种子三甲型配对实测(不是查表存在性证词):同种子下 armor_roll 流消费逐位一致、 # 阈值(hero armor_class)不变,三甲型唯一差异 = 克制点数 cdelta(normal_vs_light 0 / heavy −1 / ward +1) @@ -2211,29 +2262,31 @@ func _smoke_ac_swap_guard() -> void: boss_ac[int(row.get("level_index", 0))] = int(row.get("armor_class", 0)) check(int(boss_ac.get(1, 0)) < int(boss_ac.get(2, 0)) and int(boss_ac.get(2, 0)) < int(boss_ac.get(3, 0)), "Boss AC 关卡台阶严格递增(%d < %d < %d)" % [int(boss_ac.get(1, 0)), int(boss_ac.get(2, 0)), int(boss_ac.get(3, 0))]) - # ③c (1,2) 降档锚定 + 第一关总量曲线护栏 - # (1,2) 锚定值 hp 22 / ac 2; + # ③c 熊地精首遇带降档锚定 + 第一关总量曲线护栏 + # 首遇带行 = 数据驱动定位(契约 8:该 (1,battle,bugbear) 的最低 depth_band)锚定值 hp 22 / ac 2; # 敌种 bugbear 与池 attack6/charge2/heavy2 钉死不动(防顺手改多); - # 曲线护栏 = 相邻节点「hp × 中位数量」总量比 ≤ 2.5(12/10/22/35/72 现行全绿;不用单调不回撤——会红在 (1,0)→(1,1)) + # 曲线护栏 = 深度带升序相邻带「hp × 中位数量」总量比 ≤ 2.5(12/10/22/35/72 现行全绿;不用单调不回撤——会红在首带→次带) + var l12_band := _first_encounter_band(1, "bugbear") var l12_row: Dictionary = {} for row in _db.enemy.rows: - if int(row.get("level_index", -1)) == 1 and int(row.get("node_depth", -1)) == 2: + if int(row.get("level_index", -1)) == 1 and String(row.get("row_kind", "")) == "battle" \ + and int(row.get("depth_band", -1)) == l12_band and String(row.get("enemy_id", "")) == "bugbear": l12_row = row break var l12_pool := {} for entry in l12_row.get("pool", []): l12_pool[String(entry.skill_id)] = int(entry.weight) - check(String(l12_row.get("enemy_id", "")) == "bugbear" and int(l12_row.get("hp", -1)) == 22 \ + check(l12_band >= 0 and not l12_row.is_empty() and int(l12_row.get("hp", -1)) == 22 \ and int(l12_row.get("armor_class", -1)) == 2 \ and l12_pool == {"attack": 6, "charge": 2, "heavy": 2}, - "第一关 (1,2) 降档锚定(bugbear 保留 · hp 22 · ac 2 · 池 attack6/charge2/heavy2 零改动——迭代 24 N1)") + "熊地精首遇带(1,battle,%d)降档锚定(hp 22 · ac 2 · 池 attack6/charge2/heavy2 零改动——迭代 24 N1;首遇带数据驱动定位)〔fv21〕" % l12_band) var l1_totals := {} for row in _db.enemy.rows: if int(row.get("level_index", -1)) != 1: continue var cnt_lo: int = int(row.get("unit_count", 1)) var cnt_hi: int = int(row.get("unit_count_max", cnt_lo)) - l1_totals[int(row.get("node_depth", -1))] = int(row.get("hp", 0)) * (cnt_lo + cnt_hi) / 2.0 + l1_totals[int(row.get("depth_band", -1))] = int(row.get("hp", 0)) * (cnt_lo + cnt_hi) / 2.0 var curve_ok := true var curve_detail := "" var depths_sorted := l1_totals.keys() @@ -2245,7 +2298,7 @@ func _smoke_ac_swap_guard() -> void: if ratio > 2.5: curve_ok = false curve_detail += " (%d→%d):%.2f" % [depths_sorted[i - 1], depths_sorted[i], ratio] - check(curve_ok, "第一关相邻节点总量比 ≤ 2.5(12/10/22/35/72——迭代 24 N1 曲线护栏,越带%s)" % [ + check(curve_ok, "第一关相邻深度带总量比 ≤ 2.5(12/10/22/35/72——迭代 24 N1 曲线护栏,带键升序;越带%s)" % [ curve_detail if curve_detail != "" else "无"]) # ④ 掷点参数域(加载校验已过——此处钉形状语义) var bounds: Array = _db.settlement.roll_tier_bounds @@ -2618,8 +2671,8 @@ func _smoke_global_variant() -> void: var crng = _CountVariantRng.new() crng.set_master_seed(42) var boss_settlement = load("res://core/battle_orchestrator.gd").new(_db, crng) - var boss_ok: bool = boss_settlement.begin_battle({"level_index": 2, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) - check(boss_ok, "L2 Boss 战斗启动(node_depth=4 boss 节点——兜底回落断言载体)") + var boss_ok: bool = boss_settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}) + check(boss_ok, "L2 Boss 战斗启动(node_depth=9 Boss 终点——兜底回落断言载体)") var boss1_ids: Array = [] var boss1_variants: Dictionary = {} var boss1_calls := 0 @@ -2643,7 +2696,7 @@ func _smoke_global_variant() -> void: var crng2 = _CountVariantRng.new() crng2.set_master_seed(42) var boss2 = load("res://core/battle_orchestrator.gd").new(_db, crng2) - if boss2.begin_battle({"level_index": 2, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): + if boss2.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): var bb2 = boss2.minesweeper.current_board() check(_arr_str_eq(bb2.board_variant_ids(), boss1_ids) and bb2.variants == boss1_variants and crng2.variant_calls == boss1_calls, "确定性:同种子 Boss 图两遍 board_variants/变异格/取号逐位相同(region 布置路径)") @@ -3022,7 +3075,7 @@ func _smoke_active_refresh() -> void: if String(row.get("type", "")) == "decay" and not bool(ct["waiting_probe_hit"]): ct["waiting_probe_hit"] = true ct["waiting_refresh_rejected"] = not settlement.player_request_refresh()) - check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)") + check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "轮内刷新战斗启动(Boss 行 150HP——承载多轮样本)") var minesweeper = settlement.minesweeper # 轮 1–3:零挖掘停手攒 RAP = 3×AP(每轮全转存;表驱动——AP 从表读不写死) for i in range(3): @@ -3087,7 +3140,7 @@ func _active_refresh_replay() -> Array: var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) var rows: Array = [] settlement.step_row.connect(func(row): rows.append(row)) - if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): return rows var minesweeper = settlement.minesweeper for i in range(3): @@ -3112,7 +3165,7 @@ func _smoke_difficulty_table() -> void: var enemy_system = load("res://core/unit_container.gd").new(_db) check(enemy_system.spawn(1, 0, int(_db.hero.max_hp)), "spawn 受理") var unit = enemy_system.all_enemies()[0] - var d0: Dictionary = _db.resolve_enemy_rowset(1, 0)[0] # 行集数组口径取首行(零内容期每坐标恰一行,T2 机械适配) + var d0: Dictionary = _db.resolve_enemy_rowset(1, 0, "battle")[0] # 行集首行(带键寻址:战斗节点查 battle 键域) check(int(unit.hp) == int(d0.hp) and int(unit.atk) == int(d0.atk), "空表零修饰(hp=%d atk=%d = 行值 %d/%d)" % [int(unit.hp), int(unit.atk), int(d0.hp), int(d0.atk)]) # ② 修饰行运行时注入(debug 通道——R3 override 同族):套乘生效于 hp/atk _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 1.5, "atk_mult": 2.0}) @@ -3140,7 +3193,7 @@ func _smoke_damage_types() -> void: var enemy_system_b = load("res://core/unit_container.gd").new(_db) check(enemy_system_b.spawn(1, 2, int(_db.hero.max_hp)) and String(enemy_system_b.all_enemies()[0].armor_type) == "light", "bugbear 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)") var enemy_system_c = load("res://core/unit_container.gd").new(_db) - check(enemy_system_c.spawn(1, 4, int(_db.hero.max_hp)) and String(enemy_system_c.all_enemies()[0].armor_type) == "light", "ogre_chief 护甲 = light(护甲类型重配批 2026-09-18:关卡 1 全轻甲)") + check(enemy_system_c.spawn(1, 4, int(_db.hero.max_hp), [], -1, -1, [], "", "boss") and String(enemy_system_c.all_enemies()[0].armor_type) == "light", "ogre_chief 护甲 = light(Boss 行集——护甲类型重配批 2026-09-18:关卡 1 全轻甲)") # ② 克制矩阵表值在位(三档点数 克制 +1 / 中性 0 / 被克 −1,加法核非 percent) var am: Dictionary = _db.settlement.armor_effectiveness check(int(am.normal.light) == 0 and int(am.normal.ward) == 1 and int(am.normal.heavy) == -1, "普通行:轻甲 0 / 魔法护甲 +1 / 重甲 −1(加法核)") @@ -3174,7 +3227,7 @@ func _run_seal_backlash_round(seed_val: int) -> Dictionary: var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) var rows: Array = [] settlement.step_row.connect(func(row): rows.append(row)) - if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}): + if not settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): return {"rows": rows, "safe_cell": Vector2i(-1, -1), "safe_type": ""} var minesweeper = settlement.minesweeper var safe_t := _find_seal_target(minesweeper, false) @@ -3228,7 +3281,7 @@ func _smoke_seal_irreversible_quadrants() -> void: var settle_a = load("res://core/battle_orchestrator.gd").new(_db, rng_a) var rows_a: Array = [] settle_a.step_row.connect(func(row): rows_a.append(row)) - check(settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 A:开局受理") + check(settle_a.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "四情况 A:开局受理") var ms_a = settle_a.minesweeper var t_a := _find_seal_target(ms_a, false) check(not t_a.is_empty(), "四情况 A:找到安全格封印目标") @@ -3268,7 +3321,7 @@ func _smoke_seal_irreversible_quadrants() -> void: var settle_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b) var rows_b: Array = [] settle_b.step_row.connect(func(row): rows_b.append(row)) - check(settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 B:开局受理") + check(settle_b.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "四情况 B:开局受理") var ms_b = settle_b.minesweeper var t_b := _find_seal_target(ms_b, true) check(not t_b.is_empty(), "四情况 B:找到危险格封印目标") @@ -3298,7 +3351,7 @@ func _smoke_seal_irreversible_quadrants() -> void: var settle_c = load("res://core/battle_orchestrator.gd").new(_db, rng_c) var rows_c: Array = [] settle_c.step_row.connect(func(row): rows_c.append(row)) - check(settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "四情况 C:开局受理") + check(settle_c.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "四情况 C:开局受理") var ms_c = settle_c.minesweeper var t_safe := _find_seal_target(ms_c, false) var t_mine := _find_seal_target(ms_c, true) @@ -3403,12 +3456,21 @@ func _smoke_encounter_roll() -> void: check(settlement_d1.begin_battle({"level_index": 1, "node_depth": 1, "hero_hp": int(_db.hero.max_hp)}), "D1 战斗启动") var n1: int = settlement_d1.presentation_snapshot().enemies.size() check(n1 >= 2 and n1 <= 3, "D1 区间 [2,3] 域内(%d 只)" % n1) - # D2 固定(教学位不加骰) + # 熊地精首遇带固定(教学位不加骰)——首遇带数据驱动定位(契约 8,禁写死坐标) + var bugbear_lead_band := _first_encounter_band(1, "bugbear") + var bugbear_lead_row: Dictionary = {} + for row in _db.enemy.rows: + if int(row.level_index) == 1 and String(row.row_kind) == "battle" \ + and int(row.depth_band) == bugbear_lead_band and String(row.enemy_id) == "bugbear": + bugbear_lead_row = row + break var rng4 = load("res://core/rng_service.gd").new() rng4.set_master_seed(31000) var settlement_d2 = load("res://core/battle_orchestrator.gd").new(_db, rng4) - settlement_d2.begin_battle({"level_index": 1, "node_depth": 2, "hero_hp": int(_db.hero.max_hp)}) - check(settlement_d2.presentation_snapshot().enemies.size() == 1, "D2 熊地精固定 1(首遇教学位)") + settlement_d2.begin_battle({"level_index": 1, "node_depth": bugbear_lead_band, "hero_hp": int(_db.hero.max_hp)}) + check(settlement_d2.presentation_snapshot().enemies.size() == 1 \ + and int(bugbear_lead_row.get("unit_count", -1)) == 1 and not bugbear_lead_row.has("unit_count_max"), + "熊地精首遇带(1,battle,%d——扫表取最低带)固定 1 不加骰〔fv23〕" % bugbear_lead_band) # 全深度 × 多种子域内扫描(playtest-2026-09-12 报告 Bug#1 回归——区间下限 1 不可掷 0: # 战斗结算 roll_lo 下限 + 敌人系统 spawn count<1 fail-fast 双闸;单深度单种子是盲区,全域扫描才钉死不变式) var depths: Array = [] @@ -3426,7 +3488,7 @@ func _smoke_encounter_roll() -> void: scan_bad += 1 continue var n: int = settlement_scan.presentation_snapshot().enemies.size() - var erow2: Dictionary = _db.resolve_enemy_rowset(1, int(depth_v))[0] # 行集首行(T2 机械适配) + var erow2: Dictionary = _db.resolve_enemy_rowset(1, int(depth_v), "battle")[0] # 行集首行(扫描面 = 战斗节点) var lo2: int = int(erow2.get("unit_count", 1)) var hi2: int = int(erow2.get("unit_count_max", lo2)) if n < 1 or n < lo2 or n > hi2: @@ -3434,6 +3496,36 @@ func _smoke_encounter_roll() -> void: check(scan_bad == 0, "全深度 × 12 种子 spawn 全域内(%d 场,∈[unit_count, unit_count_max] 且 ≥1)——空遭遇不可达(Bug#1 回归)" % scan_total) +# 节点类型 → 敌人表 row_kind(非 Boss 战斗位恒 battle——深度带批寻址第三维) +func _row_kind_of(level_index: int, depth: int) -> String: + for n in _level_active_nodes(level_index): + if int(n.get("node_depth", -1)) == depth and String(n.get("type", "")) == "boss": + return "boss" + return "battle" + + +# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标) +func _boss_depth(level_index: int) -> int: + for n in _level_active_nodes(level_index): + if String(n.get("type", "")) == "boss": + return int(n.get("node_depth", -1)) + return -1 + + +# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字 +func _first_encounter_band(level_index: int, enemy_id: String) -> int: + var best := -1 + for row in _db.enemy.rows: + if int(row.get("level_index", -1)) != level_index: + continue + if String(row.get("row_kind", "")) != "battle" or String(row.get("enemy_id", "")) != enemy_id: + continue + var band := int(row.get("depth_band", -1)) + if best < 0 or band < best: + best = band + return best + + # 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义) func _level_active_nodes(level_index: int) -> Array: for row in _db.level.rows: @@ -3471,7 +3563,7 @@ func _smoke_attack_chain_fresh_pool() -> void: if row.type == "damage" and row.source_class == "enemy_action": ticks.append({"baseline": int(row.baseline), "attach": int(row.get("attach", 0)), "roll": int(row.roll), "tier": int(row.tier), "crit": bool(row.get("crit", false)), "tick": int(row.value)})) - check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp)}), "fresh 池回归战斗启动(Boss 行)") + check(settlement.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "fresh 池回归战斗启动(Boss 行)") # 停手轮:零挖掘零封印 → 零授予 → 池恒空;Boss 拍 = 开轮摇号结果(伤害技/普攻兑付伤害行、硬化施放行不计) for i in range(6): if settlement.battle_result != "": @@ -3538,7 +3630,7 @@ func _smoke_clear_reward_zero_paths() -> void: var s = load("res://core/battle_orchestrator.gd").new(_db, rng) var rows: Array = [] s.step_row.connect(func(row): rows.append(row)) - check(s.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}), + check(s.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"}), "零兑付构造战斗启动") for u in s._enemy_system.all_enemies(): s._enemy_system.apply_damage(u, int(u.hp)) # 测试通道:构造"无存活敌"(弱形式——全灭而非 despawn) @@ -3556,7 +3648,7 @@ func _smoke_clear_reward_zero_paths() -> void: var s_b = load("res://core/battle_orchestrator.gd").new(_db, rng_b) var rows_b: Array = [] s_b.step_row.connect(func(row): rows_b.append(row)) - check(s_b.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}), + check(s_b.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"}), "高光守卫构造战斗启动") for u in s_b._enemy_system.all_enemies(): s_b._enemy_system.apply_damage(u, int(u.hp)) @@ -3573,7 +3665,7 @@ func _smoke_clear_reward_zero_paths() -> void: var s2 = load("res://core/battle_orchestrator.gd").new(_db, rng2) var rows2: Array = [] s2.step_row.connect(func(row): rows2.append(row)) - check(s2.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80}), + check(s2.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "hero_hp": 80, "hero_max_hp": 80, "node_type": "boss"}), "第二份被拒构造战斗启动") var boss = s2._enemy_system.all_enemies()[0] boss.hp = 1 # 测试通道:压低至首份 aoe 必杀(弱形式——正常流 Boss 血量不可控) @@ -4044,7 +4136,7 @@ func _run_full_clear(seed_val: int, from_refresh: bool, rows: Array) -> Dictiona var settlement = load("res://core/battle_orchestrator.gd").new(_db, rng) settlement.step_row.connect(func(row): rows.append(row)) var res := {"cleared": false, "refreshed": false, "rounds": 0} - if not settlement.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, + if not settlement.begin_battle({"level_index": 1, "node_depth": CLEAR_SMOKE_DEPTH, "node_type": "boss", "hero_hp": CLEAR_SMOKE_HERO_HP, "hero_max_hp": CLEAR_SMOKE_HERO_HP}): _db.difficulty.rows.clear() return res @@ -4175,44 +4267,55 @@ func _smoke_skill_pool_data() -> void: check(not bash.is_empty() and String(bash.display_name) == "盾击" and String(bash.type) == "damage" \ and is_equal_approx(float(bash.damage_mult), 1.7) and int(bash.cooldown_beats) == 3 and not bash.has("rider_mult"), "盾击行(伤害 ×1.7 · 冷却 3 · 无 rider——迭代 24 N2)") - var spit_sole_ok := true # 毒吐只挂巨蜘蛛族且首遇位 (2,4) 不投(Q6——教学位池零新招) + var spider_lead_band := _first_encounter_band(2, "giant_spider") # 首遇带数据驱动定位(契约 8) + var spit_sole_ok := true # 毒吐只挂巨蜘蛛族且首遇带不投(Q6——教学位池零新招) var bash_sole_ok := true # 盾击只挂灰矮人族三行 for row in _db.enemy.rows: for entry in row.pool: 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.node_depth) >= 5): + 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): spit_sole_ok = false if sid == "shield_bash" and not (String(row.enemy_id) == "duergar" and int(row.level_index) == 3): bash_sole_ok = false - check(spit_sole_ok, "毒吐投放面(仅 (2,5)/(2,6)/(2,7) 巨蜘蛛族——首遇位 (2,4) 池不动,迭代 24 N2/Q6)") - check(bash_sole_ok, "盾击投放面(仅 (3,0)/(3,5)/(3,7) 灰矮人族——钩爪恐魔零触碰,迭代 24 N2)") - # 行集键 = (level_index, node_depth, enemy_id)——分支节点图批起 bugbear 等敌种跨关异池,enemy_id 不再唯一; - # 同坐标混编多行时每行各一池,键升维到三元组(行序 = 文件序 = 契约序) + check(spit_sole_ok, "毒吐投放面(仅 depth_band > 巨蜘蛛首遇带(%d)的巨蜘蛛族行——首遇带池不动,迭代 24 N2/Q6)〔fv24〕" % spider_lead_band) + check(bash_sole_ok, "盾击投放面(仅 (3,·) 灰矮人族——钩爪恐魔零触碰,迭代 24 N2)") + # 首遇带行池恒 {attack:6, bite:2, charge:1}(去写死深度判据——带键口径) + var spider_lead_pool := {} + for row in _db.enemy.rows: + if int(row.level_index) == 2 and String(row.row_kind) == "battle" \ + and int(row.depth_band) == spider_lead_band and String(row.enemy_id) == "giant_spider": + for entry in row.pool: + spider_lead_pool[String(entry.skill_id)] = int(entry.weight) + check(spider_lead_pool == {"attack": 6, "bite": 2, "charge": 1}, + "巨蜘蛛首遇带行 pool 恒 {attack:6, bite:2, charge:1}(教学位池零新招——毒吐首投在更深的带)〔fv24〕") + # 行集键 = (level_index, row_kind, depth_band, enemy_id)——分支节点图批起 bugbear 等敌种跨关异池, + # enemy_id 不再唯一;同带混编多行时每行各一池(行序 = 文件序 = 契约序) var expect_pools := { - "1_0_goblin": {"attack": 9, "lunge": 1}, - "1_1_goblin": {"attack": 9, "lunge": 1}, - "1_2_bugbear": {"attack": 6, "charge": 2, "heavy": 2}, - "1_3_hobgoblin": {"attack": 6, "charge": 2, "heavy": 2}, - "1_4_ogre_chief": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2}, - "2_0_bugbear": {"attack": 6, "lunge": 1, "heavy": 2}, - "2_1_bugbear": {"attack": 6, "lunge": 1, "heavy": 2}, - "2_4_giant_spider": {"attack": 6, "bite": 2, "charge": 1}, - "2_5_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, - "2_5_bugbear": {"attack": 5, "lunge": 2, "heavy": 2}, - "2_6_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, - "2_7_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, - "2_9_drider": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2}, - "3_0_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, - "3_1_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, - "3_4_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, - "3_5_duergar": {"attack": 5, "lunge": 2, "heavy": 2, "shield_bash": 2}, - "3_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, - "3_7_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, - "3_9_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, + "1_battle_0_goblin": {"attack": 9, "lunge": 1}, + "1_battle_1_goblin": {"attack": 9, "lunge": 1}, + "1_battle_2_bugbear": {"attack": 6, "charge": 2, "heavy": 2}, + "1_battle_3_hobgoblin": {"attack": 6, "charge": 2, "heavy": 2}, + "1_boss_4_ogre_chief": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2}, + "2_battle_0_bugbear": {"attack": 6, "lunge": 1, "heavy": 2}, + "2_battle_1_bugbear": {"attack": 6, "lunge": 1, "heavy": 2}, + "2_battle_4_giant_spider": {"attack": 6, "bite": 2, "charge": 1}, + "2_battle_5_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, + "2_battle_5_bugbear": {"attack": 5, "lunge": 2, "heavy": 2}, + "2_battle_6_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, + "2_battle_7_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, + "2_boss_9_drider": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2}, + "3_battle_0_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, + "3_battle_1_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, + "3_battle_4_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, + "3_battle_5_duergar": {"attack": 5, "lunge": 2, "heavy": 2, "shield_bash": 2}, + "3_battle_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, + "3_battle_7_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, + "3_boss_9_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, } var pools_ok := true for row in _db.enemy.rows: - var rk := "%d_%d_%s" % [int(row.level_index), int(row.node_depth), String(row.enemy_id)] + var rk := "%d_%s_%d_%s" % [int(row.level_index), String(row.row_kind), int(row.depth_band), String(row.enemy_id)] if not expect_pools.has(rk): pools_ok = false continue @@ -4221,7 +4324,7 @@ func _smoke_skill_pool_data() -> void: got[String(entry.skill_id)] = int(entry.weight) if got != expect_pools[rk]: pools_ok = false - check(pools_ok, "二十行九种池模板 = 建议池行值((L,D,enemy_id) 三元键逐行精确比对——2026-10-07 行集多行批口径;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔(2,5)-(2,7) 投放,首遇位 (2,4) 不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔(2,9)——商店房批 Boss 重编号〕;L3 灰矮人 6/2/2+盾击2〔(3,0)/(3,7)〕 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse·(3,9)〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同坐标多行各一池合法——首批投放 (2,5) 双行〔迭代 18 卡⑤:重击手+巢群,新行行值复制 (2,6)〕,其余坐标仍恰一行)") + check(pools_ok, "二十行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 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 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕,其余带键仍恰一行)") var basic_count_ok := true for row in _db.enemy.rows: var n := 0 @@ -4253,7 +4356,7 @@ func _smoke_skill_pool_data() -> void: var st_unlock: Dictionary = {"rows": []} var sett_u = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(4242)) sett_u.step_row.connect(func(row): st_unlock["rows"].append(row)) - check(sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp)}), "前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)") + check(sett_u.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}), "前置:卓尔 Boss 战受理(2,9——解锁技 venom_curb 门槛 50)") var boss_u = sett_u._enemy_system.all_enemies()[0] # 白盒夹具(smoke 惯例——autoplay 同族私有面) sett_u._enemy_system.apply_damage(boss_u, (int(boss_u.max_hp) - 65)) check(int(boss_u.hp) == 65, "前置:Boss 压到阈值 65(=round(130×0.5))") @@ -4270,13 +4373,13 @@ func _smoke_skill_pool_data() -> void: var rollable_ok := true for depth in [0, 1, 2, 3, 4]: var es = load("res://core/unit_container.gd").new(_db) - es.spawn(1, int(depth), int(_db.hero.max_hp)) + es.spawn(1, int(depth), int(_db.hero.max_hp), [], -1, -1, [], "", _row_kind_of(1, int(depth))) for u in es.all_enemies(): if es.rollable_skills(u).is_empty(): rollable_ok = false for depth in [0, 1, 4, 5, 6, 7, 8]: var es2 = load("res://core/unit_container.gd").new(_db) - es2.spawn(2, int(depth), int(_db.hero.max_hp)) + es2.spawn(2, int(depth), int(_db.hero.max_hp), [], -1, -1, [], "", _row_kind_of(2, int(depth))) for u in es2.all_enemies(): if es2.rollable_skills(u).is_empty(): rollable_ok = false @@ -4393,7 +4496,7 @@ func _smoke_skill_pool_death_void() -> void: for s in range(1, 400): var rg = load("res://core/rng_service.gd").new() rg.set_master_seed(s) - var erow: Dictionary = _db.resolve_enemy_rowset(1, 2)[0] # 行集首行(T2 机械适配) + var erow: Dictionary = _db.resolve_enemy_rowset(1, 2, "battle")[0] # 行集首行(战斗节点 battle 键域) var entries: Array = [] for entry in erow.pool: entries.append({"skill_id": String(entry.skill_id), "weight": int(entry.weight)}) @@ -4500,7 +4603,7 @@ func _smoke_debuff_rider() -> void: var settlement = load("res://core/battle_orchestrator.gd").new(_db, rg) var rows: Array = [] settlement.step_row.connect(func(row): rows.append(row)) - if not settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp)}): + if not settlement.begin_battle({"level_index": 2, "node_depth": 9, "hero_hp": int(_db.hero.max_hp), "node_type": "boss"}): continue var boss_atk: int = int(settlement._enemy_system.all_enemies()[0].atk) # 开战掷定攻击基线(毒分量随其缩放) for i in range(6): @@ -4670,53 +4773,54 @@ func _smoke_level_graph() -> void: check(green_ok, "首遇位零踩雷:完美档 12 种子(节点 0 熊地精)恒零踩雷") check(green_wins >= 7, "首遇战胜局 ≥7/12(实际 %d——阶段拔高后锚行退出绿档,多数胜口径)" % green_wins) check(green_min_hp > 0, "首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green_min_hp) - # ⑦ 跨表行完整:L2 每战斗/Boss 节点 (2, 编号) 在 board_gen 与 enemy 精确行集(加载校验同款显式复述; - # 精确行集口径——enemy 侧同坐标多行合法,键聚合坐标级天然兼容) + # ⑦ 跨表行完整:L2 每战斗/Boss 节点在 board_gen 与 enemy 精确行集(加载校验同款显式复述; + # enemy 键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域) var bg_keys := {} for row in _db.board_gen.rows: bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true var en_keys := {} for row in _db.enemy.rows: - en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true + en_keys["%d_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true var cross_ok := true for node in _level_active_nodes(2): var t := String(node.get("type", "")) if t != "battle" and t != "boss": continue - var key := "2_%d" % int(node.node_depth) - if not bg_keys.has(key) or not en_keys.has(key): + if not bg_keys.has("2_%d" % int(node.node_depth)) or not en_keys.has("2_%s_%d" % [t, int(node.node_depth)]): cross_ok = false - check(cross_ok, "跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点)") - # ⑦a 定点:L2 首遇行集与 Boss 行集(精确行集口径——断言行集构成而非单行: - # 零内容期每坐标恰一行 = 单元素行集;行序 = 文件序,首行即叙述取数面) - var l2_spider_set: Array = _db.resolve_enemy_rowset(2, 4) + check(cross_ok, "跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (2,row_kind,depth_band))") + # ⑦a 定点:L2 首遇带行集与 Boss 行集(首遇带 = 数据驱动最低带,契约 8——禁写死坐标; + # 断言行集构成而非单行:行序 = 文件序,首行即叙述取数面) + var l2_spider_band: int = _first_encounter_band(2, "giant_spider") + var l2_spider_set: Array = _db.resolve_enemy_rowset(2, l2_spider_band, "battle") var l2_spider_ids := {} for r in l2_spider_set: l2_spider_ids[String(r.get("enemy_id", ""))] = true - check(l2_spider_ids.has("giant_spider") and l2_spider_ids.size() == 1 and l2_spider_set.size() == 1, - "巨蜘蛛首遇行集 (2,4) = {giant_spider} 恰一行(固定 2 只——首遇教学位不加骰;行集口径断言——行集构成纪律下单行集 = 现行形态)") - var l2_boss_set: Array = _db.resolve_enemy_rowset(2, 9) + check(l2_spider_band >= 0 and l2_spider_ids.has("giant_spider") and l2_spider_ids.size() == 1 and l2_spider_set.size() == 1 \ + and int(l2_spider_set[0].get("unit_count", -1)) == 2 and not l2_spider_set[0].has("unit_count_max"), + "巨蜘蛛首遇带(2,battle,%d——扫表取最低带)行集 = {giant_spider} 恰一行且 unit_count=2 无 unit_count_max(固定 2 只——首遇教学位不加骰)〔fv22〕" % l2_spider_band) + var l2_boss_set: Array = _db.resolve_enemy_rowset(2, _boss_depth(2), "boss") var l2_boss_ids := {} for r in l2_boss_set: l2_boss_ids[String(r.get("enemy_id", ""))] = true check(l2_boss_ids.has("drider") and l2_boss_ids.size() == 1 and String(l2_boss_set[0].get("armor_type", "")) == "ward", - "蛛化卓尔精灵 Boss 行集 (2,9) = {drider} 恰一行且首行魔法甲(护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲;商店房批重编号 (2,8)→(2,9);P4 乙 Boss 行集多行合法——零内容期仍单行)") - # ⑦b 定点:混编遭遇行集 (2,5)(R6 行序契约运行面: + "蛛化卓尔精灵 Boss 行集(2,boss,%d)= {drider} 恰一行且首行魔法甲(护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲;P4 乙 Boss 行集多行合法——零内容期仍单行)〔fv24〕" % _boss_depth(2)) + # ⑦b 定点:混编遭遇行集 (2,battle,5)(R6 行序契约运行面: # 行集两行文件内相邻、行序 = 声明序 = 生成序;静态最坏骰和 Σ(unit_count_max,固定行缺省 = unit_count) ≤ 4) - var l2_mix_set: Array = _db.resolve_enemy_rowset(2, 5) + var l2_mix_set: Array = _db.resolve_enemy_rowset(2, 5, "battle") var mix_shape_ok: bool = l2_mix_set.size() == 2 \ and String(l2_mix_set[0].get("enemy_id", "")) == "bugbear" \ and String(l2_mix_set[1].get("enemy_id", "")) == "giant_spider" check(mix_shape_ok, - "首批混编行集 (2,5) = 双行且行序 [bugbear 重击手 → giant_spider 巢群](R6 行序契约——文件序 = 声明序 = 生成序,首行默认目标 = 前排坦克;迭代 18 卡⑤投放)") + "首批混编行集 (2,battle,5) = 双行且行序 [bugbear 重击手 → giant_spider 巢群](R6 行序契约——文件序 = 声明序 = 生成序,首行默认目标 = 前排坦克;迭代 18 卡⑤投放)〔fv18〕") var mix_sum_max := 0 for r in l2_mix_set: mix_sum_max += int(r.get("unit_count_max", int(r.get("unit_count", 1)))) check(mix_sum_max <= 4, - "首批混编行集 (2,5) 静态最坏骰和 = Σ(unit_count_max,固定行缺省=unit_count) = %d ≤ 4(行集实例总数守卫同口径——重击手固定 1 + 巢群 [2,3])" % mix_sum_max) + "首批混编行集 (2,battle,5) 静态最坏骰和 = Σ(unit_count_max,固定行缺省=unit_count) = %d ≤ 4(行集实例总数守卫同口径——重击手固定 1 + 巢群 [2,3])〔fv19〕" % mix_sum_max) check(int(l2_mix_set[1].get("unit_count", 0)) == 2 and int(l2_mix_set[1].get("unit_count_max", 0)) == 3 \ and int(l2_mix_set[1].get("hp", 0)) == 26 and int(l2_mix_set[1].get("armor_class", -1)) == 2, - "首批混编新行 (2,5,giant_spider) 行值 = 复制 (2,6) 巢群行(P4 甲零数值新增——2-3 只/hp 26/atk [1,2]/light/ac 2;既有 19 行现值零变更)") + "首批混编新行 (2,battle,5,giant_spider) 行值 = 复制巢群行(P4 甲零数值新增——2-3 只/hp 26/atk [1,2]/light/ac 2;既有 19 行现值零变更)") # ---- L3 灰矮人矿城断言段 ---- # ⑧ L3 行集结构(复用 L2 九节点图模板——6 战 + Boss + 奖励 + 恢复,真实岔路 4 处) var l3 := {} @@ -4733,29 +4837,32 @@ func _smoke_level_graph() -> void: check(int(tcount3.battle) == 6 and int(tcount3.boss) == 1, "L3 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)") check(int(tcount3.reward) == 1 and int(tcount3.rest) == 1 and int(tcount3.shop) == 1, "L3 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)") check(branch3 == 3, "L3 真实岔路 = %d 处(0→[1,2,3,4,5] · 2→[6,7] · 5→[6,7]——U2 返工批五岔环廊层宽 5)" % branch3) - # ⑨ 跨表行完整:L3 每战斗/Boss 节点 (3, 编号) 在 board_gen 与 enemy 精确行集(⑦ 的 bg_keys/en_keys 复用) + # ⑨ 跨表行完整:L3 每战斗/Boss 节点在 board_gen 与 enemy 精确行集(⑦ 的 bg_keys/en_keys 复用—— + # 键域同形:(L,row_kind,depth_band)) var cross_ok3 := true for node in _level_active_nodes(3): var t3 := String(node.get("type", "")) if t3 != "battle" and t3 != "boss": continue - var key3 := "3_%d" % int(node.node_depth) - if not bg_keys.has(key3) or not en_keys.has(key3): + if not bg_keys.has("3_%d" % int(node.node_depth)) or not en_keys.has("3_%s_%d" % [t3, int(node.node_depth)]): cross_ok3 = false - check(cross_ok3, "跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点)") - # ⑩ 定点:L3 首遇行集与 Boss 行集(克制面重轻错开——重甲灰矮人首次登台;行集口径断言同 ⑦a) - var l3_lead_set: Array = _db.resolve_enemy_rowset(3, 0) + check(cross_ok3, "跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点——带键 (3,row_kind,depth_band))") + # ⑩ 定点:L3 首遇带行集与 Boss 行集(克制面重轻错开——重甲灰矮人首次登台;首遇带数据驱动定位同 ⑦a) + var l3_lead_band: int = _first_encounter_band(3, "duergar") + var l3_lead_set: Array = _db.resolve_enemy_rowset(3, l3_lead_band, "battle") var l3_lead_ids := {} for r in l3_lead_set: l3_lead_ids[String(r.get("enemy_id", ""))] = true - check(l3_lead_ids.has("duergar") and l3_lead_ids.size() == 1 and String(l3_lead_set[0].get("armor_type", "")) == "heavy", - "灰矮人首遇行集 (3,0) = {duergar} 恰一行且首行重甲(重甲首次登台——固定 1 只,首遇教学位不加骰)") - var l3_king_set: Array = _db.resolve_enemy_rowset(3, 9) + check(l3_lead_band >= 0 and l3_lead_ids.has("duergar") and l3_lead_ids.size() == 1 \ + and String(l3_lead_set[0].get("armor_type", "")) == "heavy" \ + and int(l3_lead_set[0].get("unit_count", -1)) == 1 and not l3_lead_set[0].has("unit_count_max"), + "灰矮人首遇带(3,battle,%d——扫表取最低带)行集 = {duergar} 恰一行、首行重甲、固定 1 只不加骰〔fv23〕" % l3_lead_band) + var l3_king_set: Array = _db.resolve_enemy_rowset(3, _boss_depth(3), "boss") var l3_king_ids := {} for r in l3_king_set: l3_king_ids[String(r.get("enemy_id", ""))] = true check(l3_king_ids.has("duergar_king") and l3_king_ids.size() == 1 and String(l3_king_set[0].get("armor_type", "")) == "heavy", - "灰矮人石君王 Boss 行集 (3,9) = {duergar_king} 恰一行且首行重甲(重甲收束「轻甲→魔法甲→重甲」教学梯度;商店房批重编号 (3,8)→(3,9))") + "灰矮人石君王 Boss 行集(3,boss,%d)= {duergar_king} 恰一行且首行重甲(重甲收束「轻甲→魔法甲→重甲」教学梯度)〔fv24〕" % _boss_depth(3)) # ⑪ L3 成长代理档首遇绿档:模拟 GP 商店成长后的入关参数(atk_pct=200 / hp_pct=100)· 完美档多种子首遇战稳过 # (零加成基线红档属预期——断言口径 = 成长代理档,非基线;跑法同 ⑥) var green3_ok := true @@ -4781,23 +4888,23 @@ func _smoke_level_graph() -> void: check(green3_ok, "L3 首遇位绿档:成长代理档口径(atk_pct=200/hp_pct=100)完美档 12 种子首遇战(节点 0 灰矮人)全胜且零踩雷") check(green3_min_hp > 0, "L3 首遇战胜后 HP 下探读数 %d(>0——绿档记录)" % green3_min_hp) - # ⑫ 混编行集运行面正反断言:注入坐标 (1,5)/(1,6) = - # L1 节点图与全部既有扫描面之外的新深度(ceil 请求命中注入行集——新 L 会被 ceil 封顶到 L3 不可用), + # ⑫ 混编行集运行面正反断言:注入带键 (1,battle,5)/(1,battle,6) = + # L1 节点图与全部既有扫描面之外的新带键(带命中精确取注入行集——新 L 会被 ceil 封顶到 L3 不可用), # 断言后立即还原表,后续断言面零污染。R3 fail-fast 正反验证 + R6 行序运行面验证。 - var mix_row_a := {"level_index": 1, "node_depth": 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, "node_depth": 5, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 2, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]} + 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.enemy.rows.append(mix_row_a) _db.enemy.rows.append(mix_row_b) var mix_sys = load("res://core/unit_container.gd").new(_db) - check(mix_sys.spawn(1, 5, int(_db.hero.max_hp)), "混编行集 spawn 受理((1,5) 双行 地精1+熊地精2——定值无区间路径,行集构成纪律放开面正向)") + check(mix_sys.spawn(1, 5, int(_db.hero.max_hp)), "混编行集 spawn 受理((1,battle,5) 双行 地精1+熊地精2——定值无区间路径,行集构成纪律放开面正向)") var mix_units: Array = mix_sys.all_enemies() check(mix_units.size() == 3, "混编实例总数 = 各行数量求和(1+2=3——行集实例总数 ≤4 守卫内)") check(String(mix_units[0].get("enemy_id")) == "goblin" and String(mix_units[1].get("enemy_id")) == "bugbear" and String(mix_units[2].get("enemy_id")) == "bugbear", "混编生成序 = 文件行序(行序即契约——地精在前熊地精在后,handle_id 跨行连续派发)") check(String(mix_units[0].get("display_name")) == "地精" and int(mix_units[1].get("hp")) == 20 and int(mix_units[1].get("atk")) == 3, "混编各行行值独立投影(display_name/hp/atk 按各自行值——同场不同种不同值)") - _db.enemy.rows.append({"level_index": 1, "node_depth": 6, "enemy_id": "goblin", "display_name": "地精", "unit_count": 3, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}) - _db.enemy.rows.append({"level_index": 1, "node_depth": 6, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 3, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]}) + _db.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "goblin", "display_name": "地精", "unit_count": 3, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}) + _db.enemy.rows.append({"level_index": 1, "row_kind": "battle", "depth_band": 6, "enemy_id": "bugbear", "display_name": "熊地精", "unit_count": 3, "hp": 20, "atk": 3, "pool": [{"skill_id": "attack", "weight": 9}]}) var over_sys = load("res://core/unit_container.gd").new(_db) check(not over_sys.spawn(1, 6, int(_db.hero.max_hp)), "行集实例总数守卫:两行各定值 3(总 6 > 4)spawn 被拒(行集构成纪律③——R3 fail-fast 运行面)") _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) @@ -4930,7 +5037,7 @@ func _smoke_gp_escrow() -> void: var settle_l = load("res://core/battle_orchestrator.gd").new(_db, rng_l) var fin_l := {} settle_l.battle_finished.connect(func(p): fin_l["p"] = p) - check(settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999}), + check(settle_l.begin_battle({"level_index": 1, "node_depth": 4, "hero_hp": int(_db.hero.max_hp), "hero_atk": 999, "node_type": "boss"}), "破财直验战斗启动") for i in range(3): settle_l._grant_routed({"reward_id": "gp_lose", "cell_type": "gp", "polarity": "negative", "display_name": "破财", "power": 2, "effect_id": "gp_loss", "stacks": 1}, @@ -4938,7 +5045,11 @@ func _smoke_gp_escrow() -> void: check(int(settle_l.escrow) == -6, "破财 ×3 → escrow −6(负向缓冲记账,零钱包触达)") settle_l.minesweeper.settle_batch([{"action": "stop"}]) var gp_l: int = int(fin_l.get("p", {}).get("gp_dropped", 999)) - check(gp_l + 6 >= 2 and gp_l + 6 <= 4, "破财压制 gp_dropped 负值上抛(%d + 6 ∈ [2,4]——掷定不受破财污染)" % gp_l) + var boss_drop: Dictionary = _db.shop_gp_drop_row("boss") + var boss_bonus1: int = _db.shop_level_gp_bonus(1) + check(gp_l + 6 >= int(boss_drop.get("roll_min", 0)) + boss_bonus1 and gp_l + 6 <= int(boss_drop.get("roll_max", 0)) + boss_bonus1, + "破财压制 gp_dropped 负值上抛(%d + 6 = %d ∈ Boss 档掷定 [%d,%d] + 关级加值 %d——掷定不受破财污染)" % [ + gp_l, gp_l + 6, int(boss_drop.get("roll_min", 0)), int(boss_drop.get("roll_max", 0)), boss_bonus1]) # ③ 钱包不透支(clamp ≥0):runner 驱动 L1 首战 + 战斗中注入破财 → gp_dropped 负值 → 钱包钳制归 0 var runner = load("res://core/level_runner.gd").new(_db, _fresh_rng(4242)) runner.set_hero_override(49850, 0) # atk 2 → 999(快速终局;hp 基线) @@ -5138,17 +5249,17 @@ func _smoke_shop_graph() -> void: if not p.has(8): via_shop = false check(paths.size() >= 2 and via_shop, "L%d 全部 %d 条入口→Boss 路径穿商店房 8(必经位——DFS 枚举)" % [L, paths.size()]) - # 跨表校验:(·,9) Boss 行精确存在 · (·,8) 无战斗行残留(陈旧行清理确认) + # 跨表校验:(·,boss,9) Boss 行精确存在 · (·,battle,8) 无战斗行残留(陈旧行清理确认) var bg_keys := {} for row in _db.board_gen.rows: bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true var en_keys := {} for row in _db.enemy.rows: - en_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true + en_keys["%d_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true for L in [2, 3]: - check(bg_keys.has("%d_9" % L) and en_keys.has("%d_9" % L), "(%d,9) Boss 行在 board_gen + enemy 精确存在" % L) - check(not bg_keys.has("%d_8" % L) and not en_keys.has("%d_8" % L), "(%d,8) 商店房无板/行集残留(陈旧行已清理)" % L) - check(bg_keys.has("1_4") and en_keys.has("1_4") and not bg_keys.has("1_8") and not en_keys.has("1_8"), + check(bg_keys.has("%d_9" % L) and en_keys.has("%d_boss_9" % L), "(%d,boss,9) Boss 行在 board_gen + enemy 精确存在" % L) + check(not bg_keys.has("%d_8" % L) and not en_keys.has("%d_battle_8" % L), "(%d,battle,8) 商店房无板/战斗行集残留(陈旧行已清理)" % L) + check(bg_keys.has("1_4") and en_keys.has("1_boss_4") and not bg_keys.has("1_8") and not en_keys.has("1_battle_8"), "L1 行集零改动(单链 Boss (1,4)——教学期最小修订面)") diff --git a/game/tests/unit/battle_settlement/skill_unlock_test.gd b/game/tests/unit/battle_settlement/skill_unlock_test.gd index 54fa6d1a..9dd35a37 100644 --- a/game/tests/unit/battle_settlement/skill_unlock_test.gd +++ b/game/tests/unit/battle_settlement/skill_unlock_test.gd @@ -24,9 +24,9 @@ func _init() -> void: # 门谓词:卓尔 (2,9) hp 130(×mult 1.0)→ venom_curb unlock 50 → 阈值 round(130×0.5)=65 -func _boss(db, L: int, D: int): +func _boss(db, L: int, D: int, kind := "boss"): var ec = UnitContainerScript.new(db) - var ok: bool = ec.spawn(L, D, int(db.hero.max_hp)) + var ok: bool = ec.spawn(L, D, int(db.hero.max_hp), [], -1, -1, [], "", "", kind) Kit.check("前置:spawn (%d,%d) 受理" % [L, D], ok) return ec @@ -82,7 +82,7 @@ func _test_latch_irreversible(db) -> void: # 普通行无字段逐位零扰动:地精 (1,0) 池快照零 unlock 键、过滤恒空转、可摇=全池 func _test_plain_rows_zero_drift(db) -> void: - var ec = _boss(db, 1, 0) + var ec = _boss(db, 1, 0, "battle") var u: Dictionary = _boss_u(ec) var any_unlock_key := false for e in u.pool: @@ -94,7 +94,7 @@ func _test_plain_rows_zero_drift(db) -> void: var pool_count: int = (u.pool as Array).size() Kit.check("零扰动:可摇集合 = 全池(普攻冷却恒 0,过滤恒空转)", rollable.size() == pool_count) # 同种子确定性:容器零随机——两实例同输入查询逐位同 - var ec2 = _boss(db, 1, 0) + var ec2 = _boss(db, 1, 0, "battle") var u2: Dictionary = _boss_u(ec2) ec.apply_damage(u, 3) ec2.apply_damage(u2, 3) diff --git a/game/tests/unit/core/config_db_cross_validator_test.gd b/game/tests/unit/core/config_db_cross_validator_test.gd index 9d18836f..f3fac232 100644 --- a/game/tests/unit/core/config_db_cross_validator_test.gd +++ b/game/tests/unit/core/config_db_cross_validator_test.gd @@ -16,6 +16,7 @@ func _init() -> void: _test_all_links_valid() _test_dangling_skill_id() _test_cast_side_mismatch() + _test_level_node_band_rules() quit(Kit.finish("config_db_cross_validator_test")) @@ -51,7 +52,7 @@ func _test_all_links_valid() -> void: {"skill_id": "raise_morale", "type": "buff", "buff_ref": "st_strong"}, ]) var enemies := _enemy([ - {"level_index": 1, "node_depth": 0, "enemy_id": "goblin", "pool": [ + {"level_index": 1, "row_kind": "battle", "depth_band": 0, "enemy_id": "goblin", "pool": [ {"skill_id": "attack", "weight": 9}, {"skill_id": "raise_morale", "weight": 3}]}, ]) var status := _status([{"effect_id": "st_strong", "target_side": "enemy"}]) @@ -63,7 +64,7 @@ func _test_all_links_valid() -> void: func _test_dangling_skill_id() -> void: var skills := _skill([{"skill_id": "attack", "type": "basic"}]) var enemies := _enemy([ - {"level_index": 1, "node_depth": 0, "enemy_id": "goblin", "pool": [ + {"level_index": 1, "row_kind": "battle", "depth_band": 0, "enemy_id": "goblin", "pool": [ {"skill_id": "attack", "weight": 9}, {"skill_id": "not_in_table", "weight": 3}]}, ]) var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, null) @@ -76,9 +77,67 @@ func _test_dangling_skill_id() -> void: func _test_cast_side_mismatch() -> void: var skills := _skill([{"skill_id": "raise_morale", "type": "buff", "buff_ref": "st_hero_side"}]) var enemies := _enemy([ - {"level_index": 1, "node_depth": 0, "enemy_id": "goblin", "pool": [ + {"level_index": 1, "row_kind": "battle", "depth_band": 0, "enemy_id": "goblin", "pool": [ {"skill_id": "raise_morale", "weight": 3}]}, ]) var status := _status([{"effect_id": "st_hero_side", "target_side": "hero"}]) var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, status) Kit.check("buff 挂主角侧状态被报(施放类 side 守卫)", _cats(errs).has("cast_side")) + + +# ---- ④ 节点 × 敌人表带寻址(单终点零误报/同带双 Boss 报错/顶 clamp 消解报错)---- +const LevelTableScript := preload("res://data/schemas/level_table.gd") +const BoardGenTableScript := preload("res://data/schemas/board_gen_table.gd") +const RewardTableScript := preload("res://data/schemas/reward_table.gd") +const SettlementTableScript := preload("res://data/schemas/settlement_table.gd") + + +func _loaded(level_nodes: Array, bg_depths: Array, boss_bands: Array, battle_bands: Array) -> Dictionary: + var lv := LevelTableScript.new() + lv.rows.assign([{"level_index": 1, "display_name": "夹具关", "templates": [{"nodes": level_nodes}]}]) + var bg := BoardGenTableScript.new() + var bg_rows: Array = [] + for d in bg_depths: + bg_rows.append({"level_index": 1, "node_depth": int(d), "board_width": 10}) + bg.rows.assign(bg_rows) + var en := EnemyTableScript.new() + var en_rows: Array = [] + for b in battle_bands: + en_rows.append({"level_index": 1, "row_kind": "battle", "depth_band": int(b), "enemy_id": "goblin", + "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}) + for b in boss_bands: + en_rows.append({"level_index": 1, "row_kind": "boss", "depth_band": int(b), "enemy_id": "ogre_chief", + "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]}) + en.rows.assign(en_rows) + var st := SettlementTableScript.new() + st.source_label_map.assign({"safe_cell": "a", "seal_hit": "b", "mine_hit": "c", "clear_reward": "d", + "hero_attack": "e", "enemy_action": "f", "seal_miss": "g"}) + return {"status_effects": StatusTableScript.new(), "reward": RewardTableScript.new(), + "settlement": st, "level": lv, "board_gen": bg, "enemy": en} + + +func _cross_cats(loaded: Dictionary, needle: String) -> Array[String]: + var out: Array[String] = [] + ValidatorScript.new().cross_validate(loaded, + func(_sev, cat, msg) -> void: + if String(msg).contains(needle): + out.append(String(cat))) + return out + + +func _test_level_node_band_rules() -> void: + # 正向:单终点三关同构(battle 节点落 band 0、boss 节点精确落顶带)→ ④ 零误报 + var ok_nodes := [{"type": "battle", "node_depth": 0}, {"type": "boss", "node_depth": 9}] + var ok_cats := _cross_cats(_loaded(ok_nodes, [0, 9], [9], [0]), "level 1") + Kit.check("单终点图 ④ 敌侧带寻址零误报〔fv17〕", ok_cats.is_empty()) + # 负向:一路两终点落同一 Boss 带(深度 3/4 都落带 4)→ rowset_rule + var same_band_nodes := [{"type": "battle", "node_depth": 0}, + {"type": "boss", "node_depth": 3}, {"type": "boss", "node_depth": 4}] + var same_band_cats := _cross_cats(_loaded(same_band_nodes, [0, 3, 4], [4, 9], [0]), "同带") + Kit.check("同关双终点共带报 rowset_rule〔fv15〕", + same_band_cats.has("rowset_rule")) + # 负向:Boss 节点深度高于顶带键(顶 clamp 消解)→ level_node_dangling + var over_nodes := [{"type": "battle", "node_depth": 0}, {"type": "boss", "node_depth": 10}] + var over_cats := _cross_cats(_loaded(over_nodes, [0, 10], [9], [0]), "顶 clamp 消解") + Kit.check("Boss 节点越顶带报 level_node_dangling〔fv16〕", + over_cats.has("level_node_dangling")) diff --git a/game/tests/unit/core/config_db_resolver_test.gd b/game/tests/unit/core/config_db_resolver_test.gd index 132d5cb5..cc60df4a 100644 --- a/game/tests/unit/core/config_db_resolver_test.gd +++ b/game/tests/unit/core/config_db_resolver_test.gd @@ -1,6 +1,6 @@ # tests/unit/core/config_db_resolver_test.gd — 配置解析直接单测 -# 断言面 = ceil 进度键解析语义(向上取整保证偏难)、行集多行的行序保序、 -# 难度修饰乘数的 clamp 域。行集由夹具注入,不碰生产表——期望值由注入行数出来。 +# 断言面 = 深度带寻址语义(请求落带取偏难一侧、行集多行行序保序、越顶带 clamp+告警去重、 +# 行类路由、越域 fatal)、难度修饰乘数的 clamp 域。行集由夹具注入,不碰生产表——期望值由注入行数出来。 # 运行:godot --headless --path game --script res://tests/unit/core/config_db_resolver_test.gd extends SceneTree @@ -19,8 +19,11 @@ func _init() -> void: func() -> Array: return _enemy_rows(), func(): return _difficulty_table(), ) - _test_ceil_progress_key() + _test_band_hit() _test_rowset_order_preserved() + _test_top_band_clamp() + _test_row_kind_routing() + _test_request_oob() _test_difficulty_clamp() quit(Kit.finish("config_db_resolver_test")) @@ -33,11 +36,13 @@ func _board_gen_rows() -> Array: ] +# 带键 [0,5]:请求 4 未精确落键,取首个 ≥4 的带 5(偏难一侧),不回带 0 func _enemy_rows() -> Array: return [ - {"level_index": 1, "node_depth": 0, "enemy_id": "goblin"}, - {"level_index": 1, "node_depth": 0, "enemy_id": "bugbear"}, - {"level_index": 1, "node_depth": 5, "enemy_id": "hobgoblin"}, + {"level_index": 1, "row_kind": "battle", "depth_band": 0, "enemy_id": "goblin"}, + {"level_index": 1, "row_kind": "battle", "depth_band": 0, "enemy_id": "bugbear"}, + {"level_index": 1, "row_kind": "battle", "depth_band": 5, "enemy_id": "hobgoblin"}, + {"level_index": 1, "row_kind": "boss", "depth_band": 9, "enemy_id": "ogre_chief"}, ] @@ -48,26 +53,65 @@ func _difficulty_table(): return _FakeDifficulty.new(rows) -# ceil 语义:请求 (1,1) 落在 d=0 与 d=3 之间,向上取到 d=3(偏难),不是向下取 d=0 -func _test_ceil_progress_key() -> void: - var hit: Dictionary = _resolver.resolve_board_gen(1, 1) - Kit.check("ceil 进度键:请求 (1,1) 命中 (1,3) 而非 (1,0)——偏难语义", - int(hit.get("node_depth", -1)) == 3 and int(hit.get("board_width", 0)) == 12) - var exact: Dictionary = _resolver.resolve_board_gen(2, 0) - Kit.check("精确命中:(2,0) 取本行", int(exact.get("board_width", 0)) == 14) +func _test_band_hit() -> void: + var hit: Array = _resolver.resolve_enemy_rowset(1, 4, "battle") + Kit.check("带命中取偏难一侧:请求深度 4 命中带 5(逐字不回带 0)〔fv1〕", + hit.size() == 1 and String((hit[0] as Dictionary).get("enemy_id", "")) == "hobgoblin") + var exact: Array = _resolver.resolve_enemy_rowset(1, 5, "battle") + Kit.check("精确落带:请求深度 5 命中带 5〔fv1〕", + exact.size() == 1 and String((exact[0] as Dictionary).get("enemy_id", "")) == "hobgoblin") func _test_rowset_order_preserved() -> void: - var rows: Array = _resolver.resolve_enemy_rowset(1, 0) - Kit.check("行集返回同坐标全部行(2 行)", rows.size() == 2) + var rows: Array = _resolver.resolve_enemy_rowset(1, 0, "battle") + Kit.check("行集返回同带全部行(2 行)〔fv2〕", rows.size() == 2) var ids: Array[String] = [] for r in rows: ids.append(String((r as Dictionary).get("enemy_id", ""))) - Kit.check("行集按表文件行序返回(goblin 先于 bugbear——行序即契约)", + Kit.check("行集按表文件行序返回(goblin 先于 bugbear——行序即契约)〔fv2〕", ids == ["goblin", "bugbear"]) - var far: Array = _resolver.resolve_enemy_rowset(1, 4) - Kit.check("ceil 行集:请求 (1,4) 命中 (1,5)", far.size() == 1 - and String((far[0] as Dictionary).get("enemy_id", "")) == "hobgoblin") + + +func _test_top_band_clamp() -> void: + _diags.clear() + var rows: Array = _resolver.resolve_enemy_rowset(1, 99, "battle") + Kit.check("越顶带 clamp 取顶带行集(battle 顶带 5)〔fv3〕", + rows.size() == 1 and String((rows[0] as Dictionary).get("enemy_id", "")) == "hobgoblin") + var clamp_hit := false + for d in _diags: + if d.contains("warning/resolve_depth_clamp"): + clamp_hit = true + Kit.check("越顶带发 warning resolve_depth_clamp〔fv3〕", clamp_hit) + var warn_before: int = _diags.size() + _resolver.resolve_enemy_rowset(1, 99, "battle") + Kit.check("同键重复请求警告去重(四元组注册表)〔fv3〕", _diags.size() == warn_before) + + +func _test_row_kind_routing() -> void: + var battle_rows: Array = _resolver.resolve_enemy_rowset(1, 0, "battle") + var boss_rows: Array = _resolver.resolve_enemy_rowset(1, 0, "boss") + Kit.check("行类路由:同请求深度 battle 与 boss 命中不同键域〔fv4〕", + battle_rows.size() == 2 and boss_rows.size() == 1 + and String((boss_rows[0] as Dictionary).get("enemy_id", "")) == "ogre_chief") + + +func _test_request_oob() -> void: + _diags.clear() + _resolver.resolve_enemy_rowset(0, 0, "battle") + Kit.check("关卡序号 <1 → fatal request_oob〔fv5〕", _has_fatal_oob()) + _diags.clear() + _resolver.resolve_enemy_rowset(1, -1, "battle") + Kit.check("节点深度 <0 → fatal request_oob〔fv5〕", _has_fatal_oob()) + _diags.clear() + _resolver.resolve_enemy_rowset(1, 0, "elite") + Kit.check("row_kind 非法 → fatal request_oob〔fv5〕", _has_fatal_oob()) + + +func _has_fatal_oob() -> bool: + for d in _diags: + if d.contains("fatal/request_oob"): + return true + return false func _test_difficulty_clamp() -> void: diff --git a/game/tests/unit/data_config/config_db_test.gd b/game/tests/unit/data_config/config_db_test.gd index dca3b48f..3d229531 100644 --- a/game/tests/unit/data_config/config_db_test.gd +++ b/game/tests/unit/data_config/config_db_test.gd @@ -11,7 +11,7 @@ const Kit := preload("res://tests/unit/test_kit.gd") # T6 收口:_check/汇 func _init() -> void: var db = ConfigDBScript.new() var load_ok: bool = db.load_tables("res://data/tables", true) - Kit.check("全部表加载 + 校验通过(含跨表护栏零 error 诊断)", load_ok and db.diagnostics_count("error") == 0) + Kit.check("全部表加载 + 校验通过(含跨表护栏零 error 诊断)〔fv6〕", load_ok and db.diagnostics_count("error") == 0) Kit.check("is_ready 通道随加载置位", db.is_ready) if not load_ok: for d in db.diagnostics(): @@ -59,8 +59,8 @@ func _test_progress_resolution(db) -> void: # enemy 行集同通道:同 (L,D) 行集数组非空(关卡节点地图 前置——battle 节点双表精确行集契约; # 行集面契约:返回形态是行集数组,精确性断言随行集面) - var en_got: Array = db.resolve_enemy_rowset(int(probe_row.level_index), int(probe_row.node_depth)) - Kit.check("enemy 行集同坐标解析非空(双表精确行契约)", not en_got.is_empty()) + var en_got: Array = db.resolve_enemy_rowset(int(probe_row.level_index), int(probe_row.node_depth), "battle") + Kit.check("enemy 行集同坐标解析非空(双表精确行契约——battle 行类)", not en_got.is_empty()) # 深度 clamp:请求深度超最大行 → 取该关最大深度行 + 警告诊断一次(去重) db.reset_diagnostics() @@ -197,44 +197,46 @@ func _test_rowset_discipline() -> void: # 数组字面量会触发「Invalid assignment」脚本错误——该错误不改退出码,却会**中断本函数剩余断言** #(本函数五条纪律断言曾因此全部静默不跑,套件仍报 all green,是零覆盖假象)。 const EnemyTableScript := preload("res://data/schemas/enemy_table.gd") - var mk_row := func(l: int, d: int, eid: String) -> Dictionary: - return {"level_index": l, "node_depth": d, "enemy_id": eid, "display_name": eid + "名", + var mk_row := func(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}]} - # 正向:同坐标两行不同敌种(相邻)= 混编合法,validate 零错 + # 正向:同带键两行不同敌种(相邻)= 混编合法,validate 零错 var t_mix = EnemyTableScript.new() - t_mix.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "bugbear"), - mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行(validate 的 anchor 规则:每 level 必须有) + t_mix.rows.assign([mk_row.call(1, "battle", 5, "goblin"), mk_row.call(1, "battle", 5, "bugbear"), + mk_row.call(1, "battle", 0, "goblin"), mk_row.call(1, "boss", 9, "ogre_chief")]) var errs: Array = t_mix.validate() - Kit.check("混编行集加载通过(同坐标两行不同敌种相邻——行集恰一敌种校验退役的正向面)", errs.is_empty()) - # 正向:Boss 坐标两行同构合法(P4 乙——Boss 行集校验同样放开,加载层无 Boss 单行硬闸) + Kit.check("混编行集加载通过(同带键两行不同敌种相邻——行集恰一敌种校验退役的正向面)", errs.is_empty()) + # 正向:Boss 行集两行同构合法(P4 乙——Boss 行集校验同样放开,加载层无 Boss 单行硬闸) var t_boss = EnemyTableScript.new() - t_boss.rows.assign([mk_row.call(2, 9, "drider"), mk_row.call(2, 9, "bugbear"), - mk_row.call(2, 0, "drider")]) # 末行=depth-0 锚行 + t_boss.rows.assign([mk_row.call(2, "battle", 0, "drider"), mk_row.call(2, "boss", 9, "drider"), + mk_row.call(2, "boss", 9, "bugbear")]) errs = t_boss.validate() - Kit.check("Boss 坐标混编行集加载通过(P4 乙——Boss 多行合法,无硬闸)", errs.is_empty()) - # 负向:同坐标行不相邻 = rowset_rule 且报错定位到坐标(R6 行序纪律 + R3 错误类别可定位) + Kit.check("Boss 带混编行集加载通过(P4 乙——Boss 多行合法,无硬闸)", errs.is_empty()) + # 负向:同行集行不相邻 = rowset_rule 且报错定位到带键(R6 行序纪律 + R3 错误类别可定位) var t_gap = EnemyTableScript.new() - t_gap.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 9, "hobgoblin"), mk_row.call(1, 5, "bugbear"), - mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行(隔断关系不受影响) + t_gap.rows.assign([mk_row.call(1, "battle", 5, "goblin"), mk_row.call(1, "battle", 9, "hobgoblin"), + mk_row.call(1, "battle", 5, "bugbear"), mk_row.call(1, "battle", 0, "goblin"), + mk_row.call(1, "boss", 9, "ogre_chief")]) errs = t_gap.validate() var gap_hit := false for e in errs: - if String(e.get("category", "")) == "rowset_rule" and String(e.get("message", "")).contains("(1,5)"): + if String(e.get("category", "")) == "rowset_rule" and String(e.get("message", "")).contains("(1,battle,5)"): gap_hit = true - Kit.check("行序相邻校验:同坐标行被隔断报 rowset_rule 且定位坐标(R6——行序即契约加载期兜底)", gap_hit) + Kit.check("行序相邻校验:同带键行被隔断报 rowset_rule 且定位带键〔fv9〕〔fv20〕", gap_hit) # 负向:同敌种第二行仍被 dup_key 拦(键含 enemy_id——纪律①既有通道不回归) var t_dup = EnemyTableScript.new() - t_dup.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "goblin"), - mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行 + t_dup.rows.assign([mk_row.call(1, "battle", 5, "goblin"), mk_row.call(1, "battle", 5, "goblin"), + mk_row.call(1, "battle", 0, "goblin"), mk_row.call(1, "boss", 9, "ogre_chief")]) errs = t_dup.validate() var dup_hit := false for e in errs: if String(e.get("category", "")) == "dup_key": dup_hit = true - Kit.check("行集内同敌种第二行仍被 dup_key 拦(纪律①——enemy_id 唯一)", dup_hit) + Kit.check("同带键同敌种二行被 dup_key 新键拦(纪律①——enemy_id 唯一)〔fv8〕", dup_hit) # 负向:域校验不回归(单行 unit_count 越域仍拦——UNIT_COUNT_MAX 单种数语义下的同款域闸) var t_dom = EnemyTableScript.new() - t_dom.rows.assign([mk_row.call(1, 5, "goblin"), mk_row.call(1, 0, "goblin")]) # 末行=depth-0 锚行 + t_dom.rows.assign([mk_row.call(1, "battle", 5, "goblin"), mk_row.call(1, "battle", 0, "goblin"), + mk_row.call(1, "boss", 9, "ogre_chief")]) t_dom.rows[0]["unit_count"] = 4 errs = t_dom.validate() var dom_hit := false @@ -242,3 +244,30 @@ 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 封闭枚举(非法值拒——键面换代后新增判据) + var t_kind = EnemyTableScript.new() + t_kind.rows.assign([mk_row.call(1, "elite", 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) + # 负向: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")]) + errs = t_no_anchor.validate() + var no_anchor_hit := false + for e in errs: + if String(e.get("category", "")) == "anchor" and String(e.get("message", "")).contains("band 0"): + no_anchor_hit = true + Kit.check("缺 (battle, band 0) 锚行报 anchor〔fv10〕", no_anchor_hit) + var t_no_boss = EnemyTableScript.new() + t_no_boss.rows.assign([mk_row.call(1, "battle", 0, "goblin"), mk_row.call(1, "battle", 5, "bugbear")]) + errs = t_no_boss.validate() + var no_boss_hit := false + for e in errs: + if String(e.get("category", "")) == "anchor" and String(e.get("message", "")).contains("Boss"): + no_boss_hit = true + Kit.check("缺 Boss 行集报 anchor(每关 Boss 行集 ≥1)〔fv10〕", no_boss_hit)