Files
gplan-2/game/core/node_map_generator.gd
T
2026-11-04 05:08:32 +08:00

604 lines
24 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.
## 关卡节点图参数化长图生成器(节点图生成器核心批 卡① T2)。
## 纯函数产图面:generate(参数档, 种子) → 一张合法节点图({nodes, entries, terminals})——
## 图 = (参数档, 种子) 的函数,复现不依赖统一流计数器号位(契约 1),结构取数只经 RngService.pure_stream。
## 产前强制自证(契约 3 产出约束五条+结构铁律、契约 5 摆放约束三条、契约 2 深度赋值与多终点深度互异);
## 不合格产出=参数无解致命中止(契约 4:无兜底图、无软回退、无残图交付)。
## 生成器只吃参数档、不认关卡号(契约 6);节点深度 = 自入口最长路径步数(契约 2)。
class_name NodeMapGenerator
extends RefCounted
const FILLER_TYPES := ["battle", "reward", "rest"]
# 多终点图的支路互斥段必须各有独有类型(契约 5③),而商店与 Boss 是每条支路共有件,
# 独有类型只能来自 battle/reward/rest 三件 ⇒ 归属大岔口各区域的填充节点取互斥签名类型。
# 入口与主链段(大岔口上游,不属任何支路互斥段)恒为战斗节点——承载战斗保底与岔口下一跳互异基准形。
const REGION_SIGNATURES := ["rest", "reward", "battle"]
const SCOPE_STRUCTURE := 0 # 纯流面取数分域:结构流(代拍板 §10 ①——单流多次取数)
var _rng_service
var _params := {}
var _pool: Array[Dictionary] = []
var _by_id := {}
var _next_id := 0
var _detour_heads: Array[Dictionary] = [] # 岔口侧链首节点(下一跳类型互异的修型面)
var _detour_ids := {} # 侧链节点 id 集(配比填余不动侧链——战斗保底只由链上槽承载)
var _fatal_flag := false # 致命封印(沿数值表单点入口的致命置位体例——参数无解/产出不合格置位)
func _init(rng_service = null) -> void:
_rng_service = rng_service
## 产图主入口(§7 接口块定稿签名——不收 level_index,契约 6)
func generate(params: Dictionary, seed_val: int) -> Dictionary:
_fatal_flag = false
var errs := check_params(params)
if not errs.is_empty():
for e in errs:
push_error("[NodeMapGenerator] 参数无解: " + String(e["message"]))
_fatal("参数无解致命中止(契约 4——无兜底图、禁软回退)")
return {} # 致命中止后不可达;本行即「无残图交付」的显式证明
if _rng_service == null:
push_error("[NodeMapGenerator] 未注入 RngService(RNG 单点红线——生成器取数唯一源缺失)")
_fatal("未注入 RngService")
return {}
var built := _setup_and_build(params, seed_val)
var out_errs := validate_output(built, params)
if not out_errs.is_empty():
for e in out_errs:
push_error("[NodeMapGenerator] 产出不合格: " + String(e["message"]))
_fatal("产出自证不过=产出不合格(执行边界①——禁止进玩法)")
return {}
return built
func _setup_and_build(params: Dictionary, seed_val: int) -> Dictionary:
_params = params
var rg: RandomNumberGenerator = _rng_service.pure_stream(seed_val, SCOPE_STRUCTURE)
_pool = []
_by_id = {}
_next_id = 0
_detour_heads = []
_detour_ids = {}
return _build(rg)
## 参数档可行性判定(执行边界③:参数无解=配置错误)——域校验外加深度预算与战斗保底可放性。
static func check_params(params: Dictionary) -> Array:
# 域校验并单点(R7——参数档域合法性唯一在 LevelGenParams.validate 加载期;
# 生成器只留可行性判定 _cp_structural——schema 无此判定的生成器职责)
return _cp_structural(params)
## 结构可行性段:主链段容量与战斗保底可放性(多出口按区域签名累加可放槽)
static func _cp_structural(params: Dictionary) -> Array[Dictionary]:
var errs: Array[Dictionary] = []
var L := int(params.get("layers", 0))
var e := int(params.get("exit_count", 0))
var en := int(params.get("entry_count", 0))
var bf := int(params.get("battle_floor", -1))
var t := 0 if e == 1 else _trunk_len(L, e)
if e > 1 and t < 1:
errs.append({"category": "structural", "message": "层深 %d 与出口数 %d 无解(主链段放不下归属区域)" % [L, e]})
var battle_slots := en + t
if e == 1:
battle_slots += maxi(0, L - 2)
else:
for r in range(e):
if String(REGION_SIGNATURES[r]) == "battle":
battle_slots += maxi(0, (L - r) - t - 2)
if battle_slots < bf:
errs.append({"category": "structural", "message": "可放战斗节点 %d 低于战斗保底 %d(参数无解)" % [battle_slots, bf]})
return errs
## 产出侧自证(契约 3/契约 5/契约 2)——generate 产前强制;错误类别沿
## structural/dangling_next/dup_key/enum/domain 词表。params 为空时跳过与参数档对照的计数判据。
func validate_output(graph: Dictionary, params: Dictionary = {}) -> Array:
var errs: Array[Dictionary] = []
var nodes = graph.get("nodes", [])
if nodes.is_empty():
errs.append({"category": "structural", "message": "产出为空图(执行边界①——不合格产出禁止进玩法)"})
return errs
var by_id := {}
var indeg := {}
if not _vo_node_fields(nodes, errs, by_id, indeg):
return errs
if not _vo_edges(nodes, by_id, indeg, errs):
return errs
var flow := _vo_flow_facts(nodes, indeg, errs)
if not flow["ok"]:
return errs
if not _vo_depth_reach(nodes, by_id, flow["entries"], flow["terminals"], errs):
return errs
_vo_shop_counts(nodes, flow["terminals"], errs)
_vo_terminal_shops(nodes, flow["terminals"], errs)
if not _all_paths_one_shop(by_id, flow["entries"], flow["terminals"]):
errs.append({"category": "structural", "message": "存在入口→终点路径穿店数 ≠ 1(必经恰一店破)"})
_check_fork_rules(nodes, by_id, errs)
_vo_terminal_depths(flow["terminals"], by_id, errs)
_vo_width_rules(nodes, errs)
_vo_params_check(params, nodes, by_id, [flow["entries"], flow["terminals"]], errs)
return errs
## 阶段一·节点字段域:字段齐备/句柄唯一/类型词表/深度非负(by_id/indeg 引用填充)
func _vo_node_fields(nodes: Array, errs: Array, by_id: Dictionary, indeg: Dictionary) -> bool:
for n in nodes:
for k in ["id", "type", "depth", "next"]:
if not (n as Dictionary).has(k):
errs.append({"category": "structural", "message": "节点字段缺失: %s" % k})
return false
var id: int = int(n["id"])
if by_id.has(id):
errs.append({"category": "dup_key", "message": "节点句柄重复: %d" % id})
by_id[id] = n
if not LevelTable.NODE_TYPES.has(String(n["type"])):
errs.append({"category": "enum", "message": "节点类型出域: %s" % n["type"]})
if int(n["depth"]) < 0:
errs.append({"category": "domain", "message": "节点深度为负: %d" % n["depth"]})
indeg[id] = 0
return errs.is_empty()
## 阶段二·边域:出边悬空与环/回边判据(indeg 引用累计)
func _vo_edges(nodes: Array, by_id: Dictionary, indeg: Dictionary, errs: Array) -> bool:
for n in nodes:
for oid in (n["next"] as Array):
if not by_id.has(int(oid)):
errs.append({"category": "dangling_next", "message": "出边悬空: %d → %s" % [n["id"], oid]})
continue
if int(by_id[int(oid)]["depth"]) <= int(n["depth"]):
errs.append({"category": "structural", "message": "连线未朝前进方向(目标深度 %d 不严格大于源 %d)——环/回边" % [
int(by_id[int(oid)]["depth"]), int(n["depth"])]})
indeg[int(oid)] = int(indeg[int(oid)]) + 1
return errs.is_empty()
## 阶段三·流事实域:入口集(入度 0)/终点集(无出边)与 Boss 终点判据、去路数形态
func _vo_flow_facts(nodes: Array, indeg: Dictionary, errs: Array) -> Dictionary:
var entries: Array[int] = []
var terminals: Array[int] = []
for n in nodes:
var id0: int = int(n["id"])
var outs = n["next"]
if int(indeg[id0]) == 0:
entries.append(id0)
if outs.is_empty():
terminals.append(id0)
if String(n["type"]) != "boss":
errs.append({"category": "structural", "message": "终点(无出边)非 Boss: 节点 %d 类型 %s" % [id0, n["type"]]})
elif String(n["type"]) == "boss":
errs.append({"category": "structural", "message": "Boss 节点带出边: %d" % id0})
elif outs.size() != 1 and not (outs.size() >= 2 and outs.size() <= 3):
errs.append({"category": "domain", "message": "节点 %d 去路数 %d 非〔恰一条或岔口 2–3〕" % [id0, outs.size()]})
return {"entries": entries, "terminals": terminals, "ok": errs.is_empty()}
## 阶段四·深度与可达域:深度=自入口最长路径步数(契约 2)+全节点可达某终点
func _vo_depth_reach(nodes: Array, by_id: Dictionary, entries: Array, terminals: Array, errs: Array) -> bool:
var longest := _longest_paths(nodes, by_id, entries)
for n in nodes:
var id1: int = int(n["id"])
if int(n["depth"]) != int(longest.get(id1, -1)):
errs.append({"category": "domain", "message": "节点 %d 深度 %d ≠ 自入口最长路径步数 %d" % [
id1, n["depth"], int(longest.get(id1, -1))]})
var reaches := {}
for tid in terminals:
_mark_ancestors(int(tid), by_id, reaches)
for n in nodes:
if not reaches.has(int(n["id"])):
errs.append({"category": "structural", "message": "节点 %d 不可达任何终点(断头/孤儿)" % n["id"]})
return errs.is_empty()
## 摆放约束①前半·商店总数:商店总数=终点数(商店数=出口数已锁)
func _vo_shop_counts(nodes: Array, terminals: Array, errs: Array) -> void:
var shop_count := 0
for n in nodes:
if String(n["type"]) == "shop":
shop_count += 1
if shop_count != terminals.size():
errs.append({"category": "structural", "message": "商店总数 %d ≠ 终点数 %d(商店数=出口数已锁)" % [shop_count, terminals.size()]})
## 摆放约束①后半·终点前驱:每终点前一位恰一店且为唯一前驱、终点前一层去路集合恰含一个终点
func _vo_terminal_shops(nodes: Array, terminals: Array, errs: Array) -> void:
for tid in terminals:
var preds := []
for n in nodes:
if (n["next"] as Array).has(tid):
preds.append(n)
if preds.size() != 1:
errs.append({"category": "structural", "message": "终点 %d 直接前驱数 %d ≠ 1" % [tid, preds.size()]})
continue
var pred = preds[0]
if String(pred["type"]) != "shop":
errs.append({"category": "structural", "message": "终点 %d 前一位非商店: %s" % [tid, pred["type"]]})
continue
var t_hits := 0
for oid in (pred["next"] as Array):
if terminals.has(int(oid)):
t_hits += 1
if t_hits != 1:
errs.append({"category": "structural", "message": "终点 %d 前一层去路集合含终点 %d 个(归属锁定破)" % [tid, t_hits]})
## 计数规则①·多终点深度互异(推论条款 A——定位信息只有两样)
func _vo_terminal_depths(terminals: Array, by_id: Dictionary, errs: Array) -> void:
var tdepths := {}
for tid in terminals:
var dd: int = int(by_id[tid]["depth"])
if tdepths.has(dd):
errs.append({"category": "structural", "message": "多终点同深度 %d(推论条款 A 破——定位信息只有两样)" % dd})
tdepths[dd] = true
## 计数规则②·宽度上限:同深度节点数 ≤ 分叉生长上限 4(禁平行层摆放)
func _vo_width_rules(nodes: Array, errs: Array) -> void:
var per_depth := {}
for n in nodes:
var d2: int = int(n["depth"])
per_depth[d2] = int(per_depth.get(d2, 0)) + 1
for d3 in per_depth:
if int(per_depth[d3]) > int(_params["width_max"]):
errs.append({"category": "structural", "message": "深度 %d 节点数 %d 超分叉生长上限(width_max 表值——平行层摆放)" % [d3, per_depth[d3]]})
## 计数规则③·参数档对照:战斗保底/入口数/终点数/层高(params 为空时跳过)
func _vo_params_check(params: Dictionary, nodes: Array, by_id: Dictionary, flow_ends: Array, errs: Array) -> void:
var entries: Array[int] = flow_ends[0]
var terminals: Array[int] = flow_ends[1]
var battles := 0
for n in nodes:
if String(n["type"]) == "battle":
battles += 1
if params.is_empty():
return
if battles < int(params.get("battle_floor", 0)):
errs.append({"category": "domain", "message": "战斗节点数 %d 低于保底 %d" % [battles, params.get("battle_floor", 0)]})
if entries.size() != int(params.get("entry_count", entries.size())):
errs.append({"category": "structural", "message": "入口数 %d ≠ 参数档 entry_count %d" % [entries.size(), params.get("entry_count", 0)]})
if terminals.size() != int(params.get("exit_count", terminals.size())):
errs.append({"category": "structural", "message": "终点数 %d ≠ 参数档 exit_count %d" % [terminals.size(), params.get("exit_count", 0)]})
var maxd := 0
for tid in terminals:
maxd = maxi(maxd, int(by_id[tid]["depth"]))
if maxd != int(params.get("layers", maxd)):
errs.append({"category": "domain", "message": "层高(最长路径步数)%d ≠ 参数档 layers %d" % [maxd, params.get("layers", 0)]})
# ---- 构型(长图算法 HOW 归实现——D1:28;合法性由 validate_output 强制)----
func _build(rg: RandomNumberGenerator) -> Dictionary:
var L := int(_params["layers"])
var e := int(_params["exit_count"])
var en := int(_params["entry_count"])
var t := 0 if e == 1 else _trunk_len(L, e)
var entries: Array[int] = []
for i in range(en):
entries.append(_mk("battle", 0))
var prevs: Array[int] = _build_trunk(t, entries.duplicate())
var terminals: Array[int] = []
for r in range(e):
_build_region(r, e, L, prevs, terminals)
# e>1 时 prevs 收束为主链段末节点一处 ⇒ 该节点即归属大岔口(各区域互不连达);
# e==1 时 prevs 为入口数组,直接接单区域头(无大岔口——L1 出口恒 1)。
_add_forks(rg, L, t, e)
_assign_types(rg, e)
var nodes: Array[Dictionary] = []
nodes.assign(_pool)
return {"nodes": nodes, "entries": entries, "terminals": terminals}
## 主链段(深度 1..t 逐层单链,主链段零 RNG):返回链末节点集(e>1 时收束为一处即归属大岔口)
func _build_trunk(t: int, prevs: Array[int]) -> Array[int]:
for d in range(1, t + 1):
var nid := _mk("battle", d)
for p in prevs:
(_by_id[p]["next"] as Array).append(nid)
prevs = [nid]
return prevs
## 归属区域段(每出口一条支路:Boss 终点+店+签名填充链;区域段零 RNG):
## Boss 深度 = L−r、店在其前一位、签名链自店向主链段方向回铺,支路头接入 prevs(归属大岔口)
func _build_region(r: int, e: int, L: int, prevs: Array, terminals: Array) -> void:
var t := 0 if e == 1 else _trunk_len(L, e)
var bdepth := L - r
var bid := _mk("boss", bdepth)
var sid := _mk("shop", bdepth - 1)
(_by_id[sid]["next"] as Array).append(bid)
var cur := sid
var sig := String(REGION_SIGNATURES[r]) if e > 1 else "battle"
for d in range(bdepth - 2, t, -1):
var fid := _mk(sig, d)
(_by_id[fid]["next"] as Array).append(cur)
cur = fid
terminals.append(bid)
for p in prevs:
(_by_id[p]["next"] as Array).append(cur)
## 岔口布置:主链段(深度 ≤ t-2)或单出口图链上(深度 ≤ L-3)的节点扩为 2–3 去路岔口;
## 侧支节点与主去路同深(保持「深度=自入口最长路径步数」不变量),两支各自汇入主去路的后继,
## 于是支路互斥段={主去路节点} 与 {侧支节点}——两支类型互异即同时满足「下一跳互异」与「各有独有类型」。
func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void:
var wanted := maxi(int(round(float(_params["branch_density"]) * float(L))), 1) # 死上限 24 删(不可达值——下限 1 保留防零岔口)
var anchors := _collect_fork_anchors(L, t, e)
anchors = _shuffle(rg, anchors)
var selected := {} # 已扩岔节点 id
var succ_selected := {} # 已扩岔节点的主去路 id(禁再作锚点)
for a in anchors:
if wanted <= 0:
break
var nid := int(a["id"])
if (a["next"] as Array).size() != 1:
continue # 同一节点不重复扩岔(去路数上限 3)
var v = _by_id[int((a["next"] as Array)[0])]
var vid := int(v["id"])
# 相邻两层不得同时扩岔:若主去路 v 也是岔口,v 的侧支会落进本岔口主支路的互斥段,
# 使侧支自己的类型不再独有(契约 5③)——两个方向都要拦。
if selected.has(vid) or succ_selected.has(nid):
continue
if (v["next"] as Array).is_empty():
continue # 主去路无后继(无可汇入点)——该锚点弃用
var extra := 2 if (int(_params["fork_ways_max"]) >= 3 and rg.randf() < float(_params["fork_third_way_pct"]) / 100.0) else 1
var made := 0
for k in range(extra):
if _depth_count(int(v["depth"])) >= int(_params["width_max"]):
break
var wid := _mk("battle", int(v["depth"]))
(a["next"] as Array).append(wid)
(_by_id[wid]["next"] as Array).append(int((v["next"] as Array)[0]))
_detour_heads.append(_by_id[wid])
_detour_ids[wid] = true
made += 1
if made > 0:
selected[nid] = true
succ_selected[vid] = true
wanted -= 1
## 锚收集段(零 RNG):主链段(深度 ≤ t-2)或单出口图链上(深度 ≤ L-3)的去路恰一节点
func _collect_fork_anchors(L: int, t: int, e: int) -> Array:
var anchors := []
for n in _pool:
if int(n["depth"]) > (t - 2 if e > 1 else L - 3):
continue
if (n["next"] as Array).size() != 1:
continue
anchors.append(n)
return anchors
# 确定性洗牌(Fisher-Yates,取数只经注入的纯流 RNG——Array.shuffle() 走引擎全局随机、
# 破「同(种子,参数档)⇒ 同图」的纯函数复现前提,禁用于生成器)
func _shuffle(rg: RandomNumberGenerator, arr: Array) -> Array:
var out := arr.duplicate()
for i in range(out.size() - 1, 0, -1):
var j := rg.randi_range(0, i)
var tmp = out[i]
out[i] = out[j]
out[j] = tmp
return out
func _depth_count(d: int) -> int:
var c := 0
for n in _pool:
if int(n["depth"]) == d:
c += 1
return c
## 类型装配:多出口图区域签名/主链段类型在构型期已定(支路互斥段的独有类型来源),此处只补
## 单出口图的配比填余(摆放优先、配比填余、战斗保底兜底——§8.1 次序),再统一修岔口下一跳互异。
func _assign_types(rg: RandomNumberGenerator, e: int) -> void:
if e == 1:
_fill_ratios(rg)
_fix_detour_heads()
## 配比填余段(单出口图;shuffle+randf 取号序整体搬迁——战斗保底优先、侧链不占填余面):
## 摆放优先、配比填余、战斗保底兜底——§8.1 次序
func _fill_ratios(rg: RandomNumberGenerator) -> void:
var free: Array[Dictionary] = []
var chain_battles := 0
for n in _pool:
if String(n["type"]) == "shop" or String(n["type"]) == "boss":
continue
if _detour_ids.has(int(n["id"])):
continue # 侧链节点类型由岔口互异修型决定,不占配比填余面
if int(n["depth"]) == 1:
continue # 区域首节点留战斗——配比填余不破坏岔口下一跳互异的基准形
if String(n["type"]) == "battle":
chain_battles += 1
free.append(n)
free = _shuffle(rg, free)
var want := int(round(float(_pool.size()) * (float(_params["reward_ratio"]) + float(_params["rest_ratio"]))))
var floor_n := int(_params["battle_floor"])
var rest_first := rg.randf() < float(_params["fill_rest_first_pct"]) / 100.0
for i in range(want):
if i >= free.size():
break
# 战斗保底优先:链上战斗数降到保底下限即停(侧链修型可能再让出战斗位,故保底只看链上)
if chain_battles - 1 < floor_n:
break
var node = free[i]
node["type"] = "rest" if ((i % 2 == 0) == rest_first) else "reward"
chain_battles -= 1
## 岔口修型段(零 RNG):侧链首节点类型取岔口各去路下一节点类型的补集首件——下一跳互异承面
func _fix_detour_heads() -> void:
for head in _detour_heads:
var h = head
var fork = null
for n in _pool:
if (n["next"] as Array).has(int(h["id"])):
fork = n
break
if fork == null:
continue
var used := {}
for oid in (fork["next"] as Array):
if int(oid) != int(h["id"]):
used[String(_by_id[int(oid)]["type"])] = true
for cand in FILLER_TYPES:
if not used.has(cand):
h["type"] = cand
break
func _mk(type_: String, depth_: int) -> int:
var n := {"id": _next_id, "type": type_, "depth": depth_, "next": []}
_next_id += 1
_pool.append(n)
_by_id[int(n["id"])] = n
return int(n["id"])
static func _trunk_len(L: int, e: int) -> int:
return mini(int(L / 2), L - e - 1)
# ---- 自证辅助 ----
func _longest_paths(nodes: Array, by_id: Dictionary, entries: Array) -> Dictionary:
var out := {}
var din := {}
var q := []
for n in nodes:
din[int(n["id"])] = 0
for n in nodes:
for oid in (n["next"] as Array):
din[int(oid)] = int(din[int(oid)]) + 1
for eid in entries:
out[int(eid)] = 0
q.append(int(eid))
while not q.is_empty():
var id: int = int(q.pop_front())
for oid in (by_id[id]["next"] as Array):
var nid: int = int(oid)
out[nid] = maxi(int(out.get(nid, 0)), int(out[id]) + 1)
din[nid] = int(din[nid]) - 1
if din[nid] == 0:
q.append(nid)
return out
func _mark_ancestors(t: int, by_id: Dictionary, reached: Dictionary) -> void:
var q := [t]
reached[t] = true
while not q.is_empty():
var id: int = int(q.pop_front())
for n in by_id.values():
if (n["next"] as Array).has(id) and not reached.has(int(n["id"])):
reached[int(n["id"])] = true
q.append(int(n["id"]))
func _all_paths_one_shop(by_id: Dictionary, entries: Array, terminals: Array) -> bool:
var stack := []
for eid in entries:
stack.append([int(eid), 1 if String(by_id[int(eid)]["type"]) == "shop" else 0])
while not stack.is_empty():
var frame = stack.pop_back()
var id: int = int(frame[0])
var shops := int(frame[1])
var outs = by_id[id]["next"]
if outs.is_empty():
if shops != 1 or not terminals.has(id):
return false
continue
for oid in outs:
stack.append([int(oid), shops + (1 if String(by_id[int(oid)]["type"]) == "shop" else 0)])
return true
func _check_fork_rules(nodes: Array, by_id: Dictionary, errs: Array) -> void:
for n in nodes:
var outs = n["next"]
if outs.size() < 2:
continue
var head_types := {}
for oid in outs:
var ty := String(by_id[int(oid)]["type"])
if head_types.has(ty):
errs.append({"category": "structural", "message": "岔口 %d 各去路下一节点类型重复: %s" % [n["id"], ty]})
head_types[ty] = true
var comps := _fork_signatures(outs, by_id)
for i in range(comps.size()):
for j in range(i + 1, comps.size()):
if comps[i] == comps[j]:
errs.append({"category": "structural", "message": "岔口 %d 支路 %d/%d 类型构成相同" % [n["id"], i, j]})
for i in range(comps.size()):
if not _fork_has_unique(comps, i):
errs.append({"category": "structural", "message": "岔口 %d 支路 %d 无独有类型" % [n["id"], i]})
## 支路签名构造:各去路的独占后代类型集(他人不可达的后代类型,排序数组——支路互斥段签名)
func _fork_signatures(outs: Array, by_id: Dictionary) -> Array:
var reach_sets := []
for oid in outs:
reach_sets.append(_descendants(int(oid), by_id))
var comps := []
for i in range(reach_sets.size()):
var excl := {}
for rid in reach_sets[i]:
var shared := false
for j in range(reach_sets.size()):
if j != i and reach_sets[j].has(rid):
shared = true
break
if not shared:
excl[int(rid)] = String(by_id[int(rid)]["type"])
var arr := []
for v in excl.values():
arr.append(v)
arr.sort()
comps.append(arr)
return comps
## 独有类型判定:支路 i 的签名中存在他支路不含的类型
func _fork_has_unique(comps: Array, i: int) -> bool:
for ty in comps[i]:
var in_other := false
for j in range(comps.size()):
if j != i and (comps[j] as Array).has(ty):
in_other = true
break
if not in_other:
return true
return false
func _descendants(start: int, by_id: Dictionary) -> Dictionary:
var out := {start: true}
var q := [start]
while not q.is_empty():
var id: int = int(q.pop_front())
for oid in (by_id[id]["next"] as Array):
if not out.has(int(oid)):
out[int(oid)] = true
q.append(int(oid))
return out
func _fatal(msg: String) -> void:
# 致命面沿数值表单点入口的既有体例:Core 层置致命封印+push_error(调用方已落),退出决策归组合根
# (main.gd `_fatal_screen`/卡② 起关拒绝路径)。generate 已致命中止时不产出任何图,
# 空返回不是兜底图——消费侧必须按 is_fatal() 拒收(契约 4 零软回退)。
_fatal_flag = true
assert(false, msg)
## 致命封印读面(沿 ConfigDB.is_fatal 体例)——组合根/消费侧据此停机或拒收起关
func is_fatal() -> bool:
return _fatal_flag