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

+93 -168
View File
@@ -1,13 +1,14 @@
## VERTICAL SLICE - NOT FOR PRODUCTION
## 关卡节点表 schema:逐关节点链(单链/节点图两形态)的类型化读取(垂直切片验证:线性第一关承载元进度循环)。
## 两形态同跑一套图合法性算法,违例加载期致命(validate 报错由 ConfigDB 收敛为 fail-fast)。
## 关卡节点表 schema:逐关行集(关数/显示名/休整回复/迷雾层数)的类型化读取(垂直切片验证:改表即改关卡)。
## 图合法性只在 `validate_graph` 一处陈述(消费侧起关校验入口——关卡节点图生成 v1.1 产出规格为准);
## 表加载期不校验图(关卡表不再携带节点数据,图源=参数化生成器)。
class_name LevelTable
extends Resource
@export var rows: Array[Dictionary] = [] # Array[Dictionary]
@export var info_visible_layers: int = 2 # 信息迷雾可见层数 N(int ≥1;类型仅在邻近 N 层内可见——关卡节点地图 v2.1)
const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 五类(关卡节点地图 v1.1)
const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 五类(关卡节点地图 v2.1)
# 行单点读取(表访问收口——LevelRunner._find_level_row 禁直扫;键 = level_index)
@@ -38,182 +39,106 @@ func validate() -> Array:
var heal: int = int(row.get("rest_heal_pct", -1))
if heal < 0 or heal > 100:
errs.append({"category": "domain", "message": "level %d rest_heal_pct 越域 [0,100]" % L})
# 模板池(关卡节点图生成 v0.4):row.templates 非空 = 池形态,池内全模板加载期校验
# (违规致命——不只抽中的那张);空 = 单图形态(row.nodes 直读,向后兼容)。
var node_lists: Array[Array] = [] # [ [上下文标签, nodes 数组], ... ]
var templates: Array[Variant] = row.get("templates", [])
if templates.is_empty():
node_lists.append(["", row.get("nodes", [])])
else:
for j in templates.size():
var tpl = templates[j]
if typeof(tpl) != TYPE_DICTIONARY or (tpl.get("nodes", []) as Array).is_empty():
errs.append({"category": "domain", "message": "level %d 模板[%d] 缺 nodes 或为空" % [L, j]})
continue
node_lists.append(["模板[%d]=%s" % [j, String(tpl.get("display_name", ""))], tpl.get("nodes", [])])
for nl in node_lists:
for e in _validate_level_nodes(L, nl[0], nl[1]):
errs.append(e)
return errs
# 单节点链校验体(validate 抽出——row.nodes 与池内每张模板同跑一套;ctx = 消息上下文标签)
func _validate_level_nodes(L: int, ctx: String, nodes: Array) -> Array:
# 图合法性校验单点(消费侧起关入口——生成图到手、进节点前调用;违规=致命,无软回退)。
# 判据=四检+良构基底:① 入口(无入边)≥1 且终点(无出边)≥1;② 全部节点从某入口可达且可达某终点
#(每条路径恰从一个入口出发、恰到好处一个终点,无孤儿无断头);③ 终点恒为 Boss(终点 type=boss 且 boss 无出边);
# ④ 无环(出边目标深度严格大于源)。良构=句柄唯一·出边全命中·type 在五类枚举·battle/boss 带深度。
# 入口/终点个数与参数档的一致性归产出侧(关卡节点图生成 v1.1),本处不核个数;商店位归冒烟断言把关,本处禁检。
static func validate_graph(nodes: Array) -> Array:
var errs: Array[Dictionary] = []
var tag: String = "" if ctx.is_empty() else "〔%s〕" % ctx
if nodes.is_empty():
errs.append({"category": "domain", "message": "level %d%s 节点链为空" % [L, tag]})
errs.append({"category": "structural", "message": "图节点集为空"})
return errs
var boss_count := 0
var battle_count := 0
var is_graph := false
for node in nodes:
if node.has("next"):
is_graph = true
var t: String = String(node.get("type", ""))
var by_id := {}
var indeg := {}
for i in nodes.size():
var node: Dictionary = nodes[i]
for k in ["id", "type", "depth", "next"]:
if not node.has(k):
errs.append({"category": "structural", "message": "节点[%d] 缺字段 %s" % [i, k]})
var t := String(node.get("type", ""))
if not NODE_TYPES.has(t):
errs.append({"category": "enum", "message": "level %d%s 未知节点类型: %s" % [L, tag, t]})
continue
if t == "battle" or t == "boss":
if int(node.get("node_depth", -1)) < 0:
errs.append({"category": "domain", "message": "level %d%s %s 节点缺 node_depth" % [L, tag, t]})
battle_count += 1
if t == "boss":
boss_count += 1
if boss_count != 1:
errs.append({"category": "structural", "message": "level %d%s Boss 节点数 = %d(须恰一)" % [L, tag, boss_count]})
if battle_count < 2:
errs.append({"category": "structural", "message": "level %d%s 战斗节点(含 Boss)= %d(≥2 才构成关卡)" % [L, tag, battle_count]})
if not is_graph:
# 单链形态纪律(第一关零迁移):末节点必须是 Boss(关底收尾——冒险结构 关卡定义)
if String(nodes[nodes.size() - 1].get("type", "")) != "boss":
errs.append({"category": "structural", "message": "level %d%s 末节点非 Boss(单链以 Boss 收尾)" % [L, tag]})
# 图合法性(单链与图同一套算法——单链 = 出边恰一的退化形态,违规同样加载致命)
for e in _validate_graph(L, nodes, is_graph, tag):
errs.append(e)
# U2 目检规则(两条,加载期同检):①单链前三位纯战斗(奖励/休整禁入前三)
if not is_graph and nodes.size() >= 3:
for i in 3:
if String(nodes[i].get("type", "")) != "battle":
errs.append({"category": "structural", "message": "level %d%s 第 %d 位非战斗(单链前三位纯战斗——U2 目检规则)" % [L, tag, i + 1]})
errs.append({"category": "enum", "message": "未知节点类型: %s" % t})
var d: int = int(node.get("depth", -1))
if d < 0:
errs.append({"category": "domain", "message": "节点 %s 深度越域(≥0): %d" % [str(node.get("id", i)), d]})
if t == "battle" and not node.has("depth"):
errs.append({"category": "structural", "message": "battle 节点 %s 缺深度" % str(node.get("id", i))})
var nid: int = int(node.get("id", i))
if by_id.has(nid):
errs.append({"category": "dup_key", "message": "节点句柄重复: %d" % nid})
by_id[nid] = node
if not indeg.has(nid):
indeg[nid] = 0
if not errs.is_empty():
return errs
var outs := {}
for nid in by_id.keys():
outs[nid] = []
for nid in by_id.keys():
for tid in (by_id[nid]["next"] as Array):
if not by_id.has(int(tid)):
errs.append({"category": "dangling_next", "message": "节点 %d 出边指向不存在句柄: %s" % [nid, tid]})
continue
if int(by_id[int(tid)]["depth"]) <= int(by_id[nid]["depth"]):
errs.append({"category": "structural", "message": "连线 %d→%d 违反前进方向(目标深度须严格大于源)" % [nid, int(tid)]})
outs[nid].append(int(tid))
indeg[int(tid)] = int(indeg[int(tid)]) + 1
if not errs.is_empty():
return errs
var entries := []
var terminals := []
for nid in by_id.keys():
if int(indeg[nid]) == 0:
entries.append(nid)
if (outs[nid] as Array).is_empty():
terminals.append(nid)
if String(by_id[nid]["type"]) != "boss":
errs.append({"category": "structural", "message": "终点 %d 非 Boss(终点恒为 Boss)" % nid})
elif String(by_id[nid]["type"]) == "boss":
errs.append({"category": "structural", "message": "Boss 节点 %d 带出边(终点不可外出)" % nid})
if entries.is_empty():
errs.append({"category": "structural", "message": "入口节点数 = 0(须 ≥1)"})
if terminals.is_empty():
errs.append({"category": "structural", "message": "终点节点数 = 0(须 ≥1)"})
if not errs.is_empty():
return errs
var from_entry := {}
for eid in entries:
_mark_reachable(int(eid), outs, from_entry)
var to_terminal := {}
for tid in terminals:
_mark_backward(int(tid), outs, to_terminal)
for nid in by_id.keys():
if not from_entry.has(nid):
errs.append({"category": "structural", "message": "节点 %d 从任何入口都不可达(孤儿)" % nid})
if not to_terminal.has(nid):
errs.append({"category": "structural", "message": "节点 %d 沿连线到不了任何终点(断头)" % nid})
return errs
# 图合法性核心(返回错误列表):id 解析(图=node_depth / 单链=数组下标)→ 出边表 →
# 唯一性/前进方向/入口唯一/Boss 终点/双向可达六检。参考 关卡节点地图 v1.0 分支节点图规则。
func _validate_graph(L: int, nodes: Array, is_graph: bool, tag: String = "") -> Array:
var errs: Array[Dictionary] = []
var ids: Array[int] = [] # 每节点 id(下标对齐)
var nexts: Array[Array] = [] # 每节点出边 id 数组
var id_to_idx := {}
for i in nodes.size():
var node: Dictionary[Variant, Variant] = nodes[i]
var nid: int = int(node.get("node_depth", i)) if is_graph else i
if is_graph and not node.has("node_depth"):
errs.append({"category": "structural", "message": "level %d%s 图形态节点[%d] 缺 node_depth(全节点须编号化)" % [L, tag, i]})
nid = i # 降级继续(后续校验仍有意义)
ids.append(nid)
if id_to_idx.has(nid):
errs.append({"category": "dup_key", "message": "level %d%s 节点编号重复: %d(关卡内唯一定位地址)" % [L, tag, nid]})
id_to_idx[nid] = i
var nx: Array[int] = []
if node.has("next"):
for v in node.get("next", []):
nx.append(int(v))
elif i < nodes.size() - 1:
nx.append(i + 1) # 单链隐式出边 = 数组下一项(id 即下标)
nexts.append(nx)
# ② 出边引用存在 + 前进方向(目标 id 严格递增 ⇒ 无环——环必含非递增边)
for i in nodes.size():
for tid in nexts[i]:
if not id_to_idx.has(tid):
errs.append({"category": "dangling_next", "message": "level %d%s 节点 %d 出边指向不存在编号: %d" % [L, tag, ids[i], tid]})
elif tid <= ids[i]:
errs.append({"category": "structural", "message": "level %d%s 连线 %d→%d 违反前进方向(目标编号须严格更大)" % [L, tag, ids[i], tid]})
# ③b 出度上限(「节点出度最大为 2」):单节点出边 >2 即加载致命。
# 规则连带:出度 ≤2 + 入口唯一下 BFS 层宽上限 = 4(序列 1,2,4——层宽上限 5 与出度约束
# 不相容,模板池按层宽 ≤4 构造)。
for i in nodes.size():
if nexts[i].size() > 2:
errs.append({"category": "fanout_violation", "message": "level %d%s 节点 %d 出度 %d > 2(出度上限拍板 2026-10-01)" % [L, tag, ids[i], nexts[i].size()]})
# ④ 入口唯一(无入边节点恰一——唯一入口)
var has_inbound := {}
for i in nodes.size():
for tid in nexts[i]:
has_inbound[tid] = true
var entries: Array[int] = []
for i in nodes.size():
if not has_inbound.has(ids[i]):
entries.append(i)
if entries.size() != 1:
errs.append({"category": "structural", "message": "level %d%s 入口节点数 = %d(须恰一——无入边节点唯一)" % [L, tag, entries.size()]})
# ⑤ Boss 恒唯一终点:无出边 + 编号最大(图形态;单链由"末节点 Boss"既有校验覆盖终点性)
for i in nodes.size():
if String(nodes[i].get("type", "")) != "boss":
continue
if not nexts[i].is_empty():
errs.append({"category": "structural", "message": "level %d Boss 节点 %d 带出边(终点不可外出)" % [L, ids[i]]})
if is_graph and ids[i] != ids.max():
errs.append({"category": "structural", "message": "level %d Boss 编号 %d 非最大 %d(终点编号最大)" % [L, ids[i], ids.max()]})
if entries.size() == 1:
# ⑥ 从入口可达全部(无孤儿)· ⑦ 全部节点可达 Boss(逆向)——邻接表统一下标空间
var fwd := []
var back := []
for i in nodes.size():
fwd.append([])
back.append([])
for i in nodes.size():
for tid in nexts[i]:
if id_to_idx.has(tid):
fwd[i].append(id_to_idx[tid])
back[id_to_idx[tid]].append(i)
var reach := _reachable(entries[0], fwd, nodes.size())
for i in nodes.size():
if not reach.has(i):
errs.append({"category": "structural", "message": "level %d 节点 %d 从入口不可达(孤儿节点)" % [L, ids[i]]})
var boss_idx := -1
for i in nodes.size():
if String(nodes[i].get("type", "")) == "boss":
boss_idx = i
if boss_idx >= 0:
var to_boss := _reachable(boss_idx, back, nodes.size())
for i in nodes.size():
if not to_boss.has(i):
errs.append({"category": "structural", "message": "level %d 节点 %d 沿连线不可达 Boss" % [L, ids[i]]})
# ⑧ U2 目检规则:BFS 同深度层节点数上限 5(分支宽度天花板)
var layer_of := {}
var bfs: Array[Variant] = [entries[0]]
layer_of[entries[0]] = 0
while not bfs.is_empty():
var cur: int = bfs.pop_front()
for tid in nexts[cur]:
var tidx: int = int(id_to_idx.get(tid, -1))
if tidx >= 0 and not layer_of.has(tidx):
layer_of[tidx] = int(layer_of[cur]) + 1
bfs.append(tidx)
var width := {}
for i in nodes.size():
if layer_of.has(i):
width[layer_of[i]] = int(width.get(layer_of[i], 0)) + 1
for d in width.keys():
if int(width[d]) > 5:
errs.append({"category": "structural", "message": "level %d BFS 第 %d 层 %d 节点(同层上限 5——U2 目检规则)" % [L, d, int(width[d])]})
return errs
# BFS 可达集(邻接表 adj: 节点下标 → 下标数组)
func _reachable(start: int, adj: Array, n: int) -> Dictionary:
var seen := {}
if start < 0 or start >= n:
return seen
# 正向可达集(含起点)
static func _mark_reachable(start: int, outs: Dictionary, seen: Dictionary) -> void:
var queue: Array[Variant] = [start]
seen[start] = true
while not queue.is_empty():
var cur: int = queue.pop_front()
if cur >= adj.size():
continue
for nb in adj[cur]:
if not seen.has(nb):
seen[nb] = true
queue.append(nb)
return seen
for tid in outs[cur]:
if not seen.has(int(tid)):
seen[int(tid)] = true
queue.append(int(tid))
# 逆向可达集(含起点)——反向邻接由 outs 现推(图规模小,禁第二份邻接表持有)
static func _mark_backward(target: int, outs: Dictionary, seen: Dictionary) -> void:
var queue: Array[Variant] = [target]
seen[target] = true
while not queue.is_empty():
var cur: int = queue.pop_front()
for nid in outs.keys():
if (outs[nid] as Array).has(cur) and not seen.has(int(nid)):
seen[int(nid)] = true
queue.append(int(nid))
+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
@@ -48,7 +48,7 @@
| T 卡 | 状态 |
|---|---|
| T0 事实坐实与卡① 接口对齐 | 待做——接口四件已可由卡① 定稿直读(`generate(params, seed)` 不收 level_index、`{nodes,entries,terminals}`、节点 `id/type/depth/next`、参数档字段名),本段只需核 §3 行号漂移 |
| T1 加载校验换代(四检+良构) | 未开工——任务见分支待办 #2 |
| T1 加载校验换代(四检+良构) | 完成(提交随本报告末笔)——`LevelTable.validate_graph(nodes)` 静态单点在位,表加载期图校验与旧六判据退役,新宿主 `level_table_validate_test`〔v1〕15 条+manifest 登记,run_all 全绿 59 套件 |
| T2 消费侧切源与行进/定位/确定性换代 | 未开工——任务见分支待办 #3 |
| T3 模板清理与 S1–S8 断言换代 | 未开工(S5/V11 覆盖断言已由 §一 提前落位)——任务见分支待办 #4 |
| T4 产出物 | 本报告即起始件;代拍板单与反哺点清单随收口补 |