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))