Files
gplan-2/game/data/schemas/level_table.gd
T
DKChan c0dd442060 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 模板清出一并消失)
2026-10-24 15:52:21 +08:00

145 lines
6.4 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## VERTICAL SLICE - NOT FOR PRODUCTION
## 关卡节点表 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"] # 五类(关卡节点地图 v2.1)
# 行单点读取(表访问收口——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 info_visible_layers < 1:
errs.append({"category": "domain", "message": "info_visible_layers 越域 int≥1: %d" % info_visible_layers})
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})
return errs
# 图合法性校验单点(消费侧起关入口——生成图到手、进节点前调用;违规=致命,无软回退)。
# 判据=四检+良构基底:① 入口(无入边)≥1 且终点(无出边)≥1;② 全部节点从某入口可达且可达某终点
#(每条路径恰从一个入口出发、恰到好处一个终点,无孤儿无断头);③ 终点恒为 Boss(终点 type=boss 且 boss 无出边);
# ④ 无环(出边目标深度严格大于源)。良构=句柄唯一·出边全命中·type 在五类枚举·battle/boss 带深度。
# 入口/终点个数与参数档的一致性归产出侧(关卡节点图生成 v1.1),本处不核个数;商店位归冒烟断言把关,本处禁检。
static func validate_graph(nodes: Array) -> Array:
var errs: Array[Dictionary] = []
if nodes.is_empty():
errs.append({"category": "structural", "message": "图节点集为空"})
return errs
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": "未知节点类型: %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
# 正向可达集(含起点)
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()
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))