diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 3cbc7597..6fea2ccd 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -291,7 +291,8 @@ func _api_mark_node(_node: Dictionary, _info: Dictionary, seq: Array) -> void: func _smoke_adventure() -> void: print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---") var autoplay = load("res://smoke/level_autoplay.gd") - # ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行——覆盖义务断言) + # ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行集——覆盖义务断言; + # 2026-10-07 行集多行批:「精确行」改「精确行集」口径——enemy 侧同坐标多行合法,坐标级键聚合天然兼容) check(int(_db.level.rows.size()) == 3, "序列关数 = 行集关数(三关正式序列生效)") var bg_keys := {} for row in _db.board_gen.rows: @@ -308,7 +309,7 @@ func _smoke_adventure() -> void: 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): miss += 1 - check(miss == 0, "行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行(缺失 %d)" % miss) + check(miss == 0, "行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行集(缺失 %d)" % miss) # ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复) var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400) var sum: Dictionary = st["summary"] @@ -3048,31 +3049,32 @@ func _smoke_skill_pool_data() -> void: var off_guard_row: Dictionary = _db.status_effect_row("off_guard") check(not off_guard_row.is_empty() and String(off_guard_row.target_side) == "hero" and String(off_guard_row.unit) == "ac", "失衡状态行(hero · ac 轴——妨害技落点,ConfigDB side 守卫正例)") - # 行集键 = (level_index, node_depth)——分支节点图批起 bugbear 等敌种跨关异池,enemy_id 不再唯一 + # 行集键 = (level_index, node_depth, enemy_id)——分支节点图批起 bugbear 等敌种跨关异池,enemy_id 不再唯一; + # 2026-10-07 行集多行批:同坐标混编多行后每行各一池,键升维到三元组(行序 = 文件序 = 契约序) var expect_pools := { - "1_0": {"attack": 9, "lunge": 1}, - "1_1": {"attack": 9, "lunge": 1}, - "1_2": {"attack": 6, "charge": 2, "heavy": 2}, - "1_3": {"attack": 6, "charge": 2, "heavy": 2}, - "1_4": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2}, - "2_0": {"attack": 6, "lunge": 1, "heavy": 2}, - "2_1": {"attack": 6, "lunge": 1, "heavy": 2}, - "2_4": {"attack": 6, "bite": 2, "charge": 1}, - "2_5": {"attack": 5, "lunge": 2, "heavy": 2}, - "2_6": {"attack": 6, "bite": 2, "charge": 1}, - "2_7": {"attack": 6, "bite": 2, "charge": 1}, - "2_9": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2}, - "3_0": {"attack": 6, "charge": 2, "heavy": 2}, - "3_1": {"attack": 6, "bite": 2, "charge": 1}, - "3_4": {"attack": 6, "bite": 2, "charge": 1}, - "3_5": {"attack": 5, "lunge": 2, "heavy": 2}, - "3_6": {"attack": 6, "bite": 2, "charge": 1}, - "3_7": {"attack": 6, "charge": 2, "heavy": 2}, - "3_9": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, + "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_bugbear": {"attack": 5, "lunge": 2, "heavy": 2}, + "2_6_giant_spider": {"attack": 6, "bite": 2, "charge": 1}, + "2_7_giant_spider": {"attack": 6, "bite": 2, "charge": 1}, + "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}, + "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}, + "3_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, + "3_7_duergar": {"attack": 6, "charge": 2, "heavy": 2}, + "3_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" % [int(row.level_index), int(row.node_depth)] + var rk := "%d_%d_%s" % [int(row.level_index), int(row.node_depth), String(row.enemy_id)] if not expect_pools.has(rk): pools_ok = false continue @@ -3081,7 +3083,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, "十九行池模板 = 建议池行值(L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔(2,9)——商店房批 Boss 重编号〕;L3 灰矮人 6/2/2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2 · 石君王 6/2/2〔earth_pulse·(3,9)〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕)") + 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 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔(2,9)——商店房批 Boss 重编号〕;L3 灰矮人 6/2/2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2 · 石君王 6/2/2〔earth_pulse·(3,9)〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同坐标多行各一池合法,零内容期每坐标仍恰一行)") var basic_count_ok := true for row in _db.enemy.rows: var n := 0 @@ -3515,7 +3517,8 @@ 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 精确行(加载校验同款显式复述) + # ⑦ 跨表行完整:L2 每战斗/Boss 节点 (2, 编号) 在 board_gen 与 enemy 精确行集(加载校验同款显式复述; + # 2026-10-07 行集多行批:「精确行」口径改「精确行集」——enemy 侧同坐标多行合法,键聚合坐标级天然兼容) var bg_keys := {} for row in _db.board_gen.rows: bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true @@ -3530,12 +3533,21 @@ func _smoke_level_graph() -> void: var key := "2_%d" % int(node.node_depth) if not bg_keys.has(key) or not en_keys.has(key): cross_ok = false - check(cross_ok, "跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行(7 节点)") - var l2_spider: Dictionary = _db.resolve_enemy_rowset(2, 4)[0] # floor 版退役改 ceil 行集首行(坐标精确命中零行为差——P2 甲) - check(String(l2_spider.get("enemy_id", "")) == "giant_spider", "巨蜘蛛首遇行集 (2,4) = giant_spider(固定 2 只——首遇教学位不加骰)") - var l2_boss: Dictionary = _db.resolve_enemy_rowset(2, 9)[0] # 同上(P2 甲) - check(String(l2_boss.get("enemy_id", "")) == "drider" and String(l2_boss.get("armor_type", "")) == "ward", - "蛛化卓尔精灵 Boss 行集 (2,9) = drider(魔法甲——护甲类型重配批 2026-09-18:关卡 2 唯一非轻甲;商店房批重编号 (2,8)→(2,9))") + check(cross_ok, "跨表行完整:L2 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点)") + # ⑦a 定点:L2 首遇行集与 Boss 行集(2026-10-07 行集多行批口径——断言行集构成而非单行: + # 零内容期每坐标恰一行 = 单元素行集;行序 = 文件序,首行即叙述取数面) + var l2_spider_set: Array = _db.resolve_enemy_rowset(2, 4) + 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) + 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 行集多行合法——零内容期仍单行)") # ---- 第三关内容批(2026-09-20)L3 灰矮人矿城断言段 ---- # ⑧ L3 行集结构(复用 L2 九节点图模板——6 战 + Boss + 奖励 + 恢复,真实岔路 4 处) var l3 := {} @@ -3552,7 +3564,7 @@ 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 节点 (3, 编号) 在 board_gen 与 enemy 精确行集(⑦ 的 bg_keys/en_keys 复用) var cross_ok3 := true for node in _level_active_nodes(3): var t3 := String(node.get("type", "")) @@ -3561,14 +3573,20 @@ func _smoke_level_graph() -> void: var key3 := "3_%d" % int(node.node_depth) if not bg_keys.has(key3) or not en_keys.has(key3): cross_ok3 = false - check(cross_ok3, "跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行(7 节点)") - # ⑩ 定点:L3 首遇行集与 Boss 行集(克制面重轻错开——重甲灰矮人首次登台) - var l3_lead: Dictionary = _db.resolve_enemy_rowset(3, 0)[0] # 同上(P2 甲) - check(String(l3_lead.get("enemy_id", "")) == "duergar" and String(l3_lead.get("armor_type", "")) == "heavy", - "灰矮人首遇行集 (3,0) = duergar(重甲首次登台——固定 1 只,首遇教学位不加骰)") - var l3_king: Dictionary = _db.resolve_enemy_rowset(3, 9)[0] # 同上(P2 甲) - check(String(l3_king.get("enemy_id", "")) == "duergar_king" and String(l3_king.get("armor_type", "")) == "heavy", - "灰矮人石君王 Boss 行集 (3,9) = duergar_king(重甲收束「轻甲→魔法甲→重甲」教学梯度;商店房批重编号 (3,8)→(3,9))") + check(cross_ok3, "跨表行完整:L3 全部战斗/Boss 节点在 board_gen + enemy 精确行集(7 节点)") + # ⑩ 定点:L3 首遇行集与 Boss 行集(克制面重轻错开——重甲灰矮人首次登台;行集口径断言同 ⑦a) + var l3_lead_set: Array = _db.resolve_enemy_rowset(3, 0) + 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) + 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))") # ⑪ L3 成长代理档首遇绿档:模拟 GP 商店成长后的入关参数(atk_pct=200 / hp_pct=100)· 完美档多种子首遇战稳过 # (零加成基线红档属预期——断言口径 = 成长代理档,非基线;跑法同 ⑥) var green3_ok := true @@ -3594,6 +3612,31 @@ 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) + # ⑫ 混编行集运行面正反断言(2026-10-07 行集多行批——T4 组 D 新增):注入坐标 (1,5)/(1,6) = + # L1 节点图与全部既有扫描面之外的新深度(ceil 请求命中注入行集——新 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}]} + _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——定值无区间路径,行集构成纪律放开面正向)") + 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}]}) + 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) + _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) + _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) + _db.enemy.rows.remove_at(_db.enemy.rows.size() - 1) + check(int(_db.enemy.rows.size()) == 19, "混编断言段注入行全量还原(表回 19 行——后续断言面零污染)") + # 冒烟隔离用全新 RngService(同 master 种子——回放确定性纪律) func _fresh_rng(seed_val: int): diff --git a/game/tests/unit/data_config/config_db_test.gd b/game/tests/unit/data_config/config_db_test.gd index 4533217f..1a8786a0 100644 --- a/game/tests/unit/data_config/config_db_test.gd +++ b/game/tests/unit/data_config/config_db_test.gd @@ -23,6 +23,7 @@ func _init() -> void: _test_shop_accessors(db) _test_meta_accessors(db) _test_manifest_dir_sync(db) + _test_rowset_discipline() quit(Kit.finish("config_db_test")) @@ -191,3 +192,52 @@ func _test_manifest_dir_sync(db) -> void: if not disk_files.has(String(manifest[key])): synced = false Kit.check("TABLE_MANIFEST 与 data/tables 目录逐文件同步(无缺登/无悬登)", synced) + + +# ---- 行集构成纪律(2026-10-07 敌人表行集多行批——EnemyTable.validate 正反验证;资源直构不碰生产表)---- +# 纪律三条(敌人系统 v0.15):①行集内 enemy_id 唯一(dup_key 既有通道)②同坐标多行文件内相邻 +# (rowset_rule——行序即契约,R6 加载期兜底)③行集实例总数 ≤4(骰后值运行面,spawn 守卫——冒烟侧验证)。 + +func _test_rowset_discipline() -> void: + 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 + "名", + "unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]} + # 正向:同坐标两行不同敌种(相邻)= 混编合法,validate 零错 + var t_mix = EnemyTableScript.new() + t_mix.rows = [mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "bugbear")] + var errs: Array = t_mix.validate() + Kit.check("混编行集加载通过(同坐标两行不同敌种相邻——行集恰一敌种校验退役的正向面)", errs.is_empty()) + # 正向:Boss 坐标两行同构合法(P4 乙——Boss 行集校验同样放开,加载层无 Boss 单行硬闸) + var t_boss = EnemyTableScript.new() + t_boss.rows = [mk_row.call(2, 9, "drider"), mk_row.call(2, 9, "bugbear")] + errs = t_boss.validate() + Kit.check("Boss 坐标混编行集加载通过(P4 乙——Boss 多行合法,无硬闸)", errs.is_empty()) + # 负向:同坐标行不相邻 = rowset_rule 且报错定位到坐标(R6 行序纪律 + R3 错误类别可定位) + var t_gap = EnemyTableScript.new() + t_gap.rows = [mk_row.call(1, 5, "goblin"), mk_row.call(1, 9, "hobgoblin"), mk_row.call(1, 5, "bugbear")] + 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)"): + gap_hit = true + Kit.check("行序相邻校验:同坐标行被隔断报 rowset_rule 且定位坐标(R6——行序即契约加载期兜底)", gap_hit) + # 负向:同敌种第二行仍被 dup_key 拦(键含 enemy_id——纪律①既有通道不回归) + var t_dup = EnemyTableScript.new() + t_dup.rows = [mk_row.call(1, 5, "goblin"), mk_row.call(1, 5, "goblin")] + 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) + # 负向:域校验不回归(单行 unit_count 越域仍拦——UNIT_COUNT_MAX 单种数语义下的同款域闸) + var t_dom = EnemyTableScript.new() + t_dom.rows = [mk_row.call(1, 5, "goblin")] + t_dom.rows[0]["unit_count"] = 4 + errs = t_dom.validate() + var dom_hit := false + for e in errs: + 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)