522 lines
32 KiB
GDScript
522 lines
32 KiB
GDScript
# 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
|
||
|
||
|
||
# 首遇带数据驱动定位(契约 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
|
||
|
||
|
||
# ① 冒险表(单行全局 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("同调位读数 null 档早退分支求值 0(迭代 21 T4 槽位收编——谓词表已退役)〔天赋树批 V14·核代码增补〕",
|
||
Attunement.slots(_db, null) == 0)
|
||
Kit.check("同调位读数源=树同调位节点点亮数(非空档——socket1 点亮 1 件计 1)〔天赋树批 V14·核代码增补〕",
|
||
Attunement.slots(_db, _TreeSaveMock.new()) == 1)
|
||
# 同调目录 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 建·迭代 35 覆盖批换代:28 行键序=覆盖表序·hobgoblin 留任·Boss 行集带键清单)----
|
||
|
||
func _smoke_enemy_bands() -> void:
|
||
print("--- 敌人表深度带键结构(28 行键序 · hobgoblin 留任 · Boss 带键覆盖表)---")
|
||
# ① 28 行计数 + 键序 = 覆盖表序(文件行序不变——行序即契约)
|
||
# 迭代 35 生成图深度扩面覆盖批随动:战斗带顶扩到各关最大生成深度(L1→6/L2→9/L3→13)、
|
||
# Boss 行按终点深度分带(L1 6/L2 8·9/L3 11·12·13),模板时代带键 4/9/9 全部在册不动
|
||
var expect_keys: Array[String] = [
|
||
"1|battle|0|goblin", "1|battle|1|goblin", "1|battle|2|bugbear", "1|battle|3|hobgoblin",
|
||
"1|battle|6|bugbear",
|
||
"1|boss|4|ogre_chief", "1|boss|6|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|battle|9|giant_spider",
|
||
"2|boss|8|drider", "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|battle|13|duergar",
|
||
"3|boss|9|duergar_king", "3|boss|11|duergar_king", "3|boss|12|duergar_king", "3|boss|13|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("敌人表 28 行且键序 = 覆盖表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕",
|
||
_db.enemy.rows.size() == 28 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 行集带键 = 覆盖表(旧判据「各恰一行=图 Boss 节点深度」属单终点模板时代;
|
||
# 生成图多终点要求每终点深度解析到互异带键 ⇒ 每关 Boss 行必然多于一行——
|
||
# 迭代 35 覆盖批随动改写为带键清单,「带键互异」的运行时把关在 level_shop_economy_test〔v11〕)
|
||
var boss_bands := {}
|
||
for row in _db.enemy.rows:
|
||
if String(row.get("row_kind", "")) == "boss":
|
||
var lk: int = int(row.get("level_index", -1))
|
||
if not boss_bands.has(lk):
|
||
boss_bands[lk] = []
|
||
boss_bands[lk].append(int(row.get("depth_band", -1)))
|
||
var expect_bands := {1: [4, 6], 2: [8, 9], 3: [9, 11, 12, 13]}
|
||
var boss_ok := true
|
||
var boss_detail := ""
|
||
for L in [1, 2, 3]:
|
||
var got: Array = boss_bands.get(L, [])
|
||
got.sort()
|
||
if got != expect_bands[L]:
|
||
boss_ok = false
|
||
boss_detail += " L%d:%s≠%s" % [L, str(got), str(expect_bands[L])]
|
||
Kit.check("三关 Boss 行集带键 = 覆盖表(模板终点 4/9/9 在册+生成图终点 6/8/9/11/12/13 扩带)%s〔fv13〕" % 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.core.effect_pool_map.has("defense_up") and not _db.settlement.core.effect_pool_map.has("ac_up"))
|
||
# ② 免伤墙归零护栏(泛化——BUG-0003 免疫)
|
||
var wall_ok := true
|
||
var wall_detail := ""
|
||
for eff_id in _db.settlement.core.effect_pool_map.keys():
|
||
if String(_db.settlement.core.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.core.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.core.pool_clamp_lo)], wall_ok)
|
||
var hd_maps := 0
|
||
for eff_id in _db.settlement.core.effect_pool_map.keys():
|
||
if String(_db.settlement.core.effect_pool_map[eff_id]) == "hero_defense":
|
||
hd_maps += 1
|
||
Kit.check("hero_defense 池映射数==0(绊线钉——红=已加映射,去核上方护栏是否已同步激活)", hd_maps == 0)
|
||
# ③ 单位表 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.damage_roll.roll_tier_bounds
|
||
var discounts: Array = _db.settlement.damage_roll.roll_tier_discounts
|
||
Kit.check("掷点参数拍板值在位(d20 / nat20 暴击 ×200%% / 下限 1 / attack_bonus 0 / armor_roll 流)",
|
||
int(_db.settlement.damage_roll.roll_faces) == 20 and int(_db.settlement.damage_roll.crit_roll) == 20 \
|
||
and int(_db.settlement.damage_roll.crit_multiplier) == 200 and int(_db.settlement.damage_roll.damage_floor) == 1 \
|
||
and int(_db.settlement.damage_roll.attack_bonus) == 0 and String(_db.settlement.damage_roll.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)
|
||
for i in 3:
|
||
rng.derive_at(i)
|
||
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)
|
||
|
||
|
||
class _TreeSaveMock:
|
||
# 核代码增补:最小 save 形态——attunement.slots 只读 tree_level("att_socketN")
|
||
func tree_level(n: String) -> int:
|
||
return 1 if n == "att_socket1" else 0
|