From 49dff0853f60f8beff8016dd2f927c65eb85545f Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Fri, 23 Oct 2026 00:58:56 +0800 Subject: [PATCH] =?UTF-8?q?test(=E8=BF=81=E7=A7=BB):=20=E6=89=B9=20B=20?= =?UTF-8?q?=E6=B3=A2=201=20G2=20=E8=A1=A8=E7=BB=93=E6=9E=84=E6=97=8F?= =?UTF-8?q?=E4=B8=83=E5=A5=97=2060=20=E6=9D=A1=E8=BF=81=E5=85=A5=20table?= =?UTF-8?q?=5Fstructure=5Fguard=5Ftest.gd=E2=80=94=E2=80=94=E5=89=AA?= =?UTF-8?q?=E5=88=87=E6=8C=82=E8=BD=BD=E7=BB=BF=EF=BC=8B=E6=8C=89=E6=9D=A1?= =?UTF-8?q?=E5=8F=98=E5=BC=82=2060/60=20=E9=AA=8C=E7=BA=A2=EF=BC=88?= =?UTF-8?q?=E9=9B=B6=E6=AD=BB=E6=96=AD=E8=A8=80=E5=80=99=E9=80=89=EF=BC=9B?= =?UTF-8?q?=E5=9B=9B=E6=9D=A1=E9=A6=96=E8=AF=95=E6=97=A0=E6=95=88=E5=8F=98?= =?UTF-8?q?=E5=BC=82=E5=B7=B2=E8=AE=B0=E8=B4=A6=E6=8D=A2=E5=BD=A2=E6=80=81?= =?UTF-8?q?=EF=BC=89=EF=BC=9Bsmoke=5Fall.gd=20=E5=90=8C=E6=89=B9=E5=88=A0?= =?UTF-8?q?=E5=85=AD=E5=87=BD=E6=95=B0=EF=BC=8B=E5=85=AD=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E8=A1=8C=E3=80=81=5Fsmoke=5Fmeta=20=E7=98=A6=E5=A3=B3=E7=95=99?= =?UTF-8?q?=20G9/G11=20=E8=B0=83=E5=BA=A6=EF=BC=8C=E5=85=B1=E4=BA=AB=20hel?= =?UTF-8?q?per=20=E5=8F=8C=E4=BB=BD=E8=BF=87=E6=B8=A1=EF=BC=88=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=E6=89=B9=E5=8E=BB=E9=87=8D=EF=BC=89=EF=BC=9B=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB65=E7=BB=BF=EF=BC=8B=E9=97=B801=E5=89=A9=E4=BD=99?= =?UTF-8?q?=E9=9D=A21081=E7=BB=BF=EF=BC=8B=E9=97=B802=E5=9B=9B=E5=8D=81?= =?UTF-8?q?=E4=B8=80=E5=A5=97=E4=BB=B6=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/smoke/smoke_all.gd | 404 -------------- game/tests/run_all.gd | 1 + .../data_config/table_structure_guard_test.gd | 516 ++++++++++++++++++ .../table_structure_guard_test.gd.uid | 1 + reports/批B-断言迁移账本-2026-10-22.md | 17 + 5 files changed, 535 insertions(+), 404 deletions(-) create mode 100644 game/tests/unit/data_config/table_structure_guard_test.gd create mode 100644 game/tests/unit/data_config/table_structure_guard_test.gd.uid diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 986f4570..27565dce 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -153,15 +153,12 @@ var _rng func _run_core_smoke(db, rng) -> void: _db = db _rng = rng - _smoke_ac_swap_guard() _smoke_enemy_attack_type() - _smoke_enemy_bands() _smoke_armor_roll() _smoke_pool_wiring() _smoke_board_generator() _smoke_layout_cell_formula() _smoke_type_glow() - _smoke_variation_framework() _smoke_variant_placement(1) _smoke_variant_placement(2) _smoke_global_variant() @@ -172,7 +169,6 @@ func _run_core_smoke(db, rng) -> void: _smoke_reward_identity_unit() _smoke_power_interval() _smoke_active_refresh() - _smoke_difficulty_table() _smoke_damage_types() _smoke_playtest_logger() _smoke_battle_log_view() @@ -198,8 +194,6 @@ func _run_core_smoke(db, rng) -> void: _smoke_shop_room() _smoke_shop_graph() _smoke_shop_pool() - _smoke_nine_types_gp_stream() - _smoke_adventure_table() _smoke_adventure_api() _smoke_adventure() _smoke_feats() @@ -300,121 +294,6 @@ func _smoke_find_token(candidates: Array, kind: String, tier: String) -> Diction return {} -# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池:配额结构/件值对/池总量域检) -func _smoke_adventure_table() -> void: - print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---") - check(_db.adventure != null, "db.adventure 已加载(adventure.tres 入 manifest)") - if _db.adventure == null: - return - check(_db.adventure.validate().is_empty(), "AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)") - check(int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100, - "clear_heal_pct ∈ [0,100](%d——通关部分回血比例)" % int(_db.adventure.clear_heal_pct)) - for tier in _db.adventure.TIERS: - check(int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier]), - "档位配额结构锚(四级 70/25/7/0——迭代 31 卡② 净删两件投放):%s = %d 张" % [tier, int(_db.adventure.TIER_QUOTAS[tier])]) - check(int(_db.adventure.deck_total()) == 102, "令牌池总量 = 102 张(四级 普通 70/优秀 25/稀有 7/传说 0——迭代 31 卡② 净删两件投放 104→102)〔凭证裁削批 V5〕") - check(int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "excellent")) == 50 - and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "excellent")) == 2 - and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "excellent")) == 10 - and int(_db.adventure.curio_value("armor", "common")) == 1 and int(_db.adventure.curio_value("armor", "excellent")) == 2, - "数值对(四级换代承袭):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2") - check(int(_db.adventure.curio_value("heal", "excellent")) == 6 and int(_db.adventure.curio_value("ap", "rare")) == 1, - "恢复件 +6 当即回血(优秀档承袭)· AP +1(稀有档承袭)〔专长批 V14 换代〕") - check(int(_db.adventure.curio_cap("gp")) == 99 and int(_db.adventure.curio_cap("attack")) == 99, - "cap 占位在域(案②提前退役——99=实质无上限,实数归调参批)〔专长批 V8 形态面〕") - check(Attunement.slots(_db, null) == 0, - "同调位读数源=树同调位节点点亮数(迭代 21 T4 槽位收编——null 档求值 0;谓词表已退役)〔天赋树批 V14〕") - # 同调目录 12 件(V1/V2 断言落点;装配→战斗贯通与空装配基线的现行主语 = morning_order) - var trows: Array = _db.treasure_attunement_rows() - var tid_set := {} - for r in trows: - tid_set[String(r.get("item_id", ""))] = true - check(trows.size() == 12 and tid_set.size() == 12, - "新目录 12 件全量落表(旧 12 行删除;装配词表=活行 12 件零重复)〔秘宝批 V1〕") - var new_names := {"morning_order": "晨操令", "wide_scout_band": "阔视野带", "trial_pick": "试炼镐", - "refresh_practice": "刷新练习", "reserve_jar": "储备罐", "nest_forecast": "巢穴预告", - "incense_stick": "线香", "twin_loader": "双管装填", "ranged_pick": "远程镐", - "refresh_license": "刷新执照", "sky_eye": "天眼", "beacon_network": "信标网络"} - var names_ok := tid_set.size() == new_names.size() - for r in trows: - var iid2 := String(r.get("item_id", "")) - if not new_names.has(iid2) or String(r.get("display_name", "")) != String(new_names[iid2]): - names_ok = false - check(names_ok, "命名锁定:12 件 item_id 与 display_name 逐件=正式名(C2 代号即名)〔秘宝批 V2 初筛〕") - var keystone_ids := ["morning_order", "wide_scout_band", "trial_pick", "refresh_practice", - "reserve_jar", "nest_forecast", "incense_stick", "twin_loader"] - var cond_reqs := {"ranged_pick": {"adventure_cleared": 1}, "refresh_license": {"boss_kill_count": 3}, - "sky_eye": {"adventure_cleared": 3, "boss_kill_count": 4}, "beacon_network": {"vision_used": 10}} - var tier_map := {"common": 0, "excellent": 0, "rare": 0} - var alloc_ok := trows.size() == 12 - for r in trows: - var iid3 := String(r.get("item_id", "")) - var ureqs: Array = r.get("unlock_requires", []) as Array - if keystone_ids.has(iid3): - if int(r.get("unlock_keystone_cost", 0)) < 1 or not ureqs.is_empty(): - alloc_ok = false - elif cond_reqs.has(iid3): - var want: Dictionary = cond_reqs[iid3] - if int(r.get("unlock_keystone_cost", 0)) != 0 or ureqs.size() != want.size(): - alloc_ok = false - for req_v in ureqs: - var req: Dictionary = req_v as Dictionary - if int(want.get(String(req.get("key", "")), -1)) != int(req.get("threshold", -1)): - alloc_ok = false - else: - alloc_ok = false - var t: String = String(r.get("tier", "")) - if not tier_map.has(t): - alloc_ok = false - else: - tier_map[t] += 1 - check(alloc_ok and tier_map["common"] == 7 and tier_map["excellent"] == 3 and tier_map["rare"] == 2, - "逐行解锁方式与契约 8 分配一致(钥石 8 件带价格、条件 4 件带谓词行互斥)+三档 7/3/2〔秘宝批 V1〕") - var orch_l = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(191)) - check(orch_l.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "attunement": ["morning_order"]}) - and orch_l.attune_has("morning_order"), - "装配→战斗贯通:enc.attunement=[morning_order] 起战 attune_has=true(注入面+只读出口)〔秘宝批换主语〕") - var orch_e = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(192)) - check(orch_e.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) - and not orch_e.attune_has("morning_order"), - "空装配基线:不带 attunement 起战 attune_has=false(off 态零扰动)〔秘宝批换主语〕") - # V9 静态词表断言(棋盘事实不可变契约 3:12 行 triggers do.kind 与 effect_kinds - # 全集零棋盘改写类词〔rewrite_board/remove_mines/cell_removal=改写族——现表零引用〕; - # 效果只允许信息呈现与开格动作) - var rewrite_words := ["rewrite_board", "remove_mines", "cell_removal"] - var v9_ok := true - for r9 in trows: - for k9 in (r9.get("effect_kinds", []) as Array): - if rewrite_words.has(String(k9)): - v9_ok = false - for tr9 in (r9.get("triggers", []) as Array): - if rewrite_words.has(String((tr9 as Dictionary).get("do", {}).get("kind", ""))): - v9_ok = false - check(v9_ok, "新 12 行效果词零棋盘改写类(危险格/安全格分布同池冻结——真视/开格动作外无写面)〔秘宝批 V9〕") - # 覆盖检查(效果三方向覆盖:信息〔巢穴预告/线香/天眼/阔视野带〕/行动资源〔晨操令/ - # 双管装填/试炼镐/储备罐〕/操作能力〔远程镐/信标网络〕+抉择刷新〔刷新练习/刷新执照〕——dimension_tags 静态投影) - var tag_set := {} - for r10 in trows: - for tg in (r10.get("dimension_tags", []) as Array): - tag_set[String(tg)] = true - check(tag_set.has("info") and tag_set.has("action") and tag_set.has("plan"), - "效果三方向覆盖在册(信息/行动资源/操作能力=plan·抉择刷新——12 件目录结构对齐设计权威)〔秘宝批 V10 覆盖半〕") - # V18 装配面空目录合法态(白板档全未解锁:柜台投影 12 行全未解锁+mode 分派正确; - # 池空兜底/商店空货架两部分归奇物/商店侧断言面〔卡片 §4〕) - var mp_v18 = load("res://core/meta_progression.gd").new(_db, _meta_new_save(_META_SMOKE_PATHS[12])) - var v18_ok := true - var locked_n := 0 - for st18 in mp_v18.treasure_shop_status(): - if bool(st18.get("unlocked", false)): - v18_ok = false - else: - locked_n += 1 - if (String(st18.get("mode", "")) != "keystone" and String(st18.get("mode", "")) != "condition"): - v18_ok = false - check(v18_ok and locked_n == 12, - "装配面空目录合法态:白板档 12 件全未解锁且 mode 分派正确(空目录=合法可玩态)〔秘宝批 V18〕") - - # ② 冒险结构 API 闸(状态机受理面——重复开始拒绝 / 入口停点 / 放弃两态) func _smoke_adventure_api() -> void: print("--- 冒险结构 API 闸(重复开始拒绝 · 入口停点 · 放弃两态受理)---") @@ -1594,55 +1473,6 @@ 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) @@ -1705,121 +1535,6 @@ func _smoke_enemy_attack_type() -> void: check(payload_at == "normal", "敌方伤害技单据载荷 attack_type 非空(= %s——第 9 参传值面,迭代 24 N3)" % payload_at) -# ---- 护甲等级掷点·AC 单轨护栏(无 percent 减伤双轨)---- -# ① defense_up 状态行/池映射/奖励行三处零残留,ac_up 三处就位(unit=ac·只续时不叠层); -# ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行 -# magnitude × max_stack > pool_clamp_lo——单效果叠满不可触池底(池内现无 percent 行——本断言防未来负向行回归时静默造墙); -# ③ 单位表 armor_class 字段:主角 10(暂定)/ 敌方行 0(暂定——正式值归怪物文档); -# ④ 掷点参数域:档位边界升序/档位值降序无零档/骰面与暴击参数在域(加载校验双保险)。 - -func _smoke_ac_swap_guard() -> void: - print("--- 护甲等级掷点批·换装护栏 ---") - # ① 三处无双轨 - check(_db.status_effect_row("defense_up").is_empty(), "状态表 defense_up 行已退役(无双轨)") - var ac_row: Dictionary = _db.status_effect_row("ac_up") - check(not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \ - and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \ - and String(ac_row.get("stack_policy", "")) == "refresh", - "ac_up 行在位(hero · +1 ac · 只续时不叠层——2026-09-20 主角增益续时批)") - var ac_reward := {} - for row in _db.reward.rows: - if String(row.get("cell_type", "")) == "defense" and String(row.get("polarity", "")) == "positive": - ac_reward = row - check(String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "盾牌加护", - "奖励表 defense 正向行 effect_id = ac_up(显示名盾牌加护,权重/tier 不变)") - check(not _db.settlement.effect_pool_map.has("defense_up") and not _db.settlement.effect_pool_map.has("ac_up"), - "effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)") - # ② 免伤墙归零护栏(泛化——BUG-0003 免疫) - var wall_ok := true - var wall_detail := "" - for eff_id in _db.settlement.effect_pool_map.keys(): - if String(_db.settlement.effect_pool_map[eff_id]) != "hero_defense": - continue - var row: Dictionary = _db.status_effect_row(eff_id) - if row.is_empty() or String(row.get("unit", "")) != "percent": - continue - var floor_v: int = int(row.get("magnitude", 0)) * int(row.get("max_stack", 1)) - if floor_v <= int(_db.settlement.pool_clamp_lo): - wall_ok = false - wall_detail += " %s(%d)" % [eff_id, floor_v] - check(wall_ok, "hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" % [ - wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.pool_clamp_lo)]) - # ③ 单位表 armor_class:域内校验(配置推导——正式投值归怪物/难度批,改表不断锚) - check(int(_db.hero.armor_class) >= 0 and int(_db.hero.armor_class) <= 30, - "主角 armor_class 在域内(%d——暂定基准随表走)" % int(_db.hero.armor_class)) - var ac_band := true - var ac_detail := "" - var heavy_names := ["熊地精重击手", "灰矮人重装手"] # 逐行手设值 8/9,非精英编码位 - for row in _db.enemy.rows: - var ac: int = int(row.get("armor_class", -1)) - var dname := String(row.get("display_name", "?")) - var boss_row: bool = String(row.get("enemy_id", "")).ends_with("_chief") or \ - String(row.get("enemy_id", "")) == "drider" or String(row.get("enemy_id", "")) == "duergar_king" - var heavy_row: bool = dname in heavy_names - # 杂鱼 0~5;重击手/重装手 8~9(手设精英值);Boss 8~12 台阶 - var lo := 8 if (boss_row or heavy_row) else 0 - var hi := 12 if boss_row else (9 if heavy_row else 5) - if ac < lo or ac > hi: - ac_band = false - ac_detail += " %s:%d" % [dname, ac] - check(ac_band, "敌方 AC 行值在已裁带宽内(杂鱼 0~5·重击手/重装手 8~9·Boss 8~12——迭代 5 返工批 v1 重投,越带行%s)" % [ - ac_detail if ac_detail != "" else "无"]) - # ③b Boss 台阶断言(关卡即台阶——L1 8 < L2 10 < L3 12)+ 首遇位教学位低 AC(≤3) - var boss_ac := {} - for row in _db.enemy.rows: - var eid := String(row.get("enemy_id", "")) - if eid == "ogre_chief" or eid == "drider" or eid == "duergar_king": - 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 熊地精首遇带降档锚定 + 第一关总量曲线护栏 - # 首遇带行 = 数据驱动定位(契约 8:该 (1,battle,bugbear) 的最低 depth_band)锚定值 hp 22 / ac 2; - # 敌种 bugbear 与池 attack6/charge2/heavy2 钉死不动(防顺手改多); - # 曲线护栏 = 深度带升序相邻带「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 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(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,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("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() - depths_sorted.sort() - for i in range(1, depths_sorted.size()): - var prev: float = float(l1_totals[depths_sorted[i - 1]]) - var curr: float = float(l1_totals[depths_sorted[i]]) - var ratio: float = curr / prev if prev > 0.0 else 999.0 - 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)" % [ - curve_detail if curve_detail != "" else "无"]) - # ④ 掷点参数域(加载校验已过——此处钉形状语义) - var bounds: Array = _db.settlement.roll_tier_bounds - var discounts: Array = _db.settlement.roll_tier_discounts - check(int(_db.settlement.roll_faces) == 20 and int(_db.settlement.crit_roll) == 20 \ - and int(_db.settlement.crit_multiplier) == 200 and int(_db.settlement.damage_floor) == 1 \ - and int(_db.settlement.attack_bonus) == 0 and String(_db.settlement.armor_roll_stream) == "armor_roll", - "掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)") - check(bounds == [3, 7] and discounts == [75, 50, 25], "档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)") - - # ---- 护甲等级掷点·行为面(折扣档分布 / nat20 暴击 / 伤害下限 / 棋盘来源必中)---- func _smoke_armor_roll() -> void: @@ -2071,38 +1786,6 @@ func _mk_stream(stream_name: String, scope: int) -> RandomNumberGenerator: return rg -# ---- 地形变异 框架面(四行两轴×两关 + 门控 + 跨字段互锁)---- - -func _smoke_variation_framework() -> void: - print("--- 地形变异 框架面(首批四类型 + 候选池门控 + 跨字段互锁)---") - check(_db.variation.rows.size() == 6, "变异清单 6 行(荆棘丛/藤蔓缠绕/岩浆/烬覆 + 落石/矿尘——首批类型定稿批 + 第三关内容批;贫矿脉 2026-10-16 用户令屏蔽删行)") - check(_db.variation.validate().is_empty(), "变异表校验零错误(6 行全过域与跨字段互锁——含 scope=global 三互锁负样本)") - var pool1_ids: Array = [] - for r in _db.variant_pool(1, "dungeon"): - pool1_ids.append(String(r.variant_id)) - check(pool1_ids == ["thorn", "vine"], "L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)") - var pool2_ids: Array = [] - for r in _db.variant_pool(2, "underdark"): - pool2_ids.append(String(r.variant_id)) - check(pool2_ids == ["magma", "ash"], "L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)") - check(_db.variant_pool(1, "underdark").is_empty(), "unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)") - var pool2d_ids: Array = [] - for r in _db.variant_pool(2, "dungeon"): - pool2d_ids.append(String(r.variant_id)) - check(pool2d_ids == ["thorn", "vine"], "board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)") - check(_db.variation.row_by_id("nonexistent").is_empty(), "清单外 variant_id 返回空(读取通道在位)") - # 跨字段互锁负样本(加载期致命——枚举语义一致性闸) - var vt = load("res://data/schemas/variation_table.gd").new() - vt.rows.assign([{"variant_id": "bad1", "display_name": "坏行甲", "unlock_level": 1, "scope": "region", - "effect": "flat_damage", "effect_value": 1, "trigger": "on_open", "placement": "any", - "board_types": [], "counter_items": []}]) - check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本①:flat_damage + placement=any 被拒(只出现在未打开的格子)") - vt.rows.assign([{"variant_id": "bad2", "display_name": "坏行乙", "unlock_level": 1, "scope": "region", - "effect": "hide_number", "effect_value": 0, "trigger": "on_open", "placement": "any", - "board_types": [], "counter_items": []}]) - check(_errs_has_cross_field(vt.validate()), "跨字段互锁负样本②:hide_number + trigger≠on_number_shown 被拒") - - func _errs_has_cross_field(errs: Array) -> bool: for e in errs: if String(e.get("category", "")) == "cross_field": @@ -2666,33 +2349,6 @@ func _active_refresh_replay() -> Array: return rows -# ---- 难度曲线与Boss DifficultyTable 结构位冒烟(AC-10:空表零行为 + 套乘点 = 行值快照前)---- - -func _smoke_difficulty_table() -> void: - print("--- 难度曲线与Boss·DifficultyTable 结构位 ---") - check(_db.difficulty != null and _db.difficulty.rows.is_empty(), "DifficultyTable MVP 空表加载(结构位)") - # ① 空表零行为:敌人系统.spawn 敌值 = EnemyTable 行值原样 - 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, "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}) - var enemy_system_b = load("res://core/unit_container.gd").new(_db) - check(enemy_system_b.spawn(1, 0, int(_db.hero.max_hp)), "修饰后 spawn 受理") - var g2 = enemy_system_b.all_enemies()[0] - check(int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp), "hp 修饰 ×1.5(%d→%d,max_hp 同步)" % [int(d0.hp), int(g2.hp)]) - check(int(g2.atk) == int(d0.atk) * 2, "atk 修饰 ×2.0(%d→%d)" % [int(d0.atk), int(g2.atk)]) - # ③ 越域 clamp:乘数 50 → clamp 10 - _db.difficulty.rows.clear() - _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 50.0, "atk_mult": 1.0}) - var enemy_system_c = load("res://core/unit_container.gd").new(_db) - check(enemy_system_c.spawn(1, 0, int(_db.hero.max_hp)), "越域修饰 spawn 受理") - check(int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10, "越域乘数 clamp 至 10(%d→%d)" % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)]) - _db.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线) - - # ---- 攻击类型 × 护甲克制 + 标错惩罚冒烟(护甲快照/克制矩阵/充能/反噬三选一)---- func _smoke_damage_types() -> void: @@ -4979,70 +4635,10 @@ func _all_entry_boss_paths(nodes: Array) -> Array: return paths -# ---- ⑤ 格面资源九型 + gp_drop 流(黄金向量)---- - -func _smoke_nine_types_gp_stream() -> void: - print("--- 格面资源九型 + gp_drop 流(奖励字典 18 格 + 黄金向量)---") - check(RewardTable.CELL_TYPES.size() == 10 and RewardTable.CELL_TYPES.has("gp") and RewardTable.CELL_TYPES.has("treasure_item"), - "格面资源十型封闭(第九型 gp 九型批 2026-09-20 / 第十型 treasure_item 秘宝内容批 2026-09-29——词表 v1.2)") - var cell_rows := 0 - var pos_weight := 0 - var gp_pos := -1 - var gp_neg := -1 - for row in _db.reward.rows: - if String(row.get("pool_tag", "")) == "cell": - cell_rows += 1 - if String(row.get("polarity", "")) == "positive": - pos_weight += int(row.get("weight", 0)) - if String(row.get("reward_id", "")) == "gp_coin": - gp_pos = int(row.weight) - if String(row.get("reward_id", "")) == "gp_lose": - gp_neg = int(row.weight) - check(cell_rows == 18, "奖励字典 18 行(九型×正负 18 格——aux 挖格投放四行已退役 2026-10-01 用户拍板甲:战斗/挖格不掉道具,秘宝唯一来路=三选一共享池〔节点/通关〕;清图槽独立批尾不计)") - # 危险格负向直伤当量(负向≈正向 2 倍定价——真人反馈「三雷 3/4 血无后果感」的投值口径) - var mine_power := {"heal_backlash": 6, "pierce_snare": 8, "magic_recoil": 8} # 危险格负向直伤当量(抬档值;依据见 numfix 报告) - for row in _db.reward.rows: - var rid := String(row.get("reward_id", "")) - if mine_power.has(rid): - check(int(row.get("power", -1)) == int(mine_power[rid]), - "危险格负向直伤 %s power=%d(卡⑧投值)" % [rid, int(mine_power[rid])]) - # 正向权重结构校验(配置推导):正向 cell 行权重 ≥1(必在池)、负向行权重恒 0(不入池)、 - # 合计即配置自身的和(改表自动跟随,不再硬编码合计值)。 - var pos_all_in_pool := true - var neg_all_zero := true - for row in _db.reward.rows: - if String(row.get("pool_tag", "")) != "cell": - continue - if String(row.get("polarity", "")) == "positive" and int(row.get("weight", 0)) < 1: - pos_all_in_pool = false - if String(row.get("polarity", "")) == "negative" and int(row.get("weight", 0)) != 0: - neg_all_zero = false - check(pos_all_in_pool and neg_all_zero, - "正向 cell 行权重 ≥1 全在池 / 负向行权重恒 0 不入池(正向合计 %d——配置推导)" % pos_weight) - check(gp_pos == 2 and gp_neg == 0, "gp 型两行(GP 袋 gp_gain 正向 weight 2 / 破财 gp_loss 负向 weight 0 不入池)") - var rng = _fresh_rng(42) - check(rng.counter() == 0, "derive_at 纯函数不推计数器(统一流全局计数器恒 0——并流后纯函数语义不变,gp_drop 专用向量随分盐作废、统一向量中央断言)") - # ---- 元进度·MetaTable 表结构(行值全 provisional 归元进度设计文档配读数; # conversion 覆盖恰三,Boss 两档 first ≥ repeat)---- func _smoke_meta() -> void: - print("--- 元进度·MetaTable 表结构 ---") - var meta_tbl = _db.meta - check(meta_tbl != null and meta_tbl.validate().is_empty(), - "MetaTable 非空 + validate 零错(三行族词表封闭/唯一/连续/域界/first≥repeat/ratio 全过)") - check(meta_tbl.boss_keystone.size() == 3 - and meta_tbl.conversion.size() == 3 and meta_tbl.attune_refresh_upgrade.size() == 2, - "MetaTable 行族规模 3/3/2(Boss/换算恰三/Y 升级两档——forge_products 迭代 21 退役、workshop_items 迭代 22 退役)") - var attrs_ok := true - for a in ["max_hp", "atk", "armor_class"]: - attrs_ok = attrs_ok and not _db.meta_conversion_row(a).is_empty() - check(attrs_ok, "conversion attr 词表封闭恰全(三属性轴——meta_* 访问器可达)") - var boss_ok := true - for li in [1, 2, 3]: - var brow: Dictionary = _db.meta_boss_keystone_row(li) - boss_ok = boss_ok and not brow.is_empty() and int(brow.get("first_kill", 0)) >= int(brow.get("repeat_kill", -1)) - check(boss_ok, "boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)") _meta_pricing_and_buy() _meta_settle_and_workshop() _meta_injection() diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index a2ee7b20..f1e4272a 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -48,6 +48,7 @@ func _suites() -> Array: {"path": "res://tests/unit/data_config/reward_dangling_treasure_test.gd", "expect": 1, "negative": true}, # 秘宝双表校验 {"path": "res://tests/unit/data_config/counter_prefix_fatal_test.gd", "expect": 1, "negative": true}, # 卡② V9 前缀合法性+引用闭合加载致命 {"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/core/treasure_items_test.gd", "expect": 0}, # 秘宝结构冻结断言面 {"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 断点往返/续玩确定性/幂等/退出清断点 {"path": "res://tests/unit/core/curio_refresh_paid_test.gd", "expect": 0}, # 三选一刷新免费双源+GP 付费档(对齐商店 MC-1:价曲线/优先序/停点清零/钱包单口) diff --git a/game/tests/unit/data_config/table_structure_guard_test.gd b/game/tests/unit/data_config/table_structure_guard_test.gd new file mode 100644 index 00000000..5b43a3fe --- /dev/null +++ b/game/tests/unit/data_config/table_structure_guard_test.gd @@ -0,0 +1,516 @@ +# tests/unit/data_config/table_structure_guard_test.gd — 表结构守卫单元测试宿主 +# 断言面 = data/tables 各表的结构锚与守卫,七套: +# adventure 冒险表(档位配额/件值对/同调目录 12 件/装配→战斗贯通) +# enemy 敌人表(20 行带键行序/hobgoblin 留任/三 Boss 行) +# AC 单轨与掷点参数(status_effect/reward/settlement 三处无双轨/免伤墙护栏/敌 AC 带宽) +# variation 地形变异(行数/候选池门控/跨字段互锁负样本) +# difficulty 难度表(空表零行为/修饰套乘/越域 clamp) +# reward 九型封闭与 gp 流(18 行字典/权重结构/derive_at 计数器) +# meta 元进度表(行族规模/词表封闭/first≥repeat) +# 协议照单元侧惯例:extends SceneTree+Kit.check 主通道;--script 模式无 autoload, +# ConfigDB 显式实例化构造注入;随机只经 RngService 统一流(_fresh_rng 同种子隔离夹具)。 +# 运行:godot --headless --path game --script res://tests/unit/data_config/table_structure_guard_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ConfigDBScript := preload("res://core/config_db.gd") + +# 隔离存档名册(段内只读白板档——测试不污染 user://save.json 真实档) +const _META_SMOKE_PATHS := ["user://smoke_meta_buy.json", "user://smoke_meta_dry.json", + "user://smoke_meta_settle.json", "user://smoke_meta_workshop.json", + "user://smoke_meta_cap.json", "user://smoke_meta_white.json", + "user://smoke_meta_attune.json", "user://smoke_meta_attune2.json", + "user://smoke_meta_tree.json", "user://smoke_meta_career.json", + "user://smoke_meta_career_v5.json", "user://smoke_meta_treasure_unlock.json", "user://smoke_meta_treasure_blank.json", + "user://smoke_meta_forge.json", "user://smoke_meta_forge2.json", "user://smoke_meta_forge3.json", + "user://smoke_meta_forge4.json", "user://smoke_meta_forge5.json"] + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + # 表加载失败不中止(沿被迁断言面的源语义:表损坏的红落在具体断言上—— + # 诊断在此展开,后续断言按坏表实况逐条验红;collect_only=true 下成员照常赋值可评) + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[table_structure_guard_test] 表加载返回失败——诊断如下(红由具体断言落)") + for d in _db.diagnostics(): + if d.severity == "error" or d.severity == "fatal": + push_error(" [%s] %s: %s" % [d.severity, d.category, d.message]) + # 套执行序沿源编排序(difficulty 的修饰行段内自清空,不影响后续套的表基线) + _smoke_ac_swap_guard() + _smoke_enemy_bands() + _smoke_variation_framework() + _smoke_difficulty_table() + _smoke_nine_types_gp_stream() + _smoke_adventure_table() + _smoke_meta() + quit(Kit.finish("table_structure_guard_test")) + + +# ---- 隔离夹具(无 _db 依赖者为 static——后续宿主可 preload 复用)---- + +# 隔离用全新 RngService(同 master 种子——回放确定性纪律) +static func _fresh_rng(seed_val: int): + var rng = load("res://core/rng_service.gd").new() + rng.set_master_seed(seed_val) + return rng + + +# 隔离档只读开启(不写盘——写侧归 meta 域宿主) +static func _meta_new_save(path: String): + var sv = load("res://core/save_manager.gd").new(path) + sv.load_from_disk() + return sv + + +static func _errs_has_cross_field(errs: Array) -> bool: + for e in errs: + if String(e.get("category", "")) == "cross_field": + return true + return false + + +# 该关 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: + if int(row.level_index) == level_index: + var tpls: Array = row.get("templates", []) + if not tpls.is_empty(): + return tpls[0].get("nodes", []) + return row.get("nodes", []) + return [] + + +# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池:配额结构/件值对/池总量域检) +func _smoke_adventure_table() -> void: + print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---") + Kit.check("db.adventure 已加载(adventure.tres 入 manifest)", _db.adventure != null) + if _db.adventure == null: + return + Kit.check("AdventureTable.validate() 零违例(全键域检+档位配额结构锚在案)", _db.adventure.validate().is_empty()) + Kit.check("clear_heal_pct ∈ [0,100](%d——通关部分回血比例)" % int(_db.adventure.clear_heal_pct), + int(_db.adventure.clear_heal_pct) >= 0 and int(_db.adventure.clear_heal_pct) <= 100) + for tier in _db.adventure.TIERS: + Kit.check("档位配额结构锚(四级 70/25/7/0——迭代 31 卡② 净删两件投放):%s = %d 张" % [tier, int(_db.adventure.TIER_QUOTAS[tier])], + int(_db.adventure.tier_total(tier)) == int(_db.adventure.TIER_QUOTAS[tier])) + Kit.check("令牌池总量 = 102 张(四级 普通 70/优秀 25/稀有 7/传说 0——迭代 31 卡② 净删两件投放 104→102)〔凭证裁削批 V5〕", int(_db.adventure.deck_total()) == 102) + Kit.check("数值对(四级换代承袭):小GP 20/大GP 50 · 小增伤 +1/大 +2 · 小生命 +5/大 +10 · 小护甲 +1/大 +2", + int(_db.adventure.curio_value("gp", "common")) == 20 and int(_db.adventure.curio_value("gp", "excellent")) == 50 + and int(_db.adventure.curio_value("attack", "common")) == 1 and int(_db.adventure.curio_value("attack", "excellent")) == 2 + and int(_db.adventure.curio_value("max_hp", "common")) == 5 and int(_db.adventure.curio_value("max_hp", "excellent")) == 10 + and int(_db.adventure.curio_value("armor", "common")) == 1 and int(_db.adventure.curio_value("armor", "excellent")) == 2) + Kit.check("恢复件 +6 当即回血(优秀档承袭)· AP +1(稀有档承袭)〔专长批 V14 换代〕", + int(_db.adventure.curio_value("heal", "excellent")) == 6 and int(_db.adventure.curio_value("ap", "rare")) == 1) + Kit.check("cap 占位在域(案②提前退役——99=实质无上限,实数归调参批)〔专长批 V8 形态面〕", + int(_db.adventure.curio_cap("gp")) == 99 and int(_db.adventure.curio_cap("attack")) == 99) + Kit.check("同调位读数源=树同调位节点点亮数(迭代 21 T4 槽位收编——null 档求值 0;谓词表已退役)〔天赋树批 V14〕", + Attunement.slots(_db, null) == 0) + # 同调目录 12 件(V1/V2 断言落点;装配→战斗贯通与空装配基线的现行主语 = morning_order) + var trows: Array = _db.treasure_attunement_rows() + var tid_set := {} + for r in trows: + tid_set[String(r.get("item_id", ""))] = true + Kit.check("新目录 12 件全量落表(旧 12 行删除;装配词表=活行 12 件零重复)〔秘宝批 V1〕", + trows.size() == 12 and tid_set.size() == 12) + var new_names := {"morning_order": "晨操令", "wide_scout_band": "阔视野带", "trial_pick": "试炼镐", + "refresh_practice": "刷新练习", "reserve_jar": "储备罐", "nest_forecast": "巢穴预告", + "incense_stick": "线香", "twin_loader": "双管装填", "ranged_pick": "远程镐", + "refresh_license": "刷新执照", "sky_eye": "天眼", "beacon_network": "信标网络"} + var names_ok := tid_set.size() == new_names.size() + for r in trows: + var iid2 := String(r.get("item_id", "")) + if not new_names.has(iid2) or String(r.get("display_name", "")) != String(new_names[iid2]): + names_ok = false + Kit.check("命名锁定:12 件 item_id 与 display_name 逐件=正式名(C2 代号即名)〔秘宝批 V2 初筛〕", names_ok) + var keystone_ids := ["morning_order", "wide_scout_band", "trial_pick", "refresh_practice", + "reserve_jar", "nest_forecast", "incense_stick", "twin_loader"] + var cond_reqs := {"ranged_pick": {"adventure_cleared": 1}, "refresh_license": {"boss_kill_count": 3}, + "sky_eye": {"adventure_cleared": 3, "boss_kill_count": 4}, "beacon_network": {"vision_used": 10}} + var tier_map := {"common": 0, "excellent": 0, "rare": 0} + var alloc_ok := trows.size() == 12 + for r in trows: + var iid3 := String(r.get("item_id", "")) + var ureqs: Array = r.get("unlock_requires", []) as Array + if keystone_ids.has(iid3): + if int(r.get("unlock_keystone_cost", 0)) < 1 or not ureqs.is_empty(): + alloc_ok = false + elif cond_reqs.has(iid3): + var want: Dictionary = cond_reqs[iid3] + if int(r.get("unlock_keystone_cost", 0)) != 0 or ureqs.size() != want.size(): + alloc_ok = false + for req_v in ureqs: + var req: Dictionary = req_v as Dictionary + if int(want.get(String(req.get("key", "")), -1)) != int(req.get("threshold", -1)): + alloc_ok = false + else: + alloc_ok = false + var t: String = String(r.get("tier", "")) + if not tier_map.has(t): + alloc_ok = false + else: + tier_map[t] += 1 + Kit.check("逐行解锁方式与契约 8 分配一致(钥石 8 件带价格、条件 4 件带谓词行互斥)+三档 7/3/2〔秘宝批 V1〕", + alloc_ok and tier_map["common"] == 7 and tier_map["excellent"] == 3 and tier_map["rare"] == 2) + var orch_l = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(191)) + Kit.check("装配→战斗贯通:enc.attunement=[morning_order] 起战 attune_has=true(注入面+只读出口)〔秘宝批换主语〕", + orch_l.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp), "attunement": ["morning_order"]}) + and orch_l.attune_has("morning_order")) + var orch_e = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(192)) + Kit.check("空装配基线:不带 attunement 起战 attune_has=false(off 态零扰动)〔秘宝批换主语〕", + orch_e.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": int(_db.hero.max_hp)}) + and not orch_e.attune_has("morning_order")) + # V9 静态词表断言(棋盘事实不可变契约 3:12 行 triggers do.kind 与 effect_kinds + # 全集零棋盘改写类词〔rewrite_board/remove_mines/cell_removal=改写族——现表零引用〕; + # 效果只允许信息呈现与开格动作) + var rewrite_words := ["rewrite_board", "remove_mines", "cell_removal"] + var v9_ok := true + for r9 in trows: + for k9 in (r9.get("effect_kinds", []) as Array): + if rewrite_words.has(String(k9)): + v9_ok = false + for tr9 in (r9.get("triggers", []) as Array): + if rewrite_words.has(String((tr9 as Dictionary).get("do", {}).get("kind", ""))): + v9_ok = false + Kit.check("新 12 行效果词零棋盘改写类(危险格/安全格分布同池冻结——真视/开格动作外无写面)〔秘宝批 V9〕", v9_ok) + # 覆盖检查(效果三方向覆盖:信息〔巢穴预告/线香/天眼/阔视野带〕/行动资源〔晨操令/ + # 双管装填/试炼镐/储备罐〕/操作能力〔远程镐/信标网络〕+抉择刷新〔刷新练习/刷新执照〕——dimension_tags 静态投影) + var tag_set := {} + for r10 in trows: + for tg in (r10.get("dimension_tags", []) as Array): + tag_set[String(tg)] = true + Kit.check("效果三方向覆盖在册(信息/行动资源/操作能力=plan·抉择刷新——12 件目录结构对齐设计权威)〔秘宝批 V10 覆盖半〕", + tag_set.has("info") and tag_set.has("action") and tag_set.has("plan")) + # V18 装配面空目录合法态(白板档全未解锁:柜台投影 12 行全未解锁+mode 分派正确; + # 池空兜底/商店空货架两部分归奇物/商店侧断言面〔卡片 §4〕) + var mp_v18 = load("res://core/meta_progression.gd").new(_db, _meta_new_save(_META_SMOKE_PATHS[12])) + var v18_ok := true + var locked_n := 0 + for st18 in mp_v18.treasure_shop_status(): + if bool(st18.get("unlocked", false)): + v18_ok = false + else: + locked_n += 1 + if (String(st18.get("mode", "")) != "keystone" and String(st18.get("mode", "")) != "condition"): + v18_ok = false + Kit.check("装配面空目录合法态:白板档 12 件全未解锁且 mode 分派正确(空目录=合法可玩态)〔秘宝批 V18〕", + v18_ok and locked_n == 12) + + +# ---- 敌人表深度带键结构(迭代 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", ""))]) + Kit.check("敌人表 20 行且键序 = 迁移表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕", + _db.enemy.rows.size() == 20 and got_keys == expect_keys) + # ② hobgoblin 缺口行留任随迁(契约 9:不删行不补立绘——缺口登记随行) + var hob := {} + for row in _db.enemy.rows: + if String(row.get("enemy_id", "")) == "hobgoblin": + hob = row + Kit.check("hobgoblin 行在 (1,battle,3) 留任随迁(缺口行不删不补——补齐归敌种内容批)〔fv12〕〔fv25〕", + int(hob.get("level_index", -1)) == 1 and String(hob.get("row_kind", "")) == "battle" \ + and int(hob.get("depth_band", -1)) == 3) + Kit.check("hobgoblin 缺口登记随行(表文件注释在位——缺口不静默)〔fv25〕", + FileAccess.get_file_as_string("res://data/tables/enemy.tres").contains("hobgoblin 行为缺口行")) + # ③ 三 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] + Kit.check("三关 Boss 行各恰一行且 row_kind=boss、band=图 Boss 节点深度(%d/%d/%d%s)〔fv13〕" % [ + _boss_depth(1), _boss_depth(2), _boss_depth(3), boss_detail], boss_ok) + + +# ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行 +# magnitude × max_stack > pool_clamp_lo——单效果叠满不可触池底(池内现无 percent 行——本断言防未来负向行回归时静默造墙); +# ③ 单位表 armor_class 字段:主角 10(暂定)/ 敌方行 0(暂定——正式值归怪物文档); +# ④ 掷点参数域:档位边界升序/档位值降序无零档/骰面与暴击参数在域(加载校验双保险)。 + +func _smoke_ac_swap_guard() -> void: + print("--- 护甲等级掷点批·换装护栏 ---") + # ① 三处无双轨 + Kit.check("状态表 defense_up 行已退役(无双轨)", _db.status_effect_row("defense_up").is_empty()) + var ac_row: Dictionary = _db.status_effect_row("ac_up") + Kit.check("ac_up 行在位(hero · +1 ac · 只续时不叠层——2026-09-20 主角增益续时批)", + not ac_row.is_empty() and String(ac_row.get("target_side", "")) == "hero" \ + and String(ac_row.get("unit", "")) == "ac" and int(ac_row.get("magnitude", 0)) == 1 \ + and String(ac_row.get("stack_policy", "")) == "refresh") + var ac_reward := {} + for row in _db.reward.rows: + if String(row.get("cell_type", "")) == "defense" and String(row.get("polarity", "")) == "positive": + ac_reward = row + Kit.check("奖励表 defense 正向行 effect_id = ac_up(显示名盾牌加护,权重/tier 不变)", + String(ac_reward.get("effect_id", "")) == "ac_up" and String(ac_reward.get("display_name", "")) == "盾牌加护") + Kit.check("effect_pool_map 无 defense_up/ac_up 键(减伤不入池——AC 不经乘区)", + not _db.settlement.effect_pool_map.has("defense_up") and not _db.settlement.effect_pool_map.has("ac_up")) + # ② 免伤墙归零护栏(泛化——BUG-0003 免疫) + var wall_ok := true + var wall_detail := "" + for eff_id in _db.settlement.effect_pool_map.keys(): + if String(_db.settlement.effect_pool_map[eff_id]) != "hero_defense": + continue + var row: Dictionary = _db.status_effect_row(eff_id) + if row.is_empty() or String(row.get("unit", "")) != "percent": + continue + var floor_v: int = int(row.get("magnitude", 0)) * int(row.get("max_stack", 1)) + if floor_v <= int(_db.settlement.pool_clamp_lo): + wall_ok = false + wall_detail += " %s(%d)" % [eff_id, floor_v] + Kit.check("hero_defense 池 percent 行叠满不可触池底(墙最深%s > clamp_lo %d——单效果归零结构性不可达)" % [ + wall_detail if wall_detail != "" else "(池内现无负向行)", int(_db.settlement.pool_clamp_lo)], wall_ok) + # ③ 单位表 armor_class:域内校验(配置推导——正式投值归怪物/难度批,改表不断锚) + Kit.check("主角 armor_class 在域内(%d——暂定基准随表走)" % int(_db.hero.armor_class), + int(_db.hero.armor_class) >= 0 and int(_db.hero.armor_class) <= 30) + var ac_band := true + var ac_detail := "" + var heavy_names := ["熊地精重击手", "灰矮人重装手"] # 逐行手设值 8/9,非精英编码位 + for row in _db.enemy.rows: + var ac: int = int(row.get("armor_class", -1)) + var dname := String(row.get("display_name", "?")) + var boss_row: bool = String(row.get("enemy_id", "")).ends_with("_chief") or \ + String(row.get("enemy_id", "")) == "drider" or String(row.get("enemy_id", "")) == "duergar_king" + var heavy_row: bool = dname in heavy_names + # 杂鱼 0~5;重击手/重装手 8~9(手设精英值);Boss 8~12 台阶 + var lo := 8 if (boss_row or heavy_row) else 0 + var hi := 12 if boss_row else (9 if heavy_row else 5) + if ac < lo or ac > hi: + ac_band = false + ac_detail += " %s:%d" % [dname, ac] + Kit.check("敌方 AC 行值在已裁带宽内(杂鱼 0~5·重击手/重装手 8~9·Boss 8~12——迭代 5 返工批 v1 重投,越带行%s)" % [ + ac_detail if ac_detail != "" else "无"], ac_band) + # ③b Boss 台阶断言(关卡即台阶——L1 8 < L2 10 < L3 12)+ 首遇位教学位低 AC(≤3) + var boss_ac := {} + for row in _db.enemy.rows: + var eid := String(row.get("enemy_id", "")) + if eid == "ogre_chief" or eid == "drider" or eid == "duergar_king": + boss_ac[int(row.get("level_index", 0))] = int(row.get("armor_class", 0)) + Kit.check("Boss AC 关卡台阶严格递增(%d < %d < %d)" % [int(boss_ac.get(1, 0)), int(boss_ac.get(2, 0)), int(boss_ac.get(3, 0))], + 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))) + # ③c 熊地精首遇带降档锚定 + 第一关总量曲线护栏 + # 首遇带行 = 数据驱动定位(契约 8:该 (1,battle,bugbear) 的最低 depth_band)锚定值 hp 22 / ac 2; + # 敌种 bugbear 与池 attack6/charge2/heavy2 钉死不动(防顺手改多); + # 曲线护栏 = 深度带升序相邻带「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 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) + Kit.check("熊地精首遇带(1,battle,%d)降档锚定(hp 22 · ac 2 · 池 attack6/charge2/heavy2 零改动——迭代 24 N1;首遇带数据驱动定位)〔fv21〕" % l12_band, + 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}) + 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("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() + depths_sorted.sort() + for i in range(1, depths_sorted.size()): + var prev: float = float(l1_totals[depths_sorted[i - 1]]) + var curr: float = float(l1_totals[depths_sorted[i]]) + var ratio: float = curr / prev if prev > 0.0 else 999.0 + if ratio > 2.5: + curve_ok = false + curve_detail += " (%d→%d):%.2f" % [depths_sorted[i - 1], depths_sorted[i], ratio] + Kit.check("第一关相邻深度带总量比 ≤ 2.5(12/10/22/35/72——迭代 24 N1 曲线护栏,带键升序;越带%s)" % [ + curve_detail if curve_detail != "" else "无"], curve_ok) + # ④ 掷点参数域(加载校验已过——此处钉形状语义) + var bounds: Array = _db.settlement.roll_tier_bounds + var discounts: Array = _db.settlement.roll_tier_discounts + Kit.check("掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)", + int(_db.settlement.roll_faces) == 20 and int(_db.settlement.crit_roll) == 20 \ + and int(_db.settlement.crit_multiplier) == 200 and int(_db.settlement.damage_floor) == 1 \ + and int(_db.settlement.attack_bonus) == 0 and String(_db.settlement.armor_roll_stream) == "armor_roll") + Kit.check("档位表拍板值在位(缺口 1-3 → ×0.75 · 4-7 → ×0.5 · 8+ → ×0.25——无零档)", + bounds == [3, 7] and discounts == [75, 50, 25]) + + +# ---- 地形变异 框架面(四行两轴×两关 + 门控 + 跨字段互锁)---- + +func _smoke_variation_framework() -> void: + print("--- 地形变异 框架面(首批四类型 + 候选池门控 + 跨字段互锁)---") + Kit.check("变异清单 6 行(荆棘丛/藤蔓缠绕/岩浆/烬覆 + 落石/矿尘——首批类型定稿批 + 第三关内容批;贫矿脉 2026-10-16 用户令屏蔽删行)", + _db.variation.rows.size() == 6) + Kit.check("变异表校验零错误(6 行全过域与跨字段互锁——含 scope=global 三互锁负样本)", + _db.variation.validate().is_empty()) + var pool1_ids: Array = [] + for r in _db.variant_pool(1, "dungeon"): + pool1_ids.append(String(r.variant_id)) + Kit.check("L1 dungeon 候选池 = 荆棘丛+藤蔓缠绕(unlock + board_types 双闸)", pool1_ids == ["thorn", "vine"]) + var pool2_ids: Array = [] + for r in _db.variant_pool(2, "underdark"): + pool2_ids.append(String(r.variant_id)) + Kit.check("L2 underdark 候选池 = 岩浆+烬覆(第二关解锁)", pool2_ids == ["magma", "ash"]) + Kit.check("unlock 门控:L1 不进 underdark 行(岩浆/烬覆第二关解锁)", _db.variant_pool(1, "underdark").is_empty()) + var pool2d_ids: Array = [] + for r in _db.variant_pool(2, "dungeon"): + pool2d_ids.append(String(r.variant_id)) + Kit.check("board_types 门控:L2 dungeon 板池 = 荆棘丛+藤蔓缠绕(解锁累积;岩浆/烬覆被 board_types ∅ 交集排除)", + pool2d_ids == ["thorn", "vine"]) + Kit.check("清单外 variant_id 返回空(读取通道在位)", _db.variation.row_by_id("nonexistent").is_empty()) + # 跨字段互锁负样本(加载期致命——枚举语义一致性闸) + var vt = load("res://data/schemas/variation_table.gd").new() + vt.rows.assign([{"variant_id": "bad1", "display_name": "坏行甲", "unlock_level": 1, "scope": "region", + "effect": "flat_damage", "effect_value": 1, "trigger": "on_open", "placement": "any", + "board_types": [], "counter_items": []}]) + Kit.check("跨字段互锁负样本①:flat_damage + placement=any 被拒(只出现在未打开的格子)", _errs_has_cross_field(vt.validate())) + vt.rows.assign([{"variant_id": "bad2", "display_name": "坏行乙", "unlock_level": 1, "scope": "region", + "effect": "hide_number", "effect_value": 0, "trigger": "on_open", "placement": "any", + "board_types": [], "counter_items": []}]) + Kit.check("跨字段互锁负样本②:hide_number + trigger≠on_number_shown 被拒", _errs_has_cross_field(vt.validate())) + + +# ---- 难度曲线与Boss DifficultyTable 结构位冒烟(AC-10:空表零行为 + 套乘点 = 行值快照前)---- + +func _smoke_difficulty_table() -> void: + print("--- 难度曲线与Boss·DifficultyTable 结构位 ---") + Kit.check("DifficultyTable MVP 空表加载(结构位)", _db.difficulty != null and _db.difficulty.rows.is_empty()) + # ① 空表零行为:敌人系统.spawn 敌值 = EnemyTable 行值原样 + var enemy_system = load("res://core/unit_container.gd").new(_db) + Kit.check("spawn 受理", enemy_system.spawn(1, 0, int(_db.hero.max_hp))) + var unit = enemy_system.all_enemies()[0] + var d0: Dictionary = _db.resolve_enemy_rowset(1, 0, "battle")[0] # 行集首行(带键寻址:战斗节点查 battle 键域) + Kit.check("空表零修饰(hp=%d atk=%d = 行值 %d/%d)" % [int(unit.hp), int(unit.atk), int(d0.hp), int(d0.atk)], + int(unit.hp) == int(d0.hp) and int(unit.atk) == 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}) + var enemy_system_b = load("res://core/unit_container.gd").new(_db) + Kit.check("修饰后 spawn 受理", enemy_system_b.spawn(1, 0, int(_db.hero.max_hp))) + var g2 = enemy_system_b.all_enemies()[0] + Kit.check("hp 修饰 ×1.5(%d→%d,max_hp 同步)" % [int(d0.hp), int(g2.hp)], + int(g2.hp) == int(round(float(d0.hp) * 1.5)) and int(g2.max_hp) == int(g2.hp)) + Kit.check("atk 修饰 ×2.0(%d→%d)" % [int(d0.atk), int(g2.atk)], int(g2.atk) == int(d0.atk) * 2) + # ③ 越域 clamp:乘数 50 → clamp 10 + _db.difficulty.rows.clear() + _db.difficulty.rows.append({"level_index": 1, "node_depth": 0, "hp_mult": 50.0, "atk_mult": 1.0}) + var enemy_system_c = load("res://core/unit_container.gd").new(_db) + Kit.check("越域修饰 spawn 受理", enemy_system_c.spawn(1, 0, int(_db.hero.max_hp))) + Kit.check("越域乘数 clamp 至 10(%d→%d)" % [int(d0.hp), int(enemy_system_c.all_enemies()[0].hp)], + int(enemy_system_c.all_enemies()[0].hp) == int(d0.hp) * 10) + _db.difficulty.rows.clear() # 还原空表(后续冒烟/关卡断言基线) + + +# ---- ⑤ 格面资源九型 + gp_drop 流(黄金向量)---- + +func _smoke_nine_types_gp_stream() -> void: + print("--- 格面资源九型 + gp_drop 流(奖励字典 18 格 + 黄金向量)---") + Kit.check("格面资源十型封闭(第九型 gp 九型批 2026-09-20 / 第十型 treasure_item 秘宝内容批 2026-09-29——词表 v1.2)", + RewardTable.CELL_TYPES.size() == 10 and RewardTable.CELL_TYPES.has("gp") and RewardTable.CELL_TYPES.has("treasure_item")) + var cell_rows := 0 + var pos_weight := 0 + var gp_pos := -1 + var gp_neg := -1 + for row in _db.reward.rows: + if String(row.get("pool_tag", "")) == "cell": + cell_rows += 1 + if String(row.get("polarity", "")) == "positive": + pos_weight += int(row.get("weight", 0)) + if String(row.get("reward_id", "")) == "gp_coin": + gp_pos = int(row.weight) + if String(row.get("reward_id", "")) == "gp_lose": + gp_neg = int(row.weight) + Kit.check("奖励字典 18 行(九型×正负 18 格——aux 挖格投放四行已退役 2026-10-01 用户拍板甲:战斗/挖格不掉道具,秘宝唯一来路=三选一共享池〔节点/通关〕;清图槽独立批尾不计)", + cell_rows == 18) + # 危险格负向直伤当量(负向≈正向 2 倍定价——真人反馈「三雷 3/4 血无后果感」的投值口径) + var mine_power := {"heal_backlash": 6, "pierce_snare": 8, "magic_recoil": 8} # 危险格负向直伤当量(抬档值;依据见 numfix 报告) + for row in _db.reward.rows: + var rid := String(row.get("reward_id", "")) + if mine_power.has(rid): + Kit.check("危险格负向直伤 %s power=%d(卡⑧投值)" % [rid, int(mine_power[rid])], + int(row.get("power", -1)) == int(mine_power[rid])) + # 正向权重结构校验(配置推导):正向 cell 行权重 ≥1(必在池)、负向行权重恒 0(不入池)、 + # 合计即配置自身的和(改表自动跟随,不再硬编码合计值)。 + var pos_all_in_pool := true + var neg_all_zero := true + for row in _db.reward.rows: + if String(row.get("pool_tag", "")) != "cell": + continue + if String(row.get("polarity", "")) == "positive" and int(row.get("weight", 0)) < 1: + pos_all_in_pool = false + if String(row.get("polarity", "")) == "negative" and int(row.get("weight", 0)) != 0: + neg_all_zero = false + Kit.check("正向 cell 行权重 ≥1 全在池 / 负向行权重恒 0 不入池(正向合计 %d——配置推导)" % pos_weight, + pos_all_in_pool and neg_all_zero) + Kit.check("gp 型两行(GP 袋 gp_gain 正向 weight 2 / 破财 gp_loss 负向 weight 0 不入池)", gp_pos == 2 and gp_neg == 0) + var rng = _fresh_rng(42) + Kit.check("derive_at 纯函数不推计数器(统一流全局计数器恒 0——并流后纯函数语义不变,gp_drop 专用向量随分盐作废、统一向量中央断言)", + rng.counter() == 0) + + +# ---- 元进度·MetaTable 表结构(行值全 provisional 归元进度设计文档配读数; +# conversion 覆盖恰三,Boss 两档 first ≥ repeat)---- +func _smoke_meta() -> void: + print("--- 元进度·MetaTable 表结构 ---") + var meta_tbl = _db.meta + Kit.check("MetaTable 非空 + validate 零错(三行族词表封闭/唯一/连续/域界/first≥repeat/ratio 全过)", + meta_tbl != null and meta_tbl.validate().is_empty()) + Kit.check("MetaTable 行族规模 3/3/2(Boss/换算恰三/Y 升级两档——forge_products 迭代 21 退役、workshop_items 迭代 22 退役)", + meta_tbl.boss_keystone.size() == 3 + and meta_tbl.conversion.size() == 3 and meta_tbl.attune_refresh_upgrade.size() == 2) + var attrs_ok := true + for a in ["max_hp", "atk", "armor_class"]: + attrs_ok = attrs_ok and not _db.meta_conversion_row(a).is_empty() + Kit.check("conversion attr 词表封闭恰全(三属性轴——meta_* 访问器可达)", attrs_ok) + var boss_ok := true + for li in [1, 2, 3]: + var brow: Dictionary = _db.meta_boss_keystone_row(li) + boss_ok = boss_ok and not brow.is_empty() and int(brow.get("first_kill", 0)) >= int(brow.get("repeat_kill", -1)) + Kit.check("boss_keystone L1–L3 行连续可达且 first_kill ≥ repeat_kill 全行(首杀全量/重复小额)", boss_ok) diff --git a/game/tests/unit/data_config/table_structure_guard_test.gd.uid b/game/tests/unit/data_config/table_structure_guard_test.gd.uid new file mode 100644 index 00000000..763770f4 --- /dev/null +++ b/game/tests/unit/data_config/table_structure_guard_test.gd.uid @@ -0,0 +1 @@ +uid://decsopim6t4x5 diff --git a/reports/批B-断言迁移账本-2026-10-22.md b/reports/批B-断言迁移账本-2026-10-22.md index 668b71b8..6337f3bf 100644 --- a/reports/批B-断言迁移账本-2026-10-22.md +++ b/reports/批B-断言迁移账本-2026-10-22.md @@ -245,6 +245,23 @@ 施工发现(供后续组参考):① Git Bash sed 的 `a\` 追加与含块结构注释变异易产 Parse Error(不算红),整行 `pass`/python 插入替代;② save_manager.gd `_init` 内对成员的赋值会被成员初始化器时序覆盖(probe 实证 active_slot 恒 0),变异请选访问器面;③ main.gd `_begin_session(true)` 有两处(_resume_from_menu:550、_start_adventure:607),awk 首匹配会错定位,变异前必核行号上下文。 +**G2 单元·表结构族 → game/tests/unit/data_config/table_structure_guard_test.gd:七套 60 条全部迁入,原子 commit 一笔。** +挂载终态=宿主 `_init` 自建 `_db`(ConfigDB 构造注入,`load_tables(..., true)`;加载失败**不中止**沿被迁断言面的源语义——诊断展开后表损坏的红落在具体断言上,与单元惯例「load 失败即退」不同,因表值变异须落断言红);套执行序沿源编排序(ac_swap→enemy_bands→variation→difficulty→nine_types→adventure_table→meta,difficulty 的修饰行段内自清空不泄)。helper 随迁(首用套全在本组):`_fresh_rng/_meta_new_save/_errs_has_cross_field`(static,无 `_db` 依赖——后续宿主可 preload 复用)+`_boss_depth/_first_encounter_band/_level_active_nodes`(实例法,依赖 `_db`)+`const _META_SMOKE_PATHS` 全 18 路径。**共享 helper 在 smoke_all.gd 原地保留**(G3–G11 残余套仍消费 `_fresh_rng/_boss_depth/_first_encounter_band/_level_active_nodes/_errs_has_cross_field/_meta_new_save/_META_SMOKE_PATHS`;宿主副本为过渡态双份,收尾批拆 smoke 侧时去重)。`_smoke_meta` 剪切形态=瘦壳(六调用保留:_meta_pricing_and_buy/_meta_settle_and_workshop/_meta_injection/_smoke_forge_axes/_status_panel_projection/_career_overlay_projection——G9/G11 未迁)。smoke_all.gd 同批删六函数+`_run_core_smoke` 六调用行,`_smoke_meta` 瘦壳。 + +逐套变异记账(每条独立一轮:改坏→跑新宿主→红→`git checkout` 还原→`git status` 清洁确认): + +- `_smoke_adventure_table`(17 条):17/17 验红。变异点——:306 config_db.gd:147 adventure 装配行 pass 化(红唯一,early return 后续套内断言不跑属预期);:309 adventure.tres curio_candidate_count 3→0(validate [1,6] 违例,红唯一);:310 clear_heal_pct 20→150(红,连带 :309);:313 attack_common 配额 18→17(红,连带 :315/:309);:315 两点一致变异:配额 18→17+adventure_table.gd TIER_QUOTAS common 70→69(红唯一——锚同移则 :313 不红);:316 gp_common 值 20→21(红唯一);:321 ap_rare 1→2(红唯一);:323 adventure_table.gd:85 默认 curio_cap_gp 99→50(红唯一——cap 无表行走 schema 默认);:325 attunement.gd slots 的 null 分支返 1(红唯一);:332 treasure_items.tres morning_order 行插 `"retired": true`(红,连带 :343/:371/:414——attunement_rows 过滤 retired);:343 display_name 晨操令→晨操令改(红唯一);:371 trial_pick tier common→rare(红唯一);:374 battle_orchestrator.gd:367 attunement 注入行 pass 化(红唯一);:378 `_attune_has` 恒真(红唯一,:374 保持绿);:393 effect_kinds 加 "rewrite_board"(红唯一);:400 两 plan 行(refresh_practice/refresh_license)tag 同改 info(红唯一;首试单行变异不破「三方向各有在册」不变式=无效变异);:414 meta_progression.gd treasure_shop_status 的 unlocked 恒 true(红唯一)。 +- `_smoke_enemy_bands`(4 条):4/4 验红。变异点——:1615 L2 band1 bugbear depth_band 1→2(红唯一);:1622 hobgoblin depth_band 3→2(红,连带 :1615/:1811 曲线比 3.50);:1625 删表尾「hobgoblin 行为缺口行」注释行(红唯一);:1642 ogre_chief depth_band 4→5(红,连带 :1615)。 +- `_smoke_ac_swap_guard`(12 条):12/12 验红。变异点——:1718 status_effects.tres 表首复插 defense_up 行(红唯一);:1720 ac_up magnitude 1→2(红唯一);:1728 reward.tres defense 正向行 effect_id→heal(红唯一);:1730 settlement.tres effect_pool_map 加 ac_up 键(红唯一);:1745 两点变异:effect_pool_map 加 weaken→hero_defense(红唯一,消息展开 weaken(-1040);**首试单抬 pool_clamp_lo=无效变异——现表无 hero_defense 池项,护栏循环体零执行**);:1748 hero.tres armor_class 35(红唯一);:1765 L1 band0 goblin armor_class 9(红唯一);:1773 drider armor_class 10→7(红,连带 :1765);:1789 L1 band2 bugbear armor_class 2→3(红唯一);:1811 ogre_chief hp 72→720(红唯一,带比 20.57);:1816 roll_faces 20→10(红唯一);:1820 roll_tier_bounds [3,7]→[2,7](红唯一)。 +- `_smoke_variation_framework`(9 条):9/9 验红。变异点——:2078 variation.tres 删 rockfall 整行(红唯一);:2079 magma unlock_level 2→0(红,连带 :2088);:2083 thorn unlock_level 1→2(红唯一);:2087 magma unlock_level 2→3(红唯一);:2088 ash unlock_level 2→1(红唯一);:2092 magma board_types 加 "dungeon"(红唯一);:2093 variation_table.gd row_by_id 未命中返 `{"variant_id": 入参}`(红唯一);:2099 validate 的 flat_damage placement 互锁比较目标 closed_only→any(红,连带 :2079;首试把比较常量改成不可匹配值使违例恒真=方向反,红落 :2079 非目标,翻转后成);:2103 hide_number 互锁 `!=`→`==`(红,连带 :2079)。 +- `_smoke_difficulty_table`(8 条):8/8 验红。变异点——:2673 difficulty.tres 预置 (L2,1.0,1.0) 行(红唯一);:2676 unit_container.gd 空修饰表拒 spawn(红,difficulty 套内 index 中断+:374/:378 两断言经残留修饰行级联红——中止路径跳过 rows.clear() 所致);:2679 空修饰路径 hp 偷 +1(红唯一);:2683 双乘数 >1 拒 spawn(红,同级联);:2685 hp 误用较大乘数 atk_mult(红唯一);:2686 atk 翻倍仅限 atk_mult>1 行(红唯一;**首试 atk 误用 hp_mult 被 round 舍入吞(1×1.5→2=期望 2)=无效变异**);:2691 hp_mult≥10 拒 spawn(红,同级联);:2692 config_db_resolver.gd hp 钳上界 10→12(红唯一,8×12=96)。 +- `_smoke_nine_types_gp_stream`(6 条):6/6 验红。变异点——:4986 reward_table.gd CELL_TYPES 删 "gp"(红唯一);:5001 heal_backlash pool_tag→clear_default(红唯一);:5007 heal_backlash power 6→7(红唯一);:5020 ac_up 正向行 weight 11→0(红唯一);:5022 gp_coin weight 2→3(红唯一);:5024 rng_service.gd set_master_seed 尾 `_counter = 0`→`= 1`(红唯一;**首试 derive_at 塞计数=不可达——新建+播种不路经 derive_at,该条守「播种后计数恒 0」面**)。 +- `_smoke_meta`(4 条):4/4 验红。变异点——:5032 meta.tres conversion atk 平移量 9→101(域 [0,100] 违例,红唯一);:5034 删 attune_refresh_upgrade 第二档(连续从 1 仍成立,红唯一);:5040 conversion attr max_hp→hp(红,连带 :5032);:5045 L3 first_kill 3→2(红,连带 :5032)。 + +窄复验:新宿主面 65 项检查 0 失败 PASS(静态 60+循环展开 5=档位循环×4、mine_power×3);闸 01 剩余面(smoke_all.gd)1081 项检查 0 失败 PASS(1146−65=本轮迁出七套运行计数);闸 02(run_all.gd)41 套件 all green(+本宿主 1 件);run_smoke.py 系统级断言面 17 项 0 失败 PASS。60/60 标签与冻结清单全量脚本比对零缺失;新宿主 Kit.check( 调用计数 60=账本条数。 + +施工发现(供后续组参考,接 G1 序号):④ 共享 helper 迁移形态=宿主复制+smoke_all.gd 原地保留双份(残余套仍消费;本波各组禁从 smoke_all.gd 删共享 helper,收尾批统一去重);⑤ 表值变异若触发表内 validate(配额锚/AC 带宽/互锁等),在「夹具 load 失败不中止」形态下红自然落到断言,无需两点变异;仅当变异被断言外机制吞掉(round 舍入/不变式未破/执行序不可达)才换变异点,并在账本记明无效变异首试;⑥ 宿主 fixture 与现役单元「load 失败即退」惯例不同(沿源语义不中止),后续 data_config 侧新宿主按各自断言面对齐源形态选择。 + ## 十、支援线 9 项待裁闭合(2026-10-22 主会话裁) 1. `extract_checks.py` **常驻保留**——承担清单维护义务(test-conventions §9.3 锚)的再提取/diff 职能;其 DEFAULT_SRC 悬空问题列入波 2:默认源改为三宿主(game/tests/unit、integration、system_test.gd)遍历,或显式路径参数,波 3 出口对账用显式模式。