169 lines
8.2 KiB
GDScript
169 lines
8.2 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 = [] # Array[Dictionary]
|
||
|
||
const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 商店房批 2026-09-21 四→五类(关卡节点地图 v1.1)
|
||
|
||
|
||
func validate() -> Array:
|
||
var errs: Array = []
|
||
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})
|
||
var nodes: Array = row.get("nodes", [])
|
||
if nodes.is_empty():
|
||
errs.append({"category": "domain", "message": "level %d 节点链为空" % L})
|
||
continue
|
||
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" % [L, t]})
|
||
continue
|
||
if t == "battle" or t == "boss":
|
||
if int(node.get("node_depth", -1)) < 0:
|
||
errs.append({"category": "domain", "message": "level %d %s 节点缺 node_depth" % [L, t]})
|
||
battle_count += 1
|
||
if t == "boss":
|
||
boss_count += 1
|
||
if boss_count != 1:
|
||
errs.append({"category": "structural", "message": "level %d Boss 节点数 = %d(须恰一)" % [L, boss_count]})
|
||
if battle_count < 2:
|
||
errs.append({"category": "structural", "message": "level %d 战斗节点(含 Boss)= %d(≥2 才构成关卡)" % [L, battle_count]})
|
||
if not is_graph:
|
||
# 单链形态纪律(第一关零迁移):末节点必须是 Boss(关底收尾——冒险结构 关卡定义)
|
||
if String(nodes[nodes.size() - 1].get("type", "")) != "boss":
|
||
errs.append({"category": "structural", "message": "level %d 末节点非 Boss(单链以 Boss 收尾)" % L})
|
||
# 图合法性(单链与图同一套算法——单链 = 出边恰一的退化形态,违规同样加载致命)
|
||
for e in _validate_graph(L, nodes, is_graph):
|
||
errs.append(e)
|
||
return errs
|
||
|
||
|
||
# 图合法性核心(返回错误列表):id 解析(图=node_depth / 单链=数组下标)→ 出边表 →
|
||
# 唯一性/前进方向/入口唯一/Boss 终点/双向可达六检。参考 关卡节点地图 v1.0 分支节点图规则。
|
||
func _validate_graph(L: int, nodes: Array, is_graph: bool) -> Array:
|
||
var errs: Array = []
|
||
var ids: Array = [] # 每节点 id(下标对齐)
|
||
var nexts: 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 图形态节点[%d] 缺 node_depth(全节点须编号化)" % [L, i]})
|
||
nid = i # 降级继续(后续校验仍有意义)
|
||
ids.append(nid)
|
||
if id_to_idx.has(nid):
|
||
errs.append({"category": "dup_key", "message": "level %d 节点编号重复: %d(关卡内唯一定位地址)" % [L, nid]})
|
||
id_to_idx[nid] = i
|
||
var nx: Array = []
|
||
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 节点 %d 出边指向不存在编号: %d" % [L, ids[i], tid]})
|
||
elif tid <= ids[i]:
|
||
errs.append({"category": "structural", "message": "level %d 连线 %d→%d 违反前进方向(目标编号须严格更大)" % [L, ids[i], tid]})
|
||
# ④ 入口唯一(无入边节点恰一——唯一入口)
|
||
var has_inbound := {}
|
||
for i in nodes.size():
|
||
for tid in nexts[i]:
|
||
has_inbound[tid] = true
|
||
var entries: Array = []
|
||
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 入口节点数 = %d(须恰一——无入边节点唯一)" % [L, 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]]})
|
||
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
|