diff --git a/game/core/config_db.gd b/game/core/config_db.gd index 3ec2c319..4b316db8 100644 --- a/game/core/config_db.gd +++ b/game/core/config_db.gd @@ -19,6 +19,7 @@ const TABLE_MANIFEST := { "settlement": "settlement.tres", "level": "level.tres", "difficulty": "difficulty.tres", + "level_gen_params": "level_gen_params.tres", # LevelGenParams 参数档三行(节点图生成器核心批 卡①——五旋钮 provisional) "shop": "shop.tres", # ShopTable(表与 schema 已入库归数据面——本处只接线加载与消费) "meta": "meta.tres", # MetaTable 四行族(schema 与表同源已入库) "meta_tree": "meta_tree.tres", # MetaTreeTable 12 节点(定义表 Rule 4 豁免——天赋树.md v0.8 结构事实源) @@ -39,6 +40,7 @@ var reward: RewardTable var settlement: SettlementTable var level: LevelTable var difficulty: DifficultyTable +var level_gen_params: LevelGenParams # 参数档类型化持用(生成器只吃参数档、不认关卡号——卡① 契约 6) # ShopTable 类型化持用(schema class_name 已已入库; # 消费面一律走下方 shop_* 访问器,禁直读行集) var shop: ShopTable = null @@ -141,6 +143,7 @@ func load_tables(root: String, collect_only: bool) -> bool: settlement = loaded.get("settlement") level = loaded.get("level") difficulty = loaded.get("difficulty") + level_gen_params = loaded.get("level_gen_params") shop = loaded.get("shop") meta = loaded.get("meta") treasure_items = loaded.get("treasure_items") @@ -246,6 +249,11 @@ func level_row(level_index: int) -> Dictionary: return level.row_by_level_index(level_index) if level != null else {} +# 参数档单点读取(节点图生成器核心批 卡①——键 = level_index,缺行返回空字典;调用方据此产图,生成器本体不读关卡号) +func level_gen_params_row(level_index: int) -> Dictionary: + return level_gen_params.row_by_level_index(level_index) if level_gen_params != null else {} + + # 序列总关数(AdventureRunner levels_total / MetaProgression 通关累加域的取数口径) func level_count() -> int: return level.rows.size() if level != null else 0 diff --git a/game/core/node_map_generator.gd b/game/core/node_map_generator.gd new file mode 100644 index 00000000..c3f2cacb --- /dev/null +++ b/game/core/node_map_generator.gd @@ -0,0 +1,537 @@ +## 关卡节点图参数化长图生成器(节点图生成器核心批 卡① T2)。 +## 纯函数产图面:generate(参数档, 种子) → 一张合法节点图({nodes, entries, terminals})—— +## 图 = (参数档, 种子) 的函数,复现不依赖统一流计数器号位(契约 1),结构取数只经 RngService.pure_stream。 +## 产前强制自证(契约 3 产出约束五条+结构铁律、契约 5 摆放约束三条、契约 2 深度赋值与多终点深度互异); +## 不合格产出=参数无解致命中止(契约 4:无兜底图、无软回退、无残图交付)。 +## 生成器只吃参数档、不认关卡号(契约 6);节点深度 = 自入口最长路径步数(契约 2)。 +class_name NodeMapGenerator +extends RefCounted + +const NODE_TYPES := ["battle", "boss", "reward", "rest", "shop"] +const FILLER_TYPES := ["battle", "reward", "rest"] +const MAX_WIDTH := 4 # 产出约束·宽度=分叉生长(同深度节点数由岔口长出 1→2→3→4,禁摆平行层) +# 多终点图的支路互斥段必须各有独有类型(契约 5③),而商店与 Boss 是每条支路共有件, +# 独有类型只能来自 battle/reward/rest 三件 ⇒ 归属大岔口各区域的填充节点取互斥签名类型。 +# 入口与主链段(大岔口上游,不属任何支路互斥段)恒为战斗节点——承载战斗保底与岔口下一跳互异基准形。 +const REGION_SIGNATURES := ["rest", "reward", "battle"] +const SCOPE_STRUCTURE := 0 # 纯流面取数分域:结构流(代拍板 §10 ①——单流多次取数) + +var _rng_service +var _params: Dictionary = {} +var _pool: Array = [] +var _by_id: Dictionary = {} +var _next_id := 0 +var _detour_heads: Array = [] # 岔口侧链首节点(下一跳类型互异的修型面) +var _detour_ids: Dictionary = {} # 侧链节点 id 集(配比填余不动侧链——战斗保底只由链上槽承载) + + +func _init(rng_service = null) -> void: + _rng_service = rng_service + + +## 产图主入口(§7 接口块定稿签名——不收 level_index,契约 6) +func generate(params: Dictionary, seed_val: int) -> Dictionary: + 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 + + +## 调试面(卡① §8 测试方式两分法——能力验证可经调试接口直改参数档/种子驱动生成图): +## 只构型、不自证不致命,供断言逐条读产出规格违例;生产路径仍走 generate(不合格即致命中止)。 +func generate_for_debug(params: Dictionary, seed_val: int) -> Dictionary: + if _rng_service == null: + return {} + return _setup_and_build(params, seed_val) + + +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: + 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 bd := float(params.get("branch_density", -1.0)) + if L < 6: + errs.append({"category": "domain", "message": "层深 %d 低于下限 6(层高已锁)" % L}) + if e < 1 or e > 3: + errs.append({"category": "domain", "message": "出口个数 %d 越域 [1,3]" % e}) + if en < 1: + errs.append({"category": "domain", "message": "入口个数 %d 越域(≥1)" % en}) + if bd < 0.0 or bd > 1.0: + errs.append({"category": "domain", "message": "分支密度 %f 越域 [0,1]" % bd}) + if bf < 0: + errs.append({"category": "domain", "message": "战斗保底 %d 越域(≥0)" % bf}) + if int(params.get("fork_ways_min", 2)) < 2 or int(params.get("fork_ways_max", 3)) > 3: + errs.append({"category": "domain", "message": "岔口去路数域越界(锁 2/3)"}) + var sum := 0.0 + for rk in ["battle_ratio", "reward_ratio", "rest_ratio", "shop_ratio"]: + sum += float(params.get(rk, -1.0)) + if not is_equal_approx(sum, 1.0): + errs.append({"category": "domain", "message": "配比四类合计 %f ≠ 1" % sum}) + if not errs.is_empty(): + return errs + 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: Array = graph.get("nodes", []) + if nodes.is_empty(): + errs.append({"category": "structural", "message": "产出为空图(执行边界①——不合格产出禁止进玩法)"}) + return errs + var by_id := {} + var indeg := {} + 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 errs + 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 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 + if not errs.is_empty(): + return errs + 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 + if not errs.is_empty(): + return errs + + var entries := [] + var terminals := [] + for n in nodes: + var id0: int = int(n["id"]) + var outs: Array = 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()]}) + if not errs.is_empty(): + return errs + + # 深度=自入口最长路径步数(契约 2 赋值语义——出边严格前进的无环判据成立的前提) + var longest := _longest_paths(nodes, by_id, entries, indeg) + 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"]}) + if not errs.is_empty(): + return errs + + # 摆放约束①:每终点前一位恰一店、商店总数=终点数、终点前一层去路集合恰含一个终点 + 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()]}) + 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: Dictionary = 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]}) + + if not _all_paths_one_shop(by_id, entries, terminals): + errs.append({"category": "structural", "message": "存在入口→终点路径穿店数 ≠ 1(必经恰一店破)"}) + _check_fork_rules(nodes, by_id, errs) + + 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 + 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]) > MAX_WIDTH: + errs.append({"category": "structural", "message": "深度 %d 节点数 %d 超分叉生长上限 4(平行层摆放)" % [d3, per_depth[d3]]}) + + var battles := 0 + for n in nodes: + if String(n["type"]) == "battle": + battles += 1 + if not params.is_empty(): + 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)]}) + return errs + + +# ---- 构型(长图算法 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] = entries.duplicate() + 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] + + var terminals: Array[int] = [] + for r in range(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) + # e>1 时 prevs 收束为主链段末节点一处 ⇒ 该节点即归属大岔口(各区域互不连达); + # e==1 时 prevs 为入口数组,直接接单区域头(无大岔口——L1 出口恒 1)。 + + _add_forks(rg, L, t, e) + _assign_types(rg, e) + + var nodes: Array[Dictionary] = [] + for n in _pool: + nodes.append(n) + return {"nodes": nodes, "entries": entries, "terminals": terminals} + + +## 岔口布置:主链段(深度 ≤ t-2)或单出口图链上(深度 ≤ L-3)的节点扩为 2–3 去路岔口; +## 侧支节点与主去路同深(保持「深度=自入口最长路径步数」不变量),两支各自汇入主去路的后继, +## 于是支路互斥段={主去路节点} 与 {侧支节点}——两支类型互异即同时满足「下一跳互异」与「各有独有类型」。 +func _add_forks(rg: RandomNumberGenerator, L: int, t: int, e: int) -> void: + var wanted := clampi(int(round(float(_params["branch_density"]) * float(L))), 1, 24) + 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) + anchors = _shuffle(rg, anchors) + var selected := {} # 已扩岔节点 id + var succ_selected := {} # 已扩岔节点的主去路 id(禁再作锚点) + for a in anchors: + if wanted <= 0: + break + var node: Dictionary = a + var nid := int(node["id"]) + if (node["next"] as Array).size() != 1: + continue # 同一节点不重复扩岔(去路数上限 3) + var v: Dictionary = _by_id[int((node["next"] as Array)[0])] + var vid := int(v["id"]) + # 相邻两层不得同时扩岔:若主去路 v 也是岔口,v 的侧支会落进本岔口主支路的互斥段, + # 使侧支自己的类型不再独有(契约 5③)——两个方向都要拦。 + if selected.has(vid) or succ_selected.has(nid): + continue + var v_outs: Array = v["next"] + if v_outs.is_empty(): + continue # 主去路无后继(无可汇入点)——该锚点弃用 + var extra := 2 if (int(_params["fork_ways_max"]) >= 3 and rg.randf() < 0.35) else 1 + var made := 0 + for k in range(extra): + if _depth_count(int(v["depth"])) >= MAX_WIDTH: + break + var wid := _mk("battle", int(v["depth"])) + (node["next"] as Array).append(wid) + (_by_id[wid]["next"] as Array).append(int(v_outs[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 + + +# 确定性洗牌(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: + var free: Array = [] + 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() < 0.5 + for i in range(want): + if i >= free.size(): + break + # 战斗保底优先:链上战斗数降到保底下限即停(侧链修型可能再让出战斗位,故保底只看链上) + if chain_battles - 1 < floor_n: + break + var node: Dictionary = free[i] + node["type"] = "rest" if ((i % 2 == 0) == rest_first) else "reward" + chain_battles -= 1 + for head in _detour_heads: + var h: Dictionary = 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, indeg: Dictionary) -> Dictionary: + var out := {} + var din := {} + var q := [] + for id in indeg: + din[int(id)] = int(indeg[int(id)]) + 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: Array = stack.pop_back() + var id: int = int(frame[0]) + var shops := int(frame[1]) + var outs: Array = 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: Array = 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 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) + 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()): + var uniq := false + 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: + uniq = true + break + if not uniq: + errs.append({"category": "structural", "message": "岔口 %d 支路 %d 无独有类型" % [n["id"], i]}) + + +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: + assert(false, msg) + OS.crash("NodeMapGenerator: " + msg) diff --git a/game/core/node_map_generator.gd.uid b/game/core/node_map_generator.gd.uid new file mode 100644 index 00000000..31cfef4a --- /dev/null +++ b/game/core/node_map_generator.gd.uid @@ -0,0 +1 @@ +uid://daomfkyfn2i8e diff --git a/game/core/rng_service.gd b/game/core/rng_service.gd index bca06417..65ffccee 100644 --- a/game/core/rng_service.gd +++ b/game/core/rng_service.gd @@ -81,6 +81,20 @@ func restore_adventure(seed_val: int, counter_val: int) -> void: _open_log_session(_master_seed) +# 纯流面(节点图生成器核心批 卡① 契约 1 新增):结构生成专用取数面——(seed_val, scope) 的纯函数, +# 不推全局计数器、不落事件日志、不读 _master_seed。同 (seed_val, scope) ⇒ 逐位同流, +# 故生成器产图与号位无关(断点续玩在停点号位重生成同图的前提)。随机仍出自本服务(禁自建 RNG 红线不破—— +# 本面是 derive_at 纯派生面的流化,与 open_stream 取号面同服务内分工,非另起第二套流)。 +func pure_stream(seed_val: int, scope: int) -> RandomNumberGenerator: + assert(seed_val != INT64_MIN, "pure_stream 种子 INT64_MIN 与 0 静默别名(禁止)") + assert(scope >= 0, "负 scope 与 scope+2^63 经输出掩码别名(禁止)") + var s: int = absi(seed_val) + var t: int = s + scope * GOLDEN_RATIO # wrap64 自然回绕——与 derive_at 同语义 + var rg := RandomNumberGenerator.new() + rg.seed = _mix64(SINGLE_SALT ^ t) + return rg + + # 取号门面:一次调用 = 一次全局 scope 取号(计数器读值 → 派生 → +1 → 落日志)。 # purpose 不参与派生——只作事件日志的用途标签(七流名保留为合法标签词表)。 func open_stream(purpose: String) -> RandomNumberGenerator: diff --git a/game/data/schemas/level_gen_params.gd b/game/data/schemas/level_gen_params.gd new file mode 100644 index 00000000..20df9628 --- /dev/null +++ b/game/data/schemas/level_gen_params.gd @@ -0,0 +1,80 @@ +## 关卡节点图参数档 schema(节点图生成器核心批 卡① T1):五旋钮+战斗保底+岔口去路数的行式数据表。 +## 键 = level_index(关卡行集单点),消费面经 ConfigDB.level_gen_params_row() 读取;生成器只吃参数档、不认关卡号(契约 6)。 +## 数值全 provisional(定值批落终值);域校验沿关卡节点图生成.md v1.1 调参表已锁条款——层深 L1≥6 递增增幅变大、 +## 配比四类合计 1、出口 L1 恒 1/L2-L3 1-3、岔口去路数恒 2/3、战斗保底≥0。 +class_name LevelGenParams +extends Resource + +const RATIO_KEYS := ["battle_ratio", "reward_ratio", "rest_ratio", "shop_ratio"] +const ROW_KEYS := ["level_index", "layers", "branch_density", "entry_count", "exit_count", + "battle_floor", "fork_ways_min", "fork_ways_max"] + RATIO_KEYS + +@export var rows: Array[Dictionary] = [] # 每行 = ROW_KEYS 键集(level_index 唯一正整数) + + +func validate() -> Array: + var errs: Array[Dictionary] = [] + var seen := {} + var layers_by_level := {} + for row in rows: + var L: int = int(row.get("level_index", 0)) + var key := "level %d" % L + if seen.has(L): + errs.append({"category": "dup_key", "message": "LevelGenParams 键重复: %d" % L}) + seen[L] = true + if L < 1: + errs.append({"category": "domain", "message": "%s 关卡序号越域(≥1)" % key}) + var ks := (row as Dictionary).keys() + ks.sort() + var expect := ROW_KEYS.duplicate() + expect.sort() + if ks != expect: + errs.append({"category": "structural", "message": "%s 字段键集非 ROW_KEYS 闭集" % key}) + continue + var layers: int = int(row.get("layers", 0)) + if layers < 6: + errs.append({"category": "domain", "message": "%s 层深 %d 低于下限 6(层深 L1≥6 已锁)" % [key, layers]}) + layers_by_level[L] = layers + var bd: float = float(row.get("branch_density", -1.0)) + if bd < 0.0 or bd > 1.0: + errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]}) + var ratio_sum := 0.0 + for rk in RATIO_KEYS: + var v: float = float(row.get(rk, -1.0)) + if v < 0.0 or v > 1.0: + errs.append({"category": "domain", "message": "%s %s=%f 越域 [0,1]" % [key, rk, v]}) + ratio_sum += v + if not is_equal_approx(ratio_sum, 1.0): + errs.append({"category": "domain", "message": "%s 配比四类合计 %f ≠ 1(已锁)" % [key, ratio_sum]}) + var entry_count: int = int(row.get("entry_count", 0)) + if entry_count < 1: + errs.append({"category": "domain", "message": "%s 入口个数 %d 越域(≥1)" % [key, entry_count]}) + var exit_count: int = int(row.get("exit_count", 0)) + if exit_count < 1 or exit_count > 3: + errs.append({"category": "domain", "message": "%s 出口个数 %d 越域 [1,3](已锁范围)" % [key, exit_count]}) + if L == 1 and exit_count != 1: + errs.append({"category": "domain", "message": "%s 出口个数 %d ≠ 1(L1 出口恒 1 已锁)" % [key, exit_count]}) + var bf: int = int(row.get("battle_floor", -1)) + if bf < 0: + errs.append({"category": "domain", "message": "%s 战斗保底 %d 越域(≥0)" % [key, bf]}) + var fmin: int = int(row.get("fork_ways_min", 0)) + var fmax: int = int(row.get("fork_ways_max", 0)) + if fmin != 2 or fmax != 3: + errs.append({"category": "domain", "message": "%s 岔口去路数 (%d,%d) ≠ (2,3)(已锁)" % [key, fmin, fmax]}) + if layers_by_level.has(1) and layers_by_level.has(2) and layers_by_level.has(3): + var d12: int = int(layers_by_level[2]) - int(layers_by_level[1]) + var d23: int = int(layers_by_level[3]) - int(layers_by_level[2]) + if not (int(layers_by_level[1]) < int(layers_by_level[2]) and int(layers_by_level[2]) < int(layers_by_level[3])): + errs.append({"category": "domain", "message": "层深逐关递增不成立(L1/L2/L3 = %d/%d/%d)" % [ + layers_by_level[1], layers_by_level[2], layers_by_level[3]]}) + elif d23 <= d12: + errs.append({"category": "domain", "message": "层深增幅未变大(Δ(L1→L2)=%d、Δ(L2→L3)=%d)" % [d12, d23]}) + return errs + + +# 参数档单点读取(键 = level_index,缺行返回空字典——沿 level_row() 体例) +func row_by_level_index(level_index: int) -> Dictionary: + for row in rows: + if int(row.get("level_index", 0)) == level_index: + return row + return {} diff --git a/game/data/schemas/level_gen_params.gd.uid b/game/data/schemas/level_gen_params.gd.uid new file mode 100644 index 00000000..d897fc28 --- /dev/null +++ b/game/data/schemas/level_gen_params.gd.uid @@ -0,0 +1 @@ +uid://c3yc3mhl1w7w4 diff --git a/game/data/tables/level_gen_params.tres b/game/data/tables/level_gen_params.tres new file mode 100644 index 00000000..2aa43701 --- /dev/null +++ b/game/data/tables/level_gen_params.tres @@ -0,0 +1,49 @@ +[gd_resource type="Resource" script_class="LevelGenParams" load_steps=2 format=3] + +[ext_resource type="Script" path="res://data/schemas/level_gen_params.gd" id="1"] + +[resource] +script = ExtResource("1") +rows = [{ +"level_index": 1, +"layers": 6, +"branch_density": 0.3, +"battle_ratio": 0.5, +"reward_ratio": 0.2, +"rest_ratio": 0.2, +"shop_ratio": 0.1, +"entry_count": 1, +"exit_count": 1, +"battle_floor": 4, +"fork_ways_min": 2, +"fork_ways_max": 3 +}, { +"level_index": 2, +"layers": 9, +"branch_density": 0.5, +"battle_ratio": 0.5, +"reward_ratio": 0.2, +"rest_ratio": 0.15, +"shop_ratio": 0.15, +"entry_count": 2, +"exit_count": 2, +"battle_floor": 6, +"fork_ways_min": 2, +"fork_ways_max": 3 +}, { +"level_index": 3, +"layers": 13, +"branch_density": 0.7, +"battle_ratio": 0.55, +"reward_ratio": 0.15, +"rest_ratio": 0.15, +"shop_ratio": 0.15, +"entry_count": 2, +"exit_count": 3, +"battle_floor": 8, +"fork_ways_min": 2, +"fork_ways_max": 3 +}] + +; 节点图生成器核心批 卡① §8.1 参数档 provisional 取值表逐行填列(定值批落终值,数值全 provisional)。 +; 层深/出口/岔口去路数为已锁域;商店实数由摆放约束钉死=出口数(shop_ratio 为名义值,配比填余次序见卡① §8.1 注)。 diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index c6d447f8..824ecf97 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -41,6 +41,7 @@ func _suites() -> Array: # 样板件占位断言跑绿没有信息量,且占据「27 套件」计数。文件保留作模板(同类先例见下 fog_lantern 摘除行)。 {"path": "res://tests/unit/data_config/config_db_test.gd", "expect": 0}, {"path": "res://tests/unit/data_config/forge_table_test.gd", "expect": 0}, # 六路线 schema 正反(V1)+特效留空(V11)+几何价格梯(V2 半边) + {"path": "res://tests/unit/data_config/level_gen_params_test.gd", "expect": 0}, # 参数档 schema 正反+访问器读回(节点图生成器核心批 卡① V2〔fv2〕) {"path": "res://tests/unit/data_config/config_db_fatal_oob_test.gd", "expect": 1}, {"path": "res://tests/unit/data_config/treasure_target_violation_test.gd", "expect": 1}, # 真视窄域护栏 # negative=true=负面套件:坏表/违规输入是它的测试目标,输出里必然带 Parse Error, @@ -51,6 +52,8 @@ func _suites() -> Array: {"path": "res://tests/unit/data_config/table_structure_guard_test.gd", "expect": 0}, # 表结构守卫宿主(adventure 配额与同调目录/enemy 带键行序/AC 单轨与掷点参数/variation 门控互锁/difficulty 修饰/reward 九型/meta 行族) {"path": "res://tests/unit/data_config/rng_stream_boot_test.gd", "expect": 0}, # 启动初始化与统一 RNG 流宿主(data/tables 加载链/统一流黄金向量 m=42/derive_at 恒等/open_stream 取号序/new_adventure 计数器重置——批 B 自 smoke_all 迁入;源 :70 事件日志落盘条按裁决一转办集成宿主) {"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/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/core/node_map_generator_test.gd b/game/tests/unit/core/node_map_generator_test.gd new file mode 100644 index 00000000..adca0af1 --- /dev/null +++ b/game/tests/unit/core/node_map_generator_test.gd @@ -0,0 +1,407 @@ +# tests/unit/core/node_map_generator_test.gd — 节点图生成器验收断言(节点图生成器核心批 卡① T2/T3) +# 覆盖:V1 产出合格(三关参数档×种子产出自证零错误+违规夹具逐类报错)、V3 可复现(同种子逐位同图+ +# counter 无关+异种子异图)、V5 岔路取舍两条、V6 必经恰一店+归属锁定+多终点深度互异、V7 战斗保底、 +# V9 含岔口与出口结构(第一关即含岔口/L1 出口恒 1/大岔口自 L2 起)、V10 RNG 单点纯流面(counter 前后不变)。 +# 运行:godot --headless --path game --script res://tests/unit/core/node_map_generator_test.gd(expect 0) +# 协议(tests/README):extends SceneTree + quit(code);RngService 显式实例化注入(--script 禁裸 autoload)。 +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") +const RngScript := preload("res://core/rng_service.gd") +const GenScript := preload("res://core/node_map_generator.gd") +const Kit := preload("res://tests/unit/test_kit.gd") + +var _db +var _gen + + +func _init() -> void: + _db = ConfigDBScript.new() + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[node_map_generator_test] 前置表加载失败") + quit(2) + return + _gen = GenScript.new(RngScript.new()) + _test_output_ok() + _test_reproducible() + _test_fork_rules() + _test_shop_and_attribution() + _test_battle_floor() + _test_exit_structure() + _test_rng_single_point() + _test_negative_fixtures() + quit(Kit.finish("node_map_generator_test")) + + +func _params(L: int) -> Dictionary: + return _db.level_gen_params_row(L) + + +func _graph(L: int, seed_val: int) -> Dictionary: + return _gen.generate(_params(L), seed_val) + + +func _sig(g: Dictionary) -> String: + return JSON.stringify(g) + + +func _node(g: Dictionary, id: int) -> Dictionary: + for n in g["nodes"]: + if int(n["id"]) == id: + return n + return {} + + +func _count_type(g: Dictionary, ty: String) -> int: + var c := 0 + for n in g["nodes"]: + if String(n["type"]) == ty: + c += 1 + return c + + +func _terminals(g: Dictionary) -> Array: + return g["terminals"] + + +func _max_depth(g: Dictionary) -> int: + var m := 0 + for n in g["nodes"]: + m = maxi(m, int(n["depth"])) + return m + + +# ---- V1 产出合格:三档×种子扫描,自证零错误 ---- + +func _test_output_ok() -> void: + var all_ok := true + var entry_exit_ok := true + for L in [1, 2, 3]: + for s in range(42, 62): + var g := _graph(L, s) + if not _gen.validate_output(g, _params(L)).is_empty(): + all_ok = false + if (g["entries"] as Array).size() != int(_params(L)["entry_count"]) \ + or (g["terminals"] as Array).size() != int(_params(L)["exit_count"]): + entry_exit_ok = false + Kit.check("三档×20 种子产出图过产出约束五条+结构铁律(自证零错误)〔fv1〕", all_ok) + Kit.check("入口数/终点数 = 参数档 entry_count/exit_count〔fv1〕", entry_exit_ok) + + +# ---- V3 可复现:纯函数+counter 无关 ---- + +func _test_reproducible() -> void: + var same := true + for L in [1, 2, 3]: + if _sig(_graph(L, 42)) != _sig(_graph(L, 42)): + same = false + Kit.check("同(种子,参数档)两次产图逐位同图〔fv3〕", same) + # counter 无关:两台 RngService 处于不同号位状态,产图必须逐位相同 + var svc_a = RngScript.new() + svc_a.new_adventure(1) + var gen_a = GenScript.new(svc_a) + var svc_b = RngScript.new() + svc_b.new_adventure(999999) + for i in range(7): + svc_b.open_stream("node_map") + var gen_b = GenScript.new(svc_b) + Kit.check("counter 无关(不同号位态同种子同图)〔fv3〕", + _sig(gen_a.generate(_params(2), 42)) == _sig(gen_b.generate(_params(2), 42)) + and _sig(gen_a.generate(_params(3), 7)) == _sig(gen_b.generate(_params(3), 7))) + var distinct := 0 + var seen := {} + for s in range(42, 52): + var k := _sig(_graph(3, s)) + if not seen.has(k): + distinct += 1 + seen[k] = true + Kit.check("同参数档不同种子产出异图(10 种子去重数 ≥ 8)〔fv3〕", distinct >= 8) + + +# ---- V5 岔路取舍两条 ---- + +func _test_fork_rules() -> void: + var next_hop_ok := true + var comp_ok := true + for L in [1, 2, 3]: + for s in [42, 43, 44]: + var g := _graph(L, s) + var forks := 0 + for n in g["nodes"]: + var outs: Array = n["next"] + if outs.size() < 2: + continue + forks += 1 + var types := {} + for oid in outs: + var ty := String(_node(g, int(oid))["type"]) + if types.has(ty): + next_hop_ok = false + types[ty] = true + var comps := [] + for oid in outs: + comps.append(_branch_types(g, int(oid), outs)) + for i in range(comps.size()): + for j in range(i + 1, comps.size()): + if comps[i] == comps[j]: + comp_ok = false + for c in comps: + var has_unique := false + for ty in (c as Array): + var in_other := false + for o in comps: + if o != c and (o as Array).has(ty): + in_other = true + break + if not in_other: + has_unique = true + break + if not has_unique: + comp_ok = false + Kit.check_quiet(forks >= 1, "档 %d 种子 %d 图含岔口节点(出度≥2)" % [L, s]) + Kit.check("任意岔口各去路下一节点类型互不相同〔fv5〕", next_hop_ok) + Kit.check("任意岔口各支路类型构成互异且各有独有类型〔fv5〕", comp_ok) + + +# 支路互斥段类型多重集(口径=自出边起至再汇合/终点止,卡① §10 ②) +func _branch_types(g: Dictionary, head_id: int, sibling_outs: Array) -> Array: + var reach := {head_id: true} + var q := [head_id] + while not q.is_empty(): + var id: int = int(q.pop_front()) + for oid in (_node(g, id)["next"] as Array): + if not reach.has(int(oid)): + reach[int(oid)] = true + q.append(int(oid)) + for sid in sibling_outs: + if int(sid) == head_id: + continue + var other := {_node(g, int(sid))["id"]: true} + var qq: Array = [int(sid)] + while not qq.is_empty(): + var iid: int = int(qq.pop_front()) + for oid in (_node(g, iid)["next"] as Array): + if not other.has(int(oid)): + other[int(oid)] = true + qq.append(int(oid)) + for rid in other.keys(): + reach.erase(int(rid)) + var arr := [] + for rid in reach.keys(): + arr.append(String(_node(g, int(rid))["type"])) + arr.sort() + return arr + + +# ---- V6 必经恰一店+归属锁定+多终点深度互异 ---- + +func _test_shop_and_attribution() -> void: + var paths_ok := true + var lock_ok := true + var depth_ok := true + for L in [1, 2, 3]: + for s in [42, 43, 44]: + var g := _graph(L, s) + for p in _all_paths(g): + var shops := 0 + for id in p: + if String(_node(g, int(id))["type"]) == "shop": + shops += 1 + if shops != 1: + paths_ok = false + if _count_type(g, "shop") != _terminals(g).size(): + lock_ok = false + for tid in _terminals(g): + var preds := [] + for n in g["nodes"]: + if (n["next"] as Array).has(tid): + preds.append(n) + if preds.size() != 1 or String((preds[0] as Dictionary)["type"]) != "shop": + lock_ok = false + var ds := {} + for tid in _terminals(g): + var d: int = int(_node(g, int(tid))["depth"]) + if ds.has(d): + depth_ok = false + ds[d] = true + Kit.check("任意入口→终点路径恰过一家店〔fv6〕", paths_ok) + Kit.check("每终点前一位恰一店+商店总数=终点数(归属锁定)〔fv6〕", lock_ok) + Kit.check("多终点落在互异节点深度(L2/L3)〔fv6〕", depth_ok) + + +func _all_paths(g: Dictionary) -> Array: + var out := [] + for eid in g["entries"]: + var stack: Array = [[int(eid), []]] + while not stack.is_empty(): + var frame: Array = stack.pop_back() + var path: Array = frame[1] + [int(frame[0])] + var outs: Array = _node(g, int(frame[0]))["next"] + if outs.is_empty(): + out.append(path) + continue + for oid in outs: + stack.append([int(oid), path]) + return out + + +# ---- V7 战斗保底 ---- + +func _test_battle_floor() -> void: + var ok := true + for L in [1, 2, 3]: + for s in range(42, 52): + if _count_type(_graph(L, s), "battle") < int(_params(L)["battle_floor"]): + ok = false + Kit.check("任意合法产出战斗节点数 ≥ battle_floor〔fv7〕", ok) + + +# ---- V9 含岔口与出口结构 ---- + +func _test_exit_structure() -> void: + var fork_all := true + var l1_single := true + var big_fork_ok := true + for L in [1, 2, 3]: + for s in [42, 43, 44]: + var g := _graph(L, s) + var has_fork := false + for n in g["nodes"]: + if (n["next"] as Array).size() >= 2: + has_fork = true + if not has_fork: + fork_all = false + if L == 1 and _terminals(g).size() != 1: + l1_single = false + if L > 1 and int(_params(L)["exit_count"]) > 1: + var big := false + for n in g["nodes"]: + if (n["next"] as Array).size() == int(_params(L)["exit_count"]) \ + and _terminals_reachable(g, int(n["id"])) >= 2: + big = true + if not big: + big_fork_ok = false + Kit.check("三关参数档生成图各含 ≥1 岔口节点(第一关即含)〔fv9〕", fork_all) + Kit.check("L1 生成图终点恰 1(出口恒 1)〔fv9〕", l1_single) + Kit.check("L2/L3 含分区域大岔口(一节点可达 ≥2 终点且去路数=出口数)〔fv9〕", big_fork_ok) + + +func _terminals_reachable(g: Dictionary, id: int) -> int: + var reach := {} + var q := [id] + while not q.is_empty(): + var cur: int = int(q.pop_front()) + for oid in (_node(g, cur)["next"] as Array): + if not reach.has(int(oid)): + reach[int(oid)] = true + q.append(int(oid)) + var c := 0 + for tid in _terminals(g): + if reach.has(int(tid)): + c += 1 + return c + + +# ---- V10 RNG 单点(纯流面 counter 不变)---- + +func _test_rng_single_point() -> void: + var svc = RngScript.new() + svc.new_adventure(4242) + var before: int = svc.counter() + var g = GenScript.new(svc).generate(_params(2), 42) + Kit.check("pure_stream 不推全局计数器(产图前后 counter 同值)〔fv10〕", svc.counter() == before) + var a = svc.pure_stream(42, 0) + var b = svc.pure_stream(42, 0) + Kit.check("pure_stream 同 (seed,scope) 同流〔fv10〕", a.randf() == b.randf() and a.randi() == b.randi()) + Kit.check("pure_stream 不推计数器且产图非空〔fv10〕", + svc.counter() == before and (g["nodes"] as Array).size() > 0) + + +# ---- V1 违规夹具:各报对应错误类别 ---- + +func _test_negative_fixtures() -> void: + # 夹具构造器:手写 nodes/entries/terminals + var dup := _fx([ + {"id": 0, "type": "battle", "depth": 0, "next": [1]}, + {"id": 0, "type": "boss", "depth": 1, "next": []}]) + Kit.check("句柄重复夹具报 dup_key〔fv1〕", _has_cat(_gen.validate_output(dup), "dup_key")) + var dang := _fx([ + {"id": 0, "type": "battle", "depth": 0, "next": [9]}, + {"id": 1, "type": "boss", "depth": 1, "next": []}]) + Kit.check("悬空出边夹具报 dangling_next〔fv1〕", _has_cat(_gen.validate_output(dang), "dangling_next")) + var badty := _fx([ + {"id": 0, "type": "mine", "depth": 0, "next": [1]}, + {"id": 1, "type": "boss", "depth": 1, "next": []}]) + Kit.check("类型出域夹具报 enum〔fv1〕", _has_cat(_gen.validate_output(badty), "enum")) + # 非 Boss 终点(断头链末不是 boss) + var noBoss := _fx([ + {"id": 0, "type": "battle", "depth": 0, "next": [1]}, + {"id": 1, "type": "battle", "depth": 1, "next": []}]) + Kit.check("非 Boss 终点夹具报 structural〔fv1〕", _has_cat(_gen.validate_output(noBoss), "structural")) + # 环/回边:深度不前进 + var loop := _fx([ + {"id": 0, "type": "battle", "depth": 0, "next": [1]}, + {"id": 1, "type": "shop", "depth": 1, "next": [2]}, + {"id": 2, "type": "boss", "depth": 0, "next": []}]) + Kit.check("回边夹具报前进方向违例(structural)〔fv1〕", _has_cat(_gen.validate_output(loop), "structural")) + # 双终点合法夹具(区域类型互异+异深)与同深度违规夹具 + var twin := _fx([ + {"id": 0, "type": "battle", "depth": 0, "next": [1, 4]}, + {"id": 1, "type": "battle", "depth": 1, "next": [2]}, + {"id": 2, "type": "shop", "depth": 2, "next": [3]}, + {"id": 3, "type": "boss", "depth": 3, "next": []}, + {"id": 4, "type": "rest", "depth": 1, "next": [5]}, + {"id": 5, "type": "shop", "depth": 2, "next": [6]}, + {"id": 6, "type": "boss", "depth": 3, "next": []}]) + Kit.check("多终点同深度夹具报 structural〔fv6〕", _has_cat(_gen.validate_output(twin), "structural")) + var twin_ok := _fx([ + {"id": 0, "type": "battle", "depth": 0, "next": [1, 4]}, + {"id": 1, "type": "battle", "depth": 1, "next": [2]}, + {"id": 2, "type": "shop", "depth": 2, "next": [3]}, + {"id": 3, "type": "boss", "depth": 3, "next": []}, + {"id": 4, "type": "rest", "depth": 1, "next": [5]}, + {"id": 5, "type": "rest", "depth": 2, "next": [6]}, + {"id": 6, "type": "shop", "depth": 3, "next": [7]}, + {"id": 7, "type": "boss", "depth": 4, "next": []}]) + Kit.check("双终点异深+区域类型互异合法夹具自证零错误〔fv6〕", _gen.validate_output(twin_ok).is_empty()) + # 终点前驱非店 + var noShopPred := _fx([ + {"id": 0, "type": "battle", "depth": 0, "next": [1]}, + {"id": 1, "type": "battle", "depth": 1, "next": [2]}, + {"id": 2, "type": "boss", "depth": 2, "next": []}]) + var cats: Array = _gen.validate_output(noShopPred) + Kit.check("终点前驱非店夹具报 structural(必经恰一店/商店数)〔fv6〕", _has_cat(cats, "structural")) + # 岔口两去路同类型 + var sameHop := _fx([ + {"id": 0, "type": "battle", "depth": 0, "next": [1, 2]}, + {"id": 1, "type": "battle", "depth": 1, "next": [3]}, + {"id": 2, "type": "battle", "depth": 1, "next": [3]}, + {"id": 3, "type": "shop", "depth": 2, "next": [4]}, + {"id": 4, "type": "boss", "depth": 3, "next": []}]) + Kit.check("岔口同类型下一跳夹具报 structural〔fv5〕", _has_cat(_gen.validate_output(sameHop), "structural")) + + +func _fx(nodes: Array) -> Dictionary: + var entries := [] + var terminals := [] + var indeg := {} + for n in nodes: + indeg[int(n["id"])] = 0 + for n in nodes: + for oid in (n["next"] as Array): + if indeg.has(int(oid)): + indeg[int(oid)] += 1 + for n in nodes: + if int(indeg[int(n["id"])]) == 0: + entries.append(int(n["id"])) + if (n["next"] as Array).is_empty(): + terminals.append(int(n["id"])) + return {"nodes": nodes, "entries": entries, "terminals": terminals} + + +func _has_cat(errs: Array, cat: String) -> bool: + for e in errs: + if String(e["category"]) == cat: + return true + return false diff --git a/game/tests/unit/core/node_map_generator_test.gd.uid b/game/tests/unit/core/node_map_generator_test.gd.uid new file mode 100644 index 00000000..2013db74 --- /dev/null +++ b/game/tests/unit/core/node_map_generator_test.gd.uid @@ -0,0 +1 @@ +uid://cxxjxju8g6txd diff --git a/game/tests/unit/core/node_map_growth_test.gd b/game/tests/unit/core/node_map_growth_test.gd new file mode 100644 index 00000000..434d4f36 --- /dev/null +++ b/game/tests/unit/core/node_map_growth_test.gd @@ -0,0 +1,107 @@ +# tests/unit/core/node_map_growth_test.gd — 生长递增+参数无解 fail-fast(节点图生成器核心批 卡① T3) +# 覆盖:V8 生长递增(L1/L2/L3 最长路径步数与节点总数逐关递增、Δ(L2→L3)>Δ(L1→L2))、 +# V4 执行边界(参数无解=致命中止,无残图、无兜底回退——独立进程退出码非零通道,沿 config_db_fatal_oob 先例)。 +# 运行:godot --headless --path game --script res://tests/unit/core/node_map_growth_test.gd(expect 0) +# 子进程夹具:同路径加 user arg "fatal-fixture" → 无解参数档产图 → 期望进程致命中止(退出码非零)。 +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") +const RngScript := preload("res://core/rng_service.gd") +const GenScript := preload("res://core/node_map_generator.gd") +const Kit := preload("res://tests/unit/test_kit.gd") + + +func _init() -> void: + if OS.get_cmdline_user_args().has("fatal-fixture"): + _trigger_fatal() + return + var db = ConfigDBScript.new() + if not db.load_tables("res://data/tables", true): + push_error("[node_map_growth_test] 前置表加载失败") + quit(2) + return + var gen = GenScript.new(RngScript.new()) + _test_growth(gen, db) + _test_fail_fast_static(gen, db) + _test_fatal_subprocess() + quit(Kit.finish("node_map_growth_test")) + + +func _reading(gen, db, L: int) -> Dictionary: + var g: Dictionary = gen.generate(db.level_gen_params_row(L), 42) + var maxd := 0 + for n in g["nodes"]: + maxd = maxi(maxd, int(n["depth"])) + return {"steps": maxd, "nodes": (g["nodes"] as Array).size()} + + +# ---- V8 生长递增(三档×多种子逐档读数)---- + +func _test_growth(gen, db) -> void: + var steps_ok := true + var count_ok := true + for s in [42, 43, 44, 45, 46]: + var r1: Dictionary = _reading(gen, db, 1) + var r2: Dictionary = _reading(gen, db, 2) + var r3: Dictionary = _reading(gen, db, 3) + Kit.check_quiet(int(r1["steps"]) < int(r2["steps"]) and int(r2["steps"]) < int(r3["steps"]), + "种子 %d 最长路径步数逐关递增(%d/%d/%d)" % [s, r1["steps"], r2["steps"], r3["steps"]]) + var d12: int = int(r2["nodes"]) - int(r1["nodes"]) + var d23: int = int(r3["nodes"]) - int(r2["nodes"]) + Kit.check_quiet(int(r1["nodes"]) < int(r2["nodes"]) and d23 > d12, + "种子 %d 节点总数递增且 Δ(L2→L3)=%d > Δ(L1→L2)=%d" % [s, d23, d12]) + if not (int(r1["steps"]) < int(r2["steps"]) and int(r2["steps"]) < int(r3["steps"])): + steps_ok = false + if not (int(r1["nodes"]) < int(r2["nodes"]) and d23 > d12): + count_ok = false + Kit.check("生长递增:三档最长路径步数逐关变大〔fv8〕", steps_ok) + Kit.check("生长递增:节点总数逐关变大且增幅变大〔fv8〕", count_ok) + + +# ---- V4 参数无解判定(静态面:域/预算无解夹具必须被 check_params 拦下)---- + +func _test_fail_fast_static(gen, db) -> void: + Kit.check("生产三档参数档可行性判定零错误〔fv4〕", + NodeMapGenerator.check_params(db.level_gen_params_row(1)).is_empty() + and NodeMapGenerator.check_params(db.level_gen_params_row(3)).is_empty()) + var unsolvable := {"layers": 6, "branch_density": 0.0, "battle_ratio": 0.5, "reward_ratio": 0.2, + "rest_ratio": 0.2, "shop_ratio": 0.1, "entry_count": 1, "exit_count": 3, "battle_floor": 4, + "fork_ways_min": 2, "fork_ways_max": 3} + Kit.check("层深 6 放三出口无解 → check_params 拦截〔fv4〕", not NodeMapGenerator.check_params(unsolvable).is_empty()) + var overfloor := {"layers": 6, "branch_density": 0.0, "battle_ratio": 0.5, "reward_ratio": 0.2, + "rest_ratio": 0.2, "shop_ratio": 0.1, "entry_count": 1, "exit_count": 1, "battle_floor": 99, + "fork_ways_min": 2, "fork_ways_max": 3} + Kit.check("战斗保底超可放数无解 → check_params 拦截〔fv4〕", not NodeMapGenerator.check_params(overfloor).is_empty()) + + +# ---- V4 致命中止真实通道(子进程退出码非零;零回退分支=件核)---- + +func _test_fatal_subprocess() -> void: + var bin := _engine_bin() + if bin == "": + Kit.check("未找到引擎二进制,致命子进程通道未跑(如实标注)〔fv4〕", false) + return + var game_dir := ProjectSettings.globalize_path("res://") + var out := [] + var code: int = OS.execute(bin, ["--headless", "--path", game_dir, "--script", + "res://tests/unit/core/node_map_growth_test.gd", "--", "fatal-fixture"], out, true) + Kit.check("无解参数档产图进程致命中止(退出码非零,无残图交付)〔fv4〕", code != 0) + + +func _trigger_fatal() -> void: + var gen = GenScript.new(RngScript.new()) + # 域内合法但深度预算无解:层深 6 放三出口(check_params 拦下 → 致命中止) + var unsolvable := {"layers": 6, "branch_density": 0.0, "battle_ratio": 0.5, "reward_ratio": 0.2, + "rest_ratio": 0.2, "shop_ratio": 0.1, "entry_count": 1, "exit_count": 3, "battle_floor": 4, + "fork_ways_min": 2, "fork_ways_max": 3} + gen.generate(unsolvable, 42) + # 走到这里=致命通道未止住(软回退缺陷)→ 显式退出码 2 与崩溃码区分 + push_error("[fatal-fixture] 参数无解未致命中止(fail-fast 失效回归)") + quit(2) + + +func _engine_bin() -> String: + var env_bin := OS.get_environment("GODOT_BIN") + if env_bin != "" and FileAccess.file_exists(env_bin): + return env_bin + return OS.get_executable_path() diff --git a/game/tests/unit/core/node_map_growth_test.gd.uid b/game/tests/unit/core/node_map_growth_test.gd.uid new file mode 100644 index 00000000..38da2692 --- /dev/null +++ b/game/tests/unit/core/node_map_growth_test.gd.uid @@ -0,0 +1 @@ +uid://gxtncmtalosl diff --git a/game/tests/unit/data_config/level_gen_params_test.gd b/game/tests/unit/data_config/level_gen_params_test.gd new file mode 100644 index 00000000..b6f13733 --- /dev/null +++ b/game/tests/unit/data_config/level_gen_params_test.gd @@ -0,0 +1,105 @@ +# tests/unit/data_config/level_gen_params_test.gd — 关卡节点图参数档单测(节点图生成器核心批 卡① T1/V2) +# 覆盖:生产表三行加载+validate 零违例、域校验逐类负向(出口 L1 恒 1/配比合计 1/层深下限/增幅变大/ +# 岔口去路数锁定/键集闭集/键重复)、ConfigDB 访问器按 level_index 读回与缺行空字典。 +# 运行:godot --headless --path game --script res://tests/unit/data_config/level_gen_params_test.gd(expect 0) +# 协议(tests/README):extends SceneTree + quit(code);负向用例资源直构不碰生产表。 +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") +const LgpScript := preload("res://data/schemas/level_gen_params.gd") +const Kit := preload("res://tests/unit/test_kit.gd") + + +func _init() -> void: + var db = ConfigDBScript.new() + var load_ok: bool = db.load_tables("res://data/tables", true) + Kit.check("生产 level_gen_params.tres 随全表加载 + validate 零违例〔fv2〕", + load_ok and db.diagnostics_count("error") == 0) + if load_ok: + _test_production(db) + _test_accessor() + _test_negative() + quit(Kit.finish("level_gen_params_test")) + + +func _row(L: int, layers: int, bd: float, ratios: Array, entry: int, exit_c: int, floor_n: int) -> Dictionary: + return {"level_index": L, "layers": layers, "branch_density": bd, + "battle_ratio": ratios[0], "reward_ratio": ratios[1], "rest_ratio": ratios[2], "shop_ratio": ratios[3], + "entry_count": entry, "exit_count": exit_c, "battle_floor": floor_n, + "fork_ways_min": 2, "fork_ways_max": 3} + + +func _table(rows: Array) -> Variant: + var t = LgpScript.new() + var typed: Array[Dictionary] = [] + for r in rows: + typed.append(r) + t.rows = typed + return t + + +# ---- 生产三行正向(V2)---- + +func _test_production(db) -> void: + var r1: Dictionary = db.level_gen_params_row(1) + var r2: Dictionary = db.level_gen_params_row(2) + var r3: Dictionary = db.level_gen_params_row(3) + Kit.check("访问器读回 L1 行五旋钮齐备〔fv2〕", + int(r1.get("layers", 0)) == 6 and is_equal_approx(float(r1.get("branch_density", 0.0)), 0.3) + and int(r1.get("exit_count", 0)) == 1 and int(r1.get("entry_count", 0)) == 1) + Kit.check("访问器读回 L2/L3 行层深 9/13 递增〔fv2〕", + int(r2.get("layers", 0)) == 9 and int(r3.get("layers", 0)) == 13) + Kit.check("三行出口 = 1/2/3、战斗保底 = 4/6/8 与 §8.1 表对上〔fv2〕", + int(r1.get("battle_floor", 0)) == 4 and int(r2.get("exit_count", 0)) == 2 + and int(r3.get("exit_count", 0)) == 3 and int(r3.get("battle_floor", 0)) == 8) + Kit.check("生产表 validate 零错误〔fv2〕", db.level_gen_params.validate().is_empty()) + + +# ---- 访问器缺行防御 ---- + +func _test_accessor() -> void: + var t = _table([_row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, 4)]) + Kit.check("row_by_level_index 缺行返回空字典〔fv2〕", t.row_by_level_index(9).is_empty()) + + +# ---- 域校验逐类负向(V2)---- + +func _test_negative() -> void: + # 合法基线三行 + var base := [_row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, 4), + _row(2, 9, 0.5, [0.5, 0.2, 0.15, 0.15], 2, 2, 6), + _row(3, 13, 0.7, [0.55, 0.15, 0.15, 0.15], 2, 3, 8)] + Kit.check("合法三行域校验零错误(夹具自证)〔fv2〕", _table(base).validate().is_empty()) + # ① L1 出口改 2 → 出口恒 1 断言红(变异步同款) + var bad_l1_exit := [_row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 2, 4), base[1], base[2]] + Kit.check("L1 出口=2 被拒(出口 L1 恒 1 已锁)〔fv2〕", not _table(bad_l1_exit).validate().is_empty()) + # ② 配比合计 0.5 → 合计 1 断言红(变异步同款) + var bad_ratio := [_row(1, 6, 0.3, [0.2, 0.1, 0.1, 0.1], 1, 1, 4), base[1], base[2]] + Kit.check("配比合计 0.5 被拒(四类合计 1 已锁)〔fv2〕", not _table(bad_ratio).validate().is_empty()) + # ③ 层深低于下限 + var bad_layers := [_row(1, 5, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, 4), base[1], base[2]] + Kit.check("层深 5<6 被拒(层深下限已锁)〔fv2〕", not _table(bad_layers).validate().is_empty()) + # ④ 增幅未变大(6/9/11:Δ12=3、Δ23=2) + var bad_growth := [base[0], _row(2, 9, 0.5, [0.5, 0.2, 0.15, 0.15], 2, 2, 6), + _row(3, 11, 0.7, [0.55, 0.15, 0.15, 0.15], 2, 3, 8)] + Kit.check("层深增幅倒挂被拒(递增且增幅变大)〔fv2〕", not _table(bad_growth).validate().is_empty()) + # ⑤ 出口越上界(L3 出口 4) + var bad_exit := [base[0], base[1], _row(3, 13, 0.7, [0.55, 0.15, 0.15, 0.15], 2, 4, 8)] + Kit.check("出口 4 越域 [1,3] 被拒〔fv2〕", not _table(bad_exit).validate().is_empty()) + # ⑥ 岔口去路数改 (2,4) + var bad_fork_row := _row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, 4) + bad_fork_row["fork_ways_max"] = 4 + Kit.check("岔口去路数 (2,4) 被拒(恒 2/3 已锁)〔fv2〕", + not _table([bad_fork_row, base[1], base[2]]).validate().is_empty()) + # ⑦ 键集非闭集(多塞未知键) + var bad_keys_row: Dictionary = _row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, 4) + bad_keys_row["mystery"] = 1 + Kit.check("字段键集非 ROW_KEYS 闭集被拒〔fv2〕", + not _table([bad_keys_row, base[1], base[2]]).validate().is_empty()) + # ⑧ level_index 重复 + Kit.check("键重复行被拒〔fv2〕", not _table([base[0], base[0]]).validate().is_empty()) + # ⑨ 战斗保底负值 + Kit.check("战斗保底 -1 被拒(≥0)〔fv2〕", + not _table([_row(1, 6, 0.3, [0.5, 0.2, 0.2, 0.1], 1, 1, -1), base[1], base[2]]).validate().is_empty()) + # ⑩ 空表=合法态(沿 difficulty_table 先例) + Kit.check("空表域校验零错误(合法态)〔fv2〕", _table([]).validate().is_empty()) diff --git a/game/tests/unit/data_config/level_gen_params_test.gd.uid b/game/tests/unit/data_config/level_gen_params_test.gd.uid new file mode 100644 index 00000000..2cb31145 --- /dev/null +++ b/game/tests/unit/data_config/level_gen_params_test.gd.uid @@ -0,0 +1 @@ +uid://cq281qsnb0s28 diff --git a/reports/iter35-卡2-停机呈报-2026-10-24.md b/reports/iter35-卡2-停机呈报-2026-10-24.md new file mode 100644 index 00000000..b44853ed --- /dev/null +++ b/reports/iter35-卡2-停机呈报-2026-10-24.md @@ -0,0 +1,57 @@ +# 迭代 35 卡② 停机呈报(实现段零裁断——退回上游) + +- 日期:2026-10-24 +- 触发:`/iter 35` 第 4 步逐卡推进——卡① 已开工并实现,卡② 开 T1 前核 §0 前置,命中卡片自述硬交付序列 +- 批性质:代码实现批;本呈报只移交缺陷,不改卡片、不改 `design/`、不改待办清单 +- 本卡改动:**零**(未动 `level_table.gd`/`level.tres`/`level_runner.gd` 任何消费侧文件) + +## 一、现状差在哪 + +卡② `reports/需求卡片-节点图消费侧换代批-2026-10-21.md` §0「硬交付序列」明文: + +> 敌人深度带数据对生成图深度域的覆盖……归口=卡③ §5 已登记的「生成图深度扩面覆盖问题」,**尚未立卡(2026-10-24 实况登记)**……未覆盖时覆盖断言(§8.1 S5)红、遭遇解析顶 clamp 消解,**本卡不得单独合入**。 + +待办清单 `待办清单.md:143` 同口径登记:覆盖批的立卡要件(敌侧每 (关, row_kind) 顶带键 ≥ 该关最大生成深度+Boss 带键可互异+板侧 `board_gen` 键域覆盖全域)尚未成为任何一张卡片,roadmap 也没有该批的迭代行。 + +卡① 已落地的参数档深度域(`game/data/tables/level_gen_params.tres`)= L1 0–6/L2 0–9/L3 0–13;敌侧带顶仍是 L1=3/L2=L3=7、Boss 各关单带 4/9/9,板侧 `board_gen` 精确行同域缺口未变。 + +## 二、建议做什么 + +卡② 停工退回上游,按既定归口先走「生成图深度扩面覆盖批」的登记→写卡→编排三段,该批落地后再重编卡②。 + +预期效果:卡② 的 S5 覆盖断言(V11)有绿灯前提,切源后起关不再触发 `EnemyTable.band_of` 返 -1 的加载致命(`game/data/schemas/enemy_table.gd:157-161`、`game/core/config_db_cross_validator.gd:94-106`)。 + +会动到的面:`game/data/tables/enemy.tres` 深度带键域与 Boss 行集、`game/data/tables/board_gen.tres` 的 `node_depth` 键域——全是数值表改动,需用户发起(AGENTS.md 数值单点+MC 审批红线),不属本实现批职权。 + +本迭代已交付面:卡① 生成器本体+参数档数据表+产出侧自证+断言批(见 `reports/iter35-卡①-实现报告-2026-10-24.md`)。 + +## 三、为什么选这个做法 + +备选与淘汰依据: + +1. **本批顺带补敌/板覆盖数据**——淘汰:卡② §5「不做」与卡① §5「不做」都明写覆盖归卡③/后续表批;实现段自扩数值域=越范围,且数值改动需用户发令。 +2. **卡② 只交 T1(加载校验换代)不合切源**——淘汰:T1 退役旧判据与模板池校验分支后,`level.tres` 仍携 15 张模板,中间态必红(卡② §7 估时注记「T2+T3 须同轮落地,拆开中间态冒烟必红」),交出去的是一半不能验收的卡。 +3. **等覆盖批、卡② 整卡停工**(本呈报建议案)——代价=迭代 35 只交卡①,与 2026-10-24 上午停机呈报的「读法乙」一致,用户当时已裁「整体停工」并按此重锚两卡。 + +## 四、做完怎么验证 + +覆盖批的验收面已由卡② 预写死:S5/V11 断言=生成图逐 battle/boss 节点 `band_of` 非 -1(禁顶 clamp)+多终点 Boss 解析带键两两互异+板侧解析命中非空,扫描面=三关参数档×种子(卡② §8 V11)。该批落地后卡② 重编,这些断言即其开工门。 + +把握不足处:L2/L3 多终点 Boss 带键「可互异」需要敌表 Boss 行集从单带扩成按终点深度分带——具体带切法属卡③ 契约 5 的续域,覆盖批写卡时须一并裁。 + +## 五、待办清单状态回退与缺口补记拟稿(落笔归上游段/用户) + +`待办清单.md:149` 现状态行:`文档设计已闭合+已写卡+已编迭代 34+已编迭代 35` + +拟改为: + +``` +**状态**:文档设计已闭合+已写卡+已编迭代 34+已编迭代 35;迭代 35 卡① 已实现待合回(2026-10-24);卡② 停工退回——覆盖批未立卡,其 §0 硬交付序列未满足,须「生成图深度扩面覆盖批」落地后重编 +``` + +缺口补记(拟入 06 条目,替换现第 143 行缺口段的「迭代 35 停机退回」表述): + +``` +**缺口状态更新(2026-10-24 迭代 35 实现批)**:卡② §0 硬交付序列仍未满足——覆盖批未立卡,卡② 停工退回上游;卡① 不触覆盖域,已在本迭代实现(生成器产出深度域 L1 0–6/L2 0–9/L3 0–13 实测坐实)。 +立卡要件不变:敌侧每 (关, row_kind) 顶带键 ≥ 该关最大生成深度+Boss 带键可互异;板侧 board_gen 键域覆盖生成深度全域;数值 provisional。 +```