feat(迭代35卡②T1): 加载校验换代——LevelTable.validate_graph(nodes) 静态单点承载四检+良构基底(①入口≥1 且终点≥1 ②全员从某入口可达且可达某终点 ③终点恒 Boss 且 Boss 无出边 ④出边目标深度严格大于源=无环;良构=句柄唯一·出边全命中·type 五类枚举·battle 带深度),表加载期图校验与旧六判据(Boss 恰一/战斗≥2/单链末 Boss/前三位纯战斗/出度≤2/Boss 编号最大)连同模板池校验分支、BFS 层宽≤5 一并退役,行级校验保留

- 新宿主 tests/unit/core/level_table_validate_test.gd〔v1〕15 条(正例三形:多入口多终点/含岔口单终点/单链退化显式出边;负例八类;范围反证三条:无商店图不报错·双入口合法·缺 next 字段仍拒)+run_all manifest 登记一行(59 套件全绿)
- 判据②与④在 DAG 上互斥的实证:无环图每个源点必为入口、每条极长路必止于终点,故「孤儿/断头」构造不出独立负例——② 保留为 ④ 失效时的纵深防御面,测试以两独立链正证不误伤(该关系拟入反哺点,文档判据②的表述宜注明其兜底定位)
- 旧断言随动四条(声明,非静默放宽):level_shop_economy ③ 段整段改走 validate_graph 并把「入口唯一违例」翻为合法正例、「孤儿」改为非 Boss 终点;level_map_type_vis 夹具注释订正;round_flow_skill_pool 敌表池清单 20→28 行补齐覆盖批八行池克隆键;board_gen_reward「(2,3) ceil 上取」改判精确命中并以临时摘同行的夹具面保留 ceil 能力判据(覆盖批补齐键域后旧判据对象消亡)
- 件核:全仓 grep 六判据消息文本零残留(唯一余留在 level.tres 表尾模板生成史注释,随 T3 模板清出一并消失)
This commit is contained in:
DKChan committed 2026-10-24 15:52:21 +08:00
1 parent 6a8bde9a8e
commit c0dd442060
9 files changed
+249 -197

No files matched your search

+1
View File
@@ -54,6 +54,7 @@ func _suites() -> Array:
{"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 秘宝结构冻结断言面
{"path": "res://tests/unit/core/node_map_generator_test.gd", "expect": 0}, # 节点图生成器产出合格/可复现/岔路取舍/恰一店/归属锁定/战斗保底/出口结构/RNG 纯流面(卡① V1·V3·V5–V7·V9·V10)
{"path": "res://tests/unit/core/node_map_growth_test.gd", "expect": 0}, # 生成图生长递增+参数无解 fail-fast 子进程通道(卡① V8/V4)
{"path": "res://tests/unit/core/level_table_validate_test.gd", "expect": 0}, # 消费侧加载校验四检+良构基底正负向(卡② T1/V1〔v1〕)
{"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:价曲线/优先序/停点清零/钱包单口)
{"path": "res://tests/unit/core/level_map_type_vis_test.gd", "expect": 0}, # 信息迷雾——类型可见性(层距 N+Boss/候选豁免+单圈+N 读表+续玩同值)
@@ -596,6 +596,16 @@ func _smoke_skill_pool_data() -> void:
"3_battle_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1},
"3_battle_7_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2},
"3_boss_9_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
# 迭代 35 生成图深度扩面覆盖批八行=同关同 row_kind 顶档行的池克隆(零新池、零新技能引用):
# 战斗带顶 L1→6/L2→9/L3→13;Boss 按终点深度分带 L1→6、L2→8、L3→11/12/13
"1_battle_6_bugbear": {"attack": 6, "charge": 2, "heavy": 2},
"1_boss_6_ogre_chief": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2},
"2_battle_9_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2},
"2_boss_8_drider": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2},
"3_battle_13_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2},
"3_boss_11_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
"3_boss_12_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
"3_boss_13_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
}
var pools_ok := true
for row in _db.enemy.rows:
@@ -608,7 +618,7 @@ func _smoke_skill_pool_data() -> void:
got[String(entry.skill_id)] = int(entry.weight)
if got != expect_pools[rk]:
pools_ok = false
Kit.check("二十行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕,其余带键仍恰一行)", pools_ok)
Kit.check("二十八行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕;迭代 35 覆盖批八行为顶档池克隆,逐行同检)", pools_ok)
var basic_count_ok := true
for row in _db.enemy.rows:
var n := 0
+17 -2
View File
@@ -134,9 +134,24 @@ func _smoke_board_generator() -> void:
var row = _db.resolve_board_gen(L, 0)
Kit.check("BoardGen 行解析 (%d,0)" % L, not row.is_empty())
if L == 2:
# R1 ceil 口径:无精确行向上取整保证偏难——L2 深度 3 为恢复点无行,解析到深度 4
# 迭代 35 生成图深度扩面覆盖批随动(声明):旧口径「(2,3) 无精确行→ceil 上取到 4」的判据对象
# 是模板时代键域缺口;覆盖批补齐 0–13 后 (2,3) 有精确行,生产表上不再存在可走 ceil 的空位。
# 判据不丢——精确命中改判在册,ceil 能力用「临时摘除该行」的夹具面保留,随后原样还原。
var exact_row: Dictionary = _db.resolve_board_gen(2, 3)
Kit.check("BoardGen 精确命中 (2,3)(覆盖批键域补齐)", int(exact_row.get("node_depth", -1)) == 3)
var gap_idx := -1
for i in _db.board_gen.rows.size():
if int(_db.board_gen.rows[i].get("level_index", -1)) == 2 and int(_db.board_gen.rows[i].get("node_depth", -1)) == 3:
gap_idx = i
break
var saved: Dictionary = _db.board_gen.rows[gap_idx]
var rows_before: int = _db.board_gen.rows.size()
_db.board_gen.rows.remove_at(gap_idx)
var ceil_row: Dictionary = _db.resolve_board_gen(2, 3)
Kit.check("R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)", int(ceil_row.get("node_depth", -1)) == 4)
Kit.check("R1 ceil 能力不失(夹具摘除 (2,3)→向上取整到 4·偏难口径)", int(ceil_row.get("node_depth", -1)) == 4)
_db.board_gen.rows.insert(gap_idx, saved)
Kit.check("夹具表原样还原(行数与解析回到覆盖批现值)",
_db.board_gen.rows.size() == rows_before and int(_db.resolve_board_gen(2, 3).get("node_depth", -1)) == 3)
var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db)
Kit.check("L%d 生成产物非空" % L, b1 != null)
if b1 == null:
@@ -57,7 +57,7 @@ func _chain_nodes() -> Array:
]
# validate 正样本(无跨层跳边的合法图:入口唯一、Boss 编号最大收尾、层宽 ≤5)
# 表行夹具的正样本(无跨层跳边的合法图——表加载期只核行级字段,图合法性判据见 validate_graph·卡② T1 换代)
func _valid_graph_nodes() -> Array:
return [
{"node_depth": 0, "type": "battle", "next": [1, 2]},
+22 -24
View File
@@ -147,8 +147,8 @@ func _battle_st(policy: String) -> Dictionary:
func _smoke_level_graph() -> void:
print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---")
var autoplay = load("res://sim/level_autoplay.gd")
# ① L1 单链与 L2 图经同一套加载校验(单链 = 出边恰一的退化形态——load_tables 全绿已含,显式复述)
Kit.check("LevelTable 图合法性校验:L1 单链 + L2 图同套算法零错误", _db.level.validate().is_empty())
# ① 关卡表行级校验零错误(图合法性自 T1 起不再由表加载期承担——关卡表携带的模板数据随卡② T3 清出)
Kit.check("LevelTable 行级校验零错误(图合法性移交 validate_graph)", _db.level.validate().is_empty())
# ② L2 行集结构(网状图建议规模)
var l2 := {}
for row in _db.level.rows:
@@ -164,39 +164,37 @@ func _smoke_level_graph() -> void:
Kit.check("L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)", int(tcount.battle) == 6 and int(tcount.boss) == 1)
Kit.check("L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)", int(tcount.reward) == 1 and int(tcount.rest) == 1 and int(tcount.shop) == 1)
Kit.check("L2 真实岔路 = %d 处(≥2)" % branch, branch >= 2)
# ③ 图校验正反断言(违规行集定位到类别——加载期致命的同款算法)
var lt = load("res://data/schemas/level_table.gd").new()
var good := [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [2]},
{"node_depth": 2, "type": "boss"}]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": good}])
Kit.check("图校验正例:两岔合流小图零错误", lt.validate().is_empty())
# ③ 图校验正反断言改走 validate_graph 公有面(迭代 35 卡② T1 随动·声明):
# · 夹具字段随生成图读面换代(id/type/depth/next 取代 node_depth 双编号空间);
# · 「入口唯一违例」判据随模板池退役——多入口是现行合法形态(关卡节点图生成 v1.1 结构铁律),
# 该条改判正例;「孤儿节点」条去掉「双入口同体违例」措辞,只保留到不了终点/进不去的违例面;
# · 前进方向判据由「目标编号严格递增」换为「目标深度严格大于源」(契约 1④)。
var LT = load("res://data/schemas/level_table.gd")
var good := [{"id": 0, "type": "battle", "depth": 0, "next": [1, 2]},
{"id": 1, "type": "battle", "depth": 1, "next": [2]},
{"id": 2, "type": "boss", "depth": 2, "next": []}]
Kit.check("图校验正例:两岔合流小图零错误〔v1〕", LT.validate_graph(good).is_empty())
var dup = JSON.parse_string(JSON.stringify(good))
dup[1].node_depth = 0
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dup}])
Kit.check("图校验拒绝:节点编号重复(关卡内唯一定位地址)", _has_err_category(lt.validate(), "dup_key"))
dup[1].id = 0
Kit.check("图校验拒绝:句柄重复(图内唯一句柄)〔v1〕", _has_err_category(LT.validate_graph(dup), "dup_key"))
var dual = JSON.parse_string(JSON.stringify(good))
dual.append({"node_depth": 9, "type": "battle", "next": [2]})
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dual}])
Kit.check("图校验拒绝:第二无入边节点(入口唯一违例——入口节点数 = 3)", _has_err_category(lt.validate(), "structural"))
dual.append({"id": 9, "type": "battle", "depth": 0, "next": [2]})
Kit.check("图校验正例:多入口合法(入口唯一判据随模板池退役)〔v1〕", LT.validate_graph(dual).is_empty())
var boss_out = JSON.parse_string(JSON.stringify(good))
boss_out[2].next = [1]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": boss_out}])
Kit.check("图校验拒绝:Boss 带出边(终点不可外出)", _has_err_category(lt.validate(), "structural"))
Kit.check("图校验拒绝:Boss 带出边(终点不可外出)〔v1〕", _has_err_category(LT.validate_graph(boss_out), "structural"))
var cyc = JSON.parse_string(JSON.stringify(good))
cyc[1].next = [0]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": cyc}])
Kit.check("图校验拒绝:回边 1→0(连线只朝前进方向 ⇒ 无环)", _has_err_category(lt.validate(), "structural"))
Kit.check("图校验拒绝:回边 1→0(目标深度须严格大于源)〔v1〕", _has_err_category(LT.validate_graph(cyc), "structural"))
var dang = JSON.parse_string(JSON.stringify(good))
dang[0].next = [1, 7]
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dang}])
Kit.check("图校验拒绝:出边指向不存在编号(悬空致命)", _has_err_category(lt.validate(), "dangling_next"))
Kit.check("图校验拒绝:出边指向不存在句柄(悬空致命)〔v1〕", _has_err_category(LT.validate_graph(dang), "dangling_next"))
var orphan = JSON.parse_string(JSON.stringify(good))
orphan[0].next = [1]
orphan[1].next = [2]
orphan.append({"node_depth": 5, "type": "battle", "next": [2]})
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": orphan}])
var orphan_errs: Array = lt.validate()
Kit.check("图校验拒绝:孤儿节点(从入口不可达 / 双入口同体违例)", _has_err_category(orphan_errs, "structural"))
orphan.append({"id": 5, "type": "battle", "depth": 1, "next": [6]})
orphan.append({"id": 6, "type": "rest", "depth": 2, "next": []})
Kit.check("图校验拒绝:非 Boss 终点(终点恒为 Boss)〔v1〕", _has_err_category(LT.validate_graph(orphan), "structural"))
# ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开)
# 战斗随机值面随全局序漂移——max 路径种子按行为面扫描(路线形状/商店/选路口为断言本体)
var win_min = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "min", 0)
@@ -0,0 +1,102 @@
# tests/unit/core/level_table_validate_test.gd — 消费侧加载校验四检单测(迭代 35 卡② T1/V1)
# 覆盖:validate_graph 正例(多入口多终点/含岔口单终点/单链退化显式出边)+负例八类
#(非 Boss 终点/Boss 带出边/环=回边与自环/双独立链全员可达正证/悬空出边/句柄重复/battle 缺深度/空图)
#+「勿扩勿缩」反证:商店位与入口终点个数不在本检范围(关卡节点地图 v2.1「位置约束由冒烟断言把关」)。
# 运行:godot --headless --path game --script res://tests/unit/core/level_table_validate_test.gd(expect 0)
# 协议(tests/README):extends SceneTree + quit(code);夹具资源直构不碰生产表。
extends SceneTree
const Kit := preload("res://tests/unit/test_kit.gd")
const LevelTableScript := preload("res://data/schemas/level_table.gd")
func _init() -> void:
_test_positive()
_test_negative()
_test_scope_not_extended()
quit(Kit.finish("level_table_validate_test"))
func _n(id: int, type_: String, depth: int, outs: Array) -> Dictionary:
return {"id": id, "type": type_, "depth": depth, "next": outs}
func _errs(nodes: Array) -> Array:
return LevelTableScript.validate_graph(nodes)
func _has(errs: Array, cat: String, needle: String = "") -> bool:
for e in errs:
if String(e["category"]) != cat:
continue
if needle == "" or String(e["message"]).contains(needle):
return true
return false
# ---- 正例(四检全过)----
func _test_positive() -> void:
# 多入口多终点:两入口共汇入一岔口,两终点各在互异深度
var multi := [
_n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]),
_n(2, "battle", 1, [3, 5]),
_n(3, "rest", 2, [4]), _n(4, "shop", 3, [6]), _n(6, "boss", 4, []),
_n(5, "reward", 2, [7]), _n(7, "shop", 3, [8]), _n(8, "boss", 5, [])]
Kit.check("四检正例:多入口多终点图零错误〔v1〕", _errs(multi).is_empty())
# 含岔口单终点:岔口两侧链与主去路同深、汇入同一后继
var forked := [
_n(0, "battle", 0, [1]),
_n(1, "battle", 1, [2, 3]),
_n(2, "reward", 2, [4]), _n(3, "rest", 2, [4]),
_n(4, "shop", 3, [5]), _n(5, "boss", 4, [])]
Kit.check("四检正例:含岔口单终点图零错误〔v1〕", _errs(forked).is_empty())
# 单链退化形态合法(校验不设「必有岔口」)
var chain := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [3]), _n(3, "boss", 3, [])]
Kit.check("四检正例:单链退化图零错误(不设必有岔口)〔v1〕", _errs(chain).is_empty())
# ---- 负例九类(各报对应类别与判据消息)----
func _test_negative() -> void:
var non_boss_end := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [])]
Kit.check("负例:终点非 Boss 命中「终点恒为 Boss」〔v1〕",
_has(_errs(non_boss_end), "structural", "终点 1 非 Boss"))
var boss_out := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, [2]), _n(2, "shop", 2, [])]
Kit.check("负例:Boss 带出边被拒〔v1〕", _has(_errs(boss_out), "structural", "Boss 节点 1 带出边"))
var back_edge := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 0, [3]), _n(3, "boss", 4, [])]
Kit.check("负例:回边(目标深度不严格大于源)命中前进方向判据〔v1〕",
_has(_errs(back_edge), "structural", "违反前进方向"))
var cycle := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [1]), _n(3, "boss", 3, [])]
Kit.check("负例:环(2→1 回边)被前进方向拦下〔v1〕", _has(_errs(cycle), "structural", "违反前进方向"))
# 判据②(无孤儿无断头)与判据④(前进方向)在无环图上互斥:DAG 的每个源点都是入口、每条极长路都止于终点,
# 故 ② 是④失效时的纵深防御面,构造不出独立负例——此处以「两独立链双入口双终点」正证 ② 不误伤。
var two_components := [
_n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []),
_n(3, "battle", 0, [4]), _n(4, "rest", 1, [5]), _n(5, "boss", 2, [])]
Kit.check("判据②正证:两独立链(双入口双终点)不误伤·全员可达〔v1〕", _errs(two_components).is_empty())
var self_loop := [_n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []), _n(3, "battle", 1, [3])]
Kit.check("判据②兜底面:自环先被前进方向拦下(④ 与 ② 互斥的实证)〔v1〕",
_has(_errs(self_loop), "structural", "违反前进方向"))
var dangling := [_n(0, "battle", 0, [9]), _n(1, "boss", 1, [])]
Kit.check("负例:悬空出边报 dangling_next〔v1〕", _has(_errs(dangling), "dangling_next"))
var dup := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, []), _n(1, "shop", 2, [])]
Kit.check("负例:句柄重复报 dup_key〔v1〕", _has(_errs(dup), "dup_key"))
var no_depth := [_n(0, "battle", 0, [1]), {"id": 1, "type": "battle", "next": [2]}, _n(2, "boss", 2, [])]
Kit.check("负例:battle 节点缺深度被良构基底拒〔v1〕", _has(_errs(no_depth), "structural", "缺深度"))
Kit.check("负例:空节点集被拒〔v1〕", _has(_errs([]), "structural", "图节点集为空"))
var bad_type := [_n(0, "mine", 0, [1]), _n(1, "boss", 1, [])]
Kit.check("负例:类型出域报 enum〔v1〕", _has(_errs(bad_type), "enum"))
# ---- 「勿扩勿缩」反证:本检不含商店位与个数对参数档的核对 ----
func _test_scope_not_extended() -> void:
# 合法形状但全程无商店/三个终点同入口图——四检不该因此报错(商店位归冒烟断言、个数归产出侧)
var no_shop := [_n(0, "battle", 0, [1, 2]), _n(1, "battle", 1, [3]), _n(2, "rest", 1, [3]), _n(3, "boss", 2, [])]
Kit.check("范围反证:无商店图不在加载校验内报错(商店位由冒烟断言把关)〔v1〕", _errs(no_shop).is_empty())
var two_entries := [_n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]), _n(2, "boss", 1, [])]
Kit.check("范围反证:双入口合法(入口唯一判据已随模板池退役,个数归产出侧)〔v1〕", _errs(two_entries).is_empty())
# 良构字段缺失(无 next 键)该报错——不属被退役判据
var missing_key := [{"id": 0, "type": "battle", "depth": 0}, _n(1, "boss", 1, [])]
Kit.check("范围反证:节点缺 next 字段仍被良构基底拒〔v1〕", _has(_errs(missing_key), "structural", "缺字段 next"))
@@ -0,0 +1 @@
uid://bhymincbe2j4m