222 lines
11 KiB
GDScript
222 lines
11 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证
|
||
# Date: 2026-09-11(2026-09-17 分支节点图批:nodes 升级图结构——单链=出边恰一的退化形态)
|
||
# 数据配置 LevelTable schema(关卡节点地图 v1.0 承载面)。
|
||
# 节点类型 closed enum:battle(战斗,须带 node_depth)/ reward(直通占位点)/ rest(血量恢复点)/ boss(关底 Boss,须带 node_depth)
|
||
# / shop(商店房——GP+商店房实施批 2026-09-21 五类落地:同 reward/rest 无战斗行、无 node_depth 要求,
|
||
# 图形态仍须编号化;零新增图合法性检查,必经位由冒烟断言承载非加载校验)。
|
||
# 图形态(行集内任一节点带 "next" 出边表即整行按图校验):节点编号 = node_depth(关卡内唯一定位地址,
|
||
# 全节点须带,reward/rest 亦然);next = [出边节点编号...](连线只朝前进方向:目标编号严格大于自身)。
|
||
# 单链形态(零 "next" 键——第一关现状零迁移):隐式出边 = 数组顺序下一项,节点编号 = 数组下标
|
||
# (仅校验期内部使用;node_depth 仍只对 battle/boss 必填)——单链经同一套图合法性算法。
|
||
# 图合法性(违规加载致命,validate 报错由 ConfigDB 收敛为 fail-fast):
|
||
# 编号唯一 · 入口唯一(无入边节点恰一)· Boss 恒唯一终点(无出边且编号最大)· 连线只朝前进方向(⇒无环)
|
||
# · 从入口可达全部节点 · 全部节点沿连线可达 Boss。
|
||
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 = 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 = 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]})
|
||
# ④ 入口唯一(无入边节点恰一——唯一入口)
|
||
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 = [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 = [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
|