## VERTICAL SLICE - NOT FOR PRODUCTION ## 关卡节点表 schema:逐关节点链(单链/节点图两形态)的类型化读取(垂直切片验证:线性第一关承载元进度循环)。 ## 两形态同跑一套图合法性算法,违例加载期致命(validate 报错由 ConfigDB 收敛为 fail-fast)。 class_name LevelTable extends Resource @export var rows: Array[Dictionary] = [] # Array[Dictionary] const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 商店房批 2026-09-21 四→五类(关卡节点地图 v1.1) # 行单点读取(2026-10-02 修复批 T5 表访问收口——LevelRunner._find_level_row 直扫收口;键 = level_index) func row_by_level_index(level_index: int) -> Dictionary: for row in rows: if int(row.level_index) == level_index: return row return {} func validate() -> Array: var errs: Array[Dictionary] = [] if rows.is_empty(): errs.append({"category": "empty_table", "message": "LevelTable 为空"}) return errs var seen := {} for row in rows: var L: int = int(row.get("level_index", 0)) if seen.has(L): errs.append({"category": "dup_key", "message": "LevelTable level_index 重复: %d" % L}) seen[L] = true if L < 1: errs.append({"category": "domain", "message": "level_index 须 ≥1"}) if String(row.get("display_name", "")) == "": errs.append({"category": "domain", "message": "level %d display_name 空" % L}) 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——M-3 迭代 5 T5):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: 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]}) 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", "")) 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 目检新规则(迭代 5 返工批 2026-09-29 两条,加载期同检):①单链前三位纯战斗(奖励/休整禁入前三) 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]}) 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 出度上限(T17 用户拍板 2026-10-01「节点出度最大为 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 目检新规则(迭代 5 返工批 2026-09-29):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 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