From c0dd442060d8a03aeffc1365b8f225c1627779cc Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sat, 24 Oct 2026 15:52:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=BF=AD=E4=BB=A335=E5=8D=A1=E2=91=A1T1):?= =?UTF-8?q?=20=E5=8A=A0=E8=BD=BD=E6=A0=A1=E9=AA=8C=E6=8D=A2=E4=BB=A3?= =?UTF-8?q?=E2=80=94=E2=80=94LevelTable.validate=5Fgraph(nodes)=20?= =?UTF-8?q?=E9=9D=99=E6=80=81=E5=8D=95=E7=82=B9=E6=89=BF=E8=BD=BD=E5=9B=9B?= =?UTF-8?q?=E6=A3=80=EF=BC=8B=E8=89=AF=E6=9E=84=E5=9F=BA=E5=BA=95=EF=BC=88?= =?UTF-8?q?=E2=91=A0=E5=85=A5=E5=8F=A3=E2=89=A51=20=E4=B8=94=E7=BB=88?= =?UTF-8?q?=E7=82=B9=E2=89=A51=20=E2=91=A1=E5=85=A8=E5=91=98=E4=BB=8E?= =?UTF-8?q?=E6=9F=90=E5=85=A5=E5=8F=A3=E5=8F=AF=E8=BE=BE=E4=B8=94=E5=8F=AF?= =?UTF-8?q?=E8=BE=BE=E6=9F=90=E7=BB=88=E7=82=B9=20=E2=91=A2=E7=BB=88?= =?UTF-8?q?=E7=82=B9=E6=81=92=20Boss=20=E4=B8=94=20Boss=20=E6=97=A0?= =?UTF-8?q?=E5=87=BA=E8=BE=B9=20=E2=91=A3=E5=87=BA=E8=BE=B9=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E6=B7=B1=E5=BA=A6=E4=B8=A5=E6=A0=BC=E5=A4=A7=E4=BA=8E?= =?UTF-8?q?=E6=BA=90=EF=BC=9D=E6=97=A0=E7=8E=AF=EF=BC=9B=E8=89=AF=E6=9E=84?= =?UTF-8?q?=EF=BC=9D=E5=8F=A5=E6=9F=84=E5=94=AF=E4=B8=80=C2=B7=E5=87=BA?= =?UTF-8?q?=E8=BE=B9=E5=85=A8=E5=91=BD=E4=B8=AD=C2=B7type=20=E4=BA=94?= =?UTF-8?q?=E7=B1=BB=E6=9E=9A=E4=B8=BE=C2=B7battle=20=E5=B8=A6=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=EF=BC=89=EF=BC=8C=E8=A1=A8=E5=8A=A0=E8=BD=BD=E6=9C=9F?= =?UTF-8?q?=E5=9B=BE=E6=A0=A1=E9=AA=8C=E4=B8=8E=E6=97=A7=E5=85=AD=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=EF=BC=88Boss=20=E6=81=B0=E4=B8=80=EF=BC=8F=E6=88=98?= =?UTF-8?q?=E6=96=97=E2=89=A52=EF=BC=8F=E5=8D=95=E9=93=BE=E6=9C=AB=20Boss?= =?UTF-8?q?=EF=BC=8F=E5=89=8D=E4=B8=89=E4=BD=8D=E7=BA=AF=E6=88=98=E6=96=97?= =?UTF-8?q?=EF=BC=8F=E5=87=BA=E5=BA=A6=E2=89=A42=EF=BC=8FBoss=20=E7=BC=96?= =?UTF-8?q?=E5=8F=B7=E6=9C=80=E5=A4=A7=EF=BC=89=E8=BF=9E=E5=90=8C=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E6=B1=A0=E6=A0=A1=E9=AA=8C=E5=88=86=E6=94=AF=E3=80=81?= =?UTF-8?q?BFS=20=E5=B1=82=E5=AE=BD=E2=89=A45=20=E4=B8=80=E5=B9=B6?= =?UTF-8?q?=E9=80=80=E5=BD=B9=EF=BC=8C=E8=A1=8C=E7=BA=A7=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E4=BF=9D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新宿主 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 模板清出一并消失) --- game/data/schemas/level_table.gd | 261 +++++++----------- game/tests/run_all.gd | 1 + .../round_flow_skill_pool_test.gd | 12 +- game/tests/unit/core/board_gen_reward_test.gd | 19 +- .../unit/core/level_map_type_vis_test.gd | 2 +- .../unit/core/level_shop_economy_test.gd | 46 ++- .../unit/core/level_table_validate_test.gd | 102 +++++++ .../core/level_table_validate_test.gd.uid | 1 + reports/iter35-卡②-实现报告-2026-10-24.md | 2 +- 9 files changed, 249 insertions(+), 197 deletions(-) create mode 100644 game/tests/unit/core/level_table_validate_test.gd create mode 100644 game/tests/unit/core/level_table_validate_test.gd.uid diff --git a/game/data/schemas/level_table.gd b/game/data/schemas/level_table.gd index d37a4061..a0753aa3 100644 --- a/game/data/schemas/level_table.gd +++ b/game/data/schemas/level_table.gd @@ -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)) diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 824ecf97..1890c23d 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -54,6 +54,7 @@ func _suites() -> Array: {"path": "res://tests/unit/core/treasure_items_test.gd", "expect": 0}, # 秘宝结构冻结断言面 {"path": "res://tests/unit/core/node_map_generator_test.gd", "expect": 0}, # 节点图生成器产出合格/可复现/岔路取舍/恰一店/归属锁定/战斗保底/出口结构/RNG 纯流面(卡① V1·V3·V5–V7·V9·V10) {"path": "res://tests/unit/core/node_map_growth_test.gd", "expect": 0}, # 生成图生长递增+参数无解 fail-fast 子进程通道(卡① V8/V4) + {"path": "res://tests/unit/core/level_table_validate_test.gd", "expect": 0}, # 消费侧加载校验四检+良构基底正负向(卡② T1/V1〔v1〕) {"path": "res://tests/unit/core/breakpoint_resume_test.gd", "expect": 0}, # 断点往返/续玩确定性/幂等/退出清断点 {"path": "res://tests/unit/core/curio_refresh_paid_test.gd", "expect": 0}, # 三选一刷新免费双源+GP 付费档(对齐商店 MC-1:价曲线/优先序/停点清零/钱包单口) {"path": "res://tests/unit/core/level_map_type_vis_test.gd", "expect": 0}, # 信息迷雾——类型可见性(层距 N+Boss/候选豁免+单圈+N 读表+续玩同值) diff --git a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd index 6a6b0428..64526fc0 100644 --- a/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd +++ b/game/tests/unit/battle_settlement/round_flow_skill_pool_test.gd @@ -596,6 +596,16 @@ func _smoke_skill_pool_data() -> void: "3_battle_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1}, "3_battle_7_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, "3_boss_9_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, + # 迭代 35 生成图深度扩面覆盖批八行=同关同 row_kind 顶档行的池克隆(零新池、零新技能引用): + # 战斗带顶 L1→6/L2→9/L3→13;Boss 按终点深度分带 L1→6、L2→8、L3→11/12/13 + "1_battle_6_bugbear": {"attack": 6, "charge": 2, "heavy": 2}, + "1_boss_6_ogre_chief": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2}, + "2_battle_9_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2}, + "2_boss_8_drider": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2}, + "3_battle_13_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2}, + "3_boss_11_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, + "3_boss_12_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, + "3_boss_13_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2}, } var pools_ok := true for row in _db.enemy.rows: @@ -608,7 +618,7 @@ func _smoke_skill_pool_data() -> void: got[String(entry.skill_id)] = int(entry.weight) if got != expect_pools[rk]: pools_ok = false - Kit.check("二十行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕,其余带键仍恰一行)", pools_ok) + Kit.check("二十八行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕;迭代 35 覆盖批八行为顶档池克隆,逐行同检)", pools_ok) var basic_count_ok := true for row in _db.enemy.rows: var n := 0 diff --git a/game/tests/unit/core/board_gen_reward_test.gd b/game/tests/unit/core/board_gen_reward_test.gd index ea75b84f..69a0d3b6 100644 --- a/game/tests/unit/core/board_gen_reward_test.gd +++ b/game/tests/unit/core/board_gen_reward_test.gd @@ -134,9 +134,24 @@ func _smoke_board_generator() -> void: var row = _db.resolve_board_gen(L, 0) Kit.check("BoardGen 行解析 (%d,0)" % L, not row.is_empty()) if L == 2: - # R1 ceil 口径:无精确行向上取整保证偏难——L2 深度 3 为恢复点无行,解析到深度 4 + # 迭代 35 生成图深度扩面覆盖批随动(声明):旧口径「(2,3) 无精确行→ceil 上取到 4」的判据对象 + # 是模板时代键域缺口;覆盖批补齐 0–13 后 (2,3) 有精确行,生产表上不再存在可走 ceil 的空位。 + # 判据不丢——精确命中改判在册,ceil 能力用「临时摘除该行」的夹具面保留,随后原样还原。 + var exact_row: Dictionary = _db.resolve_board_gen(2, 3) + Kit.check("BoardGen 精确命中 (2,3)(覆盖批键域补齐)", int(exact_row.get("node_depth", -1)) == 3) + var gap_idx := -1 + for i in _db.board_gen.rows.size(): + if int(_db.board_gen.rows[i].get("level_index", -1)) == 2 and int(_db.board_gen.rows[i].get("node_depth", -1)) == 3: + gap_idx = i + break + var saved: Dictionary = _db.board_gen.rows[gap_idx] + var rows_before: int = _db.board_gen.rows.size() + _db.board_gen.rows.remove_at(gap_idx) var ceil_row: Dictionary = _db.resolve_board_gen(2, 3) - Kit.check("R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)", int(ceil_row.get("node_depth", -1)) == 4) + Kit.check("R1 ceil 能力不失(夹具摘除 (2,3)→向上取整到 4·偏难口径)", int(ceil_row.get("node_depth", -1)) == 4) + _db.board_gen.rows.insert(gap_idx, saved) + Kit.check("夹具表原样还原(行数与解析回到覆盖批现值)", + _db.board_gen.rows.size() == rows_before and int(_db.resolve_board_gen(2, 3).get("node_depth", -1)) == 3) var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db) Kit.check("L%d 生成产物非空" % L, b1 != null) if b1 == null: diff --git a/game/tests/unit/core/level_map_type_vis_test.gd b/game/tests/unit/core/level_map_type_vis_test.gd index b3ca3d97..1c56bbf8 100644 --- a/game/tests/unit/core/level_map_type_vis_test.gd +++ b/game/tests/unit/core/level_map_type_vis_test.gd @@ -57,7 +57,7 @@ func _chain_nodes() -> Array: ] -# validate 正样本(无跨层跳边的合法图:入口唯一、Boss 编号最大收尾、层宽 ≤5) +# 表行夹具的正样本(无跨层跳边的合法图——表加载期只核行级字段,图合法性判据见 validate_graph·卡② T1 换代) func _valid_graph_nodes() -> Array: return [ {"node_depth": 0, "type": "battle", "next": [1, 2]}, diff --git a/game/tests/unit/core/level_shop_economy_test.gd b/game/tests/unit/core/level_shop_economy_test.gd index df6db8ed..98da5e9a 100644 --- a/game/tests/unit/core/level_shop_economy_test.gd +++ b/game/tests/unit/core/level_shop_economy_test.gd @@ -147,8 +147,8 @@ func _battle_st(policy: String) -> Dictionary: func _smoke_level_graph() -> void: print("--- 关卡节点地图·分支节点图(L2 幽暗地域)---") var autoplay = load("res://sim/level_autoplay.gd") - # ① L1 单链与 L2 图经同一套加载校验(单链 = 出边恰一的退化形态——load_tables 全绿已含,显式复述) - Kit.check("LevelTable 图合法性校验:L1 单链 + L2 图同套算法零错误", _db.level.validate().is_empty()) + # ① 关卡表行级校验零错误(图合法性自 T1 起不再由表加载期承担——关卡表携带的模板数据随卡② T3 清出) + Kit.check("LevelTable 行级校验零错误(图合法性移交 validate_graph)", _db.level.validate().is_empty()) # ② L2 行集结构(网状图建议规模) var l2 := {} for row in _db.level.rows: @@ -164,39 +164,37 @@ func _smoke_level_graph() -> void: Kit.check("L2 普通战斗节点 = 6 + Boss 恒一(编号最大恒终点)", int(tcount.battle) == 6 and int(tcount.boss) == 1) Kit.check("L2 恢复节点 1(rest_heal_pct 行字段)+ 奖励节点 1(直通空态)+ 商店房节点 1(商店房批)", int(tcount.reward) == 1 and int(tcount.rest) == 1 and int(tcount.shop) == 1) Kit.check("L2 真实岔路 = %d 处(≥2)" % branch, branch >= 2) - # ③ 图校验正反断言(违规行集定位到类别——加载期致命的同款算法) - var lt = load("res://data/schemas/level_table.gd").new() - var good := [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [2]}, - {"node_depth": 2, "type": "boss"}] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": good}]) - Kit.check("图校验正例:两岔合流小图零错误", lt.validate().is_empty()) + # ③ 图校验正反断言改走 validate_graph 公有面(迭代 35 卡② T1 随动·声明): + # · 夹具字段随生成图读面换代(id/type/depth/next 取代 node_depth 双编号空间); + # · 「入口唯一违例」判据随模板池退役——多入口是现行合法形态(关卡节点图生成 v1.1 结构铁律), + # 该条改判正例;「孤儿节点」条去掉「双入口同体违例」措辞,只保留到不了终点/进不去的违例面; + # · 前进方向判据由「目标编号严格递增」换为「目标深度严格大于源」(契约 1④)。 + var LT = load("res://data/schemas/level_table.gd") + var good := [{"id": 0, "type": "battle", "depth": 0, "next": [1, 2]}, + {"id": 1, "type": "battle", "depth": 1, "next": [2]}, + {"id": 2, "type": "boss", "depth": 2, "next": []}] + Kit.check("图校验正例:两岔合流小图零错误〔v1〕", LT.validate_graph(good).is_empty()) var dup = JSON.parse_string(JSON.stringify(good)) - dup[1].node_depth = 0 - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dup}]) - Kit.check("图校验拒绝:节点编号重复(关卡内唯一定位地址)", _has_err_category(lt.validate(), "dup_key")) + dup[1].id = 0 + Kit.check("图校验拒绝:句柄重复(图内唯一句柄)〔v1〕", _has_err_category(LT.validate_graph(dup), "dup_key")) var dual = JSON.parse_string(JSON.stringify(good)) - dual.append({"node_depth": 9, "type": "battle", "next": [2]}) - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dual}]) - Kit.check("图校验拒绝:第二无入边节点(入口唯一违例——入口节点数 = 3)", _has_err_category(lt.validate(), "structural")) + dual.append({"id": 9, "type": "battle", "depth": 0, "next": [2]}) + Kit.check("图校验正例:多入口合法(入口唯一判据随模板池退役)〔v1〕", LT.validate_graph(dual).is_empty()) var boss_out = JSON.parse_string(JSON.stringify(good)) boss_out[2].next = [1] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": boss_out}]) - Kit.check("图校验拒绝:Boss 带出边(终点不可外出)", _has_err_category(lt.validate(), "structural")) + Kit.check("图校验拒绝:Boss 带出边(终点不可外出)〔v1〕", _has_err_category(LT.validate_graph(boss_out), "structural")) var cyc = JSON.parse_string(JSON.stringify(good)) cyc[1].next = [0] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": cyc}]) - Kit.check("图校验拒绝:回边 1→0(连线只朝前进方向 ⇒ 无环)", _has_err_category(lt.validate(), "structural")) + Kit.check("图校验拒绝:回边 1→0(目标深度须严格大于源)〔v1〕", _has_err_category(LT.validate_graph(cyc), "structural")) var dang = JSON.parse_string(JSON.stringify(good)) dang[0].next = [1, 7] - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": dang}]) - Kit.check("图校验拒绝:出边指向不存在编号(悬空致命)", _has_err_category(lt.validate(), "dangling_next")) + Kit.check("图校验拒绝:出边指向不存在句柄(悬空致命)〔v1〕", _has_err_category(LT.validate_graph(dang), "dangling_next")) var orphan = JSON.parse_string(JSON.stringify(good)) orphan[0].next = [1] orphan[1].next = [2] - orphan.append({"node_depth": 5, "type": "battle", "next": [2]}) - lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30, "nodes": orphan}]) - var orphan_errs: Array = lt.validate() - Kit.check("图校验拒绝:孤儿节点(从入口不可达 / 双入口同体违例)", _has_err_category(orphan_errs, "structural")) + orphan.append({"id": 5, "type": "battle", "depth": 1, "next": [6]}) + orphan.append({"id": 6, "type": "rest", "depth": 2, "next": []}) + Kit.check("图校验拒绝:非 Boss 终点(终点恒为 Boss)〔v1〕", _has_err_category(LT.validate_graph(orphan), "structural")) # ④ L2 选路推进:min / max 双路径全通(确定性路径轴——零随机流自开) # 战斗随机值面随全局序漂移——max 路径种子按行为面扫描(路线形状/商店/选路口为断言本体) var win_min = autoplay.run_level(_db, 42, 2, 200, 100, false, null, "normal", "min", 0) diff --git a/game/tests/unit/core/level_table_validate_test.gd b/game/tests/unit/core/level_table_validate_test.gd new file mode 100644 index 00000000..437cdfee --- /dev/null +++ b/game/tests/unit/core/level_table_validate_test.gd @@ -0,0 +1,102 @@ +# tests/unit/core/level_table_validate_test.gd — 消费侧加载校验四检单测(迭代 35 卡② T1/V1) +# 覆盖:validate_graph 正例(多入口多终点/含岔口单终点/单链退化显式出边)+负例八类 +#(非 Boss 终点/Boss 带出边/环=回边与自环/双独立链全员可达正证/悬空出边/句柄重复/battle 缺深度/空图) +#+「勿扩勿缩」反证:商店位与入口终点个数不在本检范围(关卡节点地图 v2.1「位置约束由冒烟断言把关」)。 +# 运行:godot --headless --path game --script res://tests/unit/core/level_table_validate_test.gd(expect 0) +# 协议(tests/README):extends SceneTree + quit(code);夹具资源直构不碰生产表。 +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const LevelTableScript := preload("res://data/schemas/level_table.gd") + + +func _init() -> void: + _test_positive() + _test_negative() + _test_scope_not_extended() + quit(Kit.finish("level_table_validate_test")) + + +func _n(id: int, type_: String, depth: int, outs: Array) -> Dictionary: + return {"id": id, "type": type_, "depth": depth, "next": outs} + + +func _errs(nodes: Array) -> Array: + return LevelTableScript.validate_graph(nodes) + + +func _has(errs: Array, cat: String, needle: String = "") -> bool: + for e in errs: + if String(e["category"]) != cat: + continue + if needle == "" or String(e["message"]).contains(needle): + return true + return false + + +# ---- 正例(四检全过)---- + +func _test_positive() -> void: + # 多入口多终点:两入口共汇入一岔口,两终点各在互异深度 + var multi := [ + _n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]), + _n(2, "battle", 1, [3, 5]), + _n(3, "rest", 2, [4]), _n(4, "shop", 3, [6]), _n(6, "boss", 4, []), + _n(5, "reward", 2, [7]), _n(7, "shop", 3, [8]), _n(8, "boss", 5, [])] + Kit.check("四检正例:多入口多终点图零错误〔v1〕", _errs(multi).is_empty()) + # 含岔口单终点:岔口两侧链与主去路同深、汇入同一后继 + var forked := [ + _n(0, "battle", 0, [1]), + _n(1, "battle", 1, [2, 3]), + _n(2, "reward", 2, [4]), _n(3, "rest", 2, [4]), + _n(4, "shop", 3, [5]), _n(5, "boss", 4, [])] + Kit.check("四检正例:含岔口单终点图零错误〔v1〕", _errs(forked).is_empty()) + # 单链退化形态合法(校验不设「必有岔口」) + var chain := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [3]), _n(3, "boss", 3, [])] + Kit.check("四检正例:单链退化图零错误(不设必有岔口)〔v1〕", _errs(chain).is_empty()) + + +# ---- 负例九类(各报对应类别与判据消息)---- + +func _test_negative() -> void: + var non_boss_end := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [])] + Kit.check("负例:终点非 Boss 命中「终点恒为 Boss」〔v1〕", + _has(_errs(non_boss_end), "structural", "终点 1 非 Boss")) + var boss_out := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, [2]), _n(2, "shop", 2, [])] + Kit.check("负例:Boss 带出边被拒〔v1〕", _has(_errs(boss_out), "structural", "Boss 节点 1 带出边")) + var back_edge := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 0, [3]), _n(3, "boss", 4, [])] + Kit.check("负例:回边(目标深度不严格大于源)命中前进方向判据〔v1〕", + _has(_errs(back_edge), "structural", "违反前进方向")) + var cycle := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [1]), _n(3, "boss", 3, [])] + Kit.check("负例:环(2→1 回边)被前进方向拦下〔v1〕", _has(_errs(cycle), "structural", "违反前进方向")) + # 判据②(无孤儿无断头)与判据④(前进方向)在无环图上互斥:DAG 的每个源点都是入口、每条极长路都止于终点, + # 故 ② 是④失效时的纵深防御面,构造不出独立负例——此处以「两独立链双入口双终点」正证 ② 不误伤。 + var two_components := [ + _n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []), + _n(3, "battle", 0, [4]), _n(4, "rest", 1, [5]), _n(5, "boss", 2, [])] + Kit.check("判据②正证:两独立链(双入口双终点)不误伤·全员可达〔v1〕", _errs(two_components).is_empty()) + var self_loop := [_n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []), _n(3, "battle", 1, [3])] + Kit.check("判据②兜底面:自环先被前进方向拦下(④ 与 ② 互斥的实证)〔v1〕", + _has(_errs(self_loop), "structural", "违反前进方向")) + var dangling := [_n(0, "battle", 0, [9]), _n(1, "boss", 1, [])] + Kit.check("负例:悬空出边报 dangling_next〔v1〕", _has(_errs(dangling), "dangling_next")) + var dup := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, []), _n(1, "shop", 2, [])] + Kit.check("负例:句柄重复报 dup_key〔v1〕", _has(_errs(dup), "dup_key")) + var no_depth := [_n(0, "battle", 0, [1]), {"id": 1, "type": "battle", "next": [2]}, _n(2, "boss", 2, [])] + Kit.check("负例:battle 节点缺深度被良构基底拒〔v1〕", _has(_errs(no_depth), "structural", "缺深度")) + Kit.check("负例:空节点集被拒〔v1〕", _has(_errs([]), "structural", "图节点集为空")) + var bad_type := [_n(0, "mine", 0, [1]), _n(1, "boss", 1, [])] + Kit.check("负例:类型出域报 enum〔v1〕", _has(_errs(bad_type), "enum")) + + +# ---- 「勿扩勿缩」反证:本检不含商店位与个数对参数档的核对 ---- + +func _test_scope_not_extended() -> void: + # 合法形状但全程无商店/三个终点同入口图——四检不该因此报错(商店位归冒烟断言、个数归产出侧) + var no_shop := [_n(0, "battle", 0, [1, 2]), _n(1, "battle", 1, [3]), _n(2, "rest", 1, [3]), _n(3, "boss", 2, [])] + Kit.check("范围反证:无商店图不在加载校验内报错(商店位由冒烟断言把关)〔v1〕", _errs(no_shop).is_empty()) + var two_entries := [_n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]), _n(2, "boss", 1, [])] + Kit.check("范围反证:双入口合法(入口唯一判据已随模板池退役,个数归产出侧)〔v1〕", _errs(two_entries).is_empty()) + # 良构字段缺失(无 next 键)该报错——不属被退役判据 + var missing_key := [{"id": 0, "type": "battle", "depth": 0}, _n(1, "boss", 1, [])] + Kit.check("范围反证:节点缺 next 字段仍被良构基底拒〔v1〕", _has(_errs(missing_key), "structural", "缺字段 next")) diff --git a/game/tests/unit/core/level_table_validate_test.gd.uid b/game/tests/unit/core/level_table_validate_test.gd.uid new file mode 100644 index 00000000..24713668 --- /dev/null +++ b/game/tests/unit/core/level_table_validate_test.gd.uid @@ -0,0 +1 @@ +uid://bhymincbe2j4m diff --git a/reports/iter35-卡②-实现报告-2026-10-24.md b/reports/iter35-卡②-实现报告-2026-10-24.md index 25808aa6..d43ebd74 100644 --- a/reports/iter35-卡②-实现报告-2026-10-24.md +++ b/reports/iter35-卡②-实现报告-2026-10-24.md @@ -48,7 +48,7 @@ | T 卡 | 状态 | |---|---| | T0 事实坐实与卡① 接口对齐 | 待做——接口四件已可由卡① 定稿直读(`generate(params, seed)` 不收 level_index、`{nodes,entries,terminals}`、节点 `id/type/depth/next`、参数档字段名),本段只需核 §3 行号漂移 | -| T1 加载校验换代(四检+良构) | 未开工——任务见分支待办 #2 | +| T1 加载校验换代(四检+良构) | 完成(提交随本报告末笔)——`LevelTable.validate_graph(nodes)` 静态单点在位,表加载期图校验与旧六判据退役,新宿主 `level_table_validate_test`〔v1〕15 条+manifest 登记,run_all 全绿 59 套件 | | T2 消费侧切源与行进/定位/确定性换代 | 未开工——任务见分支待办 #3 | | T3 模板清理与 S1–S8 断言换代 | 未开工(S5/V11 覆盖断言已由 §一 提前落位)——任务见分支待办 #4 | | T4 产出物 | 本报告即起始件;代拍板单与反哺点清单随收口补 |