merge(迭代35卡①): 节点图生成器核心批拆批合回——卡① 全绿交付(参数档 schema+.tres 三行 provisional+ConfigDB 单点读、RngService pure_stream 纯流面、NodeMapGenerator generate/validate_output/check_params/is_fatal 致命封印沿数值表单点入口体例、三宿主 72 条断言 17/39/16、run_all manifest +3 行实跑 58 套件全绿);改动—验收对照表 fv1–fv11 齐、变异注入 12 条含 NOT-RED 与尾缀漂移如实标注、独立验收审计有条件通过且缺漏五项全部收口(含删零调用方调试面)。
域判定依据:diff 路径=game/**(代码域)+reports/**(实现批唯一可写产出物目录,非 design/),按现行口径 game/ 前缀即代码域,回执取 --code 档(receipt commit=90fd434a,分支尖 f4790127 为其后的 docs-only 报告补笔)。
卡② 停工随批呈报:其 §0 硬交付序列的「生成图深度扩面覆盖批」未立卡(待办清单 06 条目缺口段),未切运行时图源——迭代 35 因此拆批,卡② 退回上游待覆盖批落地后重编。
11 回放确定性对拍未发起=按零跑红线闭合(本批无数值改动、不接运行时;确定性由〔fv3〕〔fv10〕与闸 02 覆盖),用户裁定不批准跑批。
This commit is contained in:
commit
6475ebff35
19 files changed
+1693
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
## 关卡节点图参数化长图生成器(节点图生成器核心批 卡① 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 := {}
|
||||
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:
|
||||
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 = 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 = 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 = 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 = a
|
||||
var nid := int(node["id"])
|
||||
if (node["next"] as Array).size() != 1:
|
||||
continue # 同一节点不重复扩岔(去路数上限 3)
|
||||
var v = _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 = 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[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() < 0.5
|
||||
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
|
||||
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, 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 = 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 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:
|
||||
# 致命面沿数值表单点入口的既有体例: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
|
||||
@@ -0,0 +1 @@
|
||||
uid://daomfkyfn2i8e
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3yc3mhl1w7w4
|
||||
@@ -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 注)。
|
||||
@@ -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 读表+续玩同值)
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
# 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"))
|
||||
# 战斗保底判据专测:形状合法但战斗节点数低于保底的夹具必须被自证拒收
|
||||
var thin_battle := _fx([
|
||||
{"id": 0, "type": "battle", "depth": 0, "next": [1]},
|
||||
{"id": 1, "type": "reward", "depth": 1, "next": [2]},
|
||||
{"id": 2, "type": "shop", "depth": 2, "next": [3]},
|
||||
{"id": 3, "type": "boss", "depth": 3, "next": []}])
|
||||
Kit.check("战斗节点低于 battle_floor 的合法形状夹具命中「低于保底」判据〔fv7〕",
|
||||
_has_msg(_gen.validate_output(thin_battle, {"battle_floor": 4}), "低于保底"))
|
||||
Kit.check("同夹具在保底 1 下自证零错误(判据不误伤)〔fv7〕",
|
||||
_gen.validate_output(thin_battle, {"battle_floor": 1}).is_empty())
|
||||
var noBoss := _fx([
|
||||
{"id": 0, "type": "battle", "depth": 0, "next": [1]},
|
||||
{"id": 1, "type": "battle", "depth": 1, "next": []}])
|
||||
Kit.check("非 Boss 终点夹具命中「终点(无出边)非 Boss」判据〔fv1〕",
|
||||
_has_msg(_gen.validate_output(noBoss), "终点(无出边)非 Boss"))
|
||||
# 合流图深度赋值语义(契约 2「自入口最长路径步数」):
|
||||
# (a) 合法合流——第二入口直插主链中段,该节点深度必须取长路值而非短路值
|
||||
var conf_legal := _fx([
|
||||
{"id": 0, "type": "battle", "depth": 0, "next": [1]},
|
||||
{"id": 1, "type": "battle", "depth": 1, "next": [2]},
|
||||
{"id": 2, "type": "battle", "depth": 2, "next": [3]},
|
||||
{"id": 3, "type": "shop", "depth": 3, "next": [4]},
|
||||
{"id": 4, "type": "boss", "depth": 4, "next": []},
|
||||
{"id": 5, "type": "battle", "depth": 0, "next": [3]}])
|
||||
Kit.check("合法合流图(第二入口直插主链,深度取最长路 3)自证零错误〔fv1〕",
|
||||
_gen.validate_output(conf_legal).is_empty())
|
||||
# (b) 声明深度高于真实最长路值 → 必被拒(前进方向不破,专测深度判据)
|
||||
var conf_high := _fx([
|
||||
{"id": 0, "type": "battle", "depth": 0, "next": [1]},
|
||||
{"id": 1, "type": "battle", "depth": 1, "next": [2]},
|
||||
{"id": 2, "type": "battle", "depth": 2, "next": [3]},
|
||||
{"id": 3, "type": "shop", "depth": 4, "next": [4]},
|
||||
{"id": 4, "type": "boss", "depth": 5, "next": []}])
|
||||
Kit.check("声明深度高于最长路径步数被拒(深度=自入口最长路径步数)〔fv1〕",
|
||||
_has_msg(_gen.validate_output(conf_high), "自入口最长路径步数"))
|
||||
# 环/回边:深度不前进
|
||||
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
|
||||
|
||||
|
||||
func _has_msg(errs: Array, needle: String) -> bool:
|
||||
for e in errs:
|
||||
if String(e["message"]).contains(needle):
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxxjxju8g6txd
|
||||
@@ -0,0 +1,114 @@
|
||||
# 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)
|
||||
# 退出码三分:0 = 子进程正常收尾(致命通道静默);2 = 无解后仍走到失效分支 quit(2);
|
||||
# 1 = 致命封印置位、由夹具扮演组合根停机(既有 fatal 路径的承接形态)。
|
||||
print("[fv4] 无解子进程退出码 = %d" % code)
|
||||
Kit.check("无解参数档置致命封印并由组合根停机(退出码恰 1,无残图交付)〔fv4〕", code == 1)
|
||||
|
||||
|
||||
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)
|
||||
# 致命封印未置位=fail-fast 静默化(缺陷)→ 显式退出码 2 与致命停机码 1 区分
|
||||
if gen.is_fatal():
|
||||
print("[fatal-fixture] 致命封印已置位(参数无解致命中止),组合根角色据此停机")
|
||||
quit(1)
|
||||
return
|
||||
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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://gxtncmtalosl
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://cq281qsnb0s28
|
||||
@@ -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。
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# 迭代 35 卡① 代拍板追认单(节点图生成器核心批)
|
||||
|
||||
- 日期:2026-10-24
|
||||
- 依据:`reports/需求卡片-节点图生成器核心批-2026-10-21.md` §10 代拍板白名单六条——授权发生在卡片审问阶段,本批只执行预授权并留痕
|
||||
- 待用户动作:追认或推翻。推翻任一条即回写对应实现面(不改设计文档)
|
||||
|
||||
| # | 卡片白名单条目 | 本批拍定值 | 落点 |
|
||||
|---|---|---|---|
|
||||
| ① | RNG 纯流面机制细节 | `pure_stream(seed_val: int, scope: int) -> RandomNumberGenerator`;派生式复用 `_mix64(SINGLE_SALT ^ (\|seed_val\| + scope × GOLDEN_RATIO))`;scope 分域取**单流多次取数**(`SCOPE_STRUCTURE = 0` 一个域,内部按取数序推进 RNG 状态);不推 `_counter`、不落事件日志、不读 `_master_seed`;负种子按 `absi` 归一、`INT64_MIN` 断言拒 | `game/core/rng_service.gd` pure_stream;断言〔fv3〕〔fv10〕 |
|
||||
| ② | 「支路」度量口径 | 支路互斥段 = 自出边起的可达集,减去同岔口其余出边可达集的并集(即「至再汇合/终点止」的独占段);类型构成按该独占段的**多重集**(类型名排序数组)比较;「每支路至少一个别的支路没有的类型」判据 = 该独占段内存在一个类型不出现在任何其他支路的独占段 | `node_map_generator.gd` `_check_fork_rules`;断言〔fv5〕 |
|
||||
| ③ | 生成器内持有结构与句柄赋值序 | 图持有 = `_pool`(Array[Dictionary])+`_by_id`;节点字段恰 `id/type/depth/next`;id 赋值序 = 入口 → 主链段自浅到深 → 各区域自终点倒序回填 → 侧链岔口;`generate` 返回 `{nodes, entries, terminals}` | 同上 `_build`/`_mk`;读面对齐 §7 接口块 |
|
||||
| ④ | 参数档 .tres 行键形态与 provisional 微调 | `rows: Array[Dictionary]`,键 = `level_index`,字段键集 = `ROW_KEYS` 十二键闭集(含 `fork_ways_min/max`);三行取卡片 §8.1 表原值(layers 6/9/13、branch_density .30/.50/.70、配比三档、entry 1/2/2、exit 1/2/3、battle_floor 4/6/8),未做域内微调 | `game/data/schemas/level_gen_params.gd`、`game/data/tables/level_gen_params.tres`;断言〔fv2〕 |
|
||||
| ⑤ | 长图算法具体步骤(HOW) | 单出口图(L1)= 入口→定长链→商店→Boss,链上挂侧链岔口;多出口图(L2/L3)= 入口→主链段→**归属大岔口**(主链段末节点切 e 条区域)→各区域单调链(区域内禁再扩岔);岔口侧链与主去路同深度、汇入主去路的后继(保「深度=最长路径步数」不变量);相邻两层禁同时扩岔(否则上层支路独占段吞掉下层侧链,破契约 5③);分支密度实际生效数 = min(round(bd×层高), 可用锚点数)——种子 42 实测侧支节点数 L1=4/L2=4/L3=3(总节点 11/19/29 减无岔口基线 7/15/26),provisional 期的实现自由面,定值批若要「十几次决策点」须放宽锚点几何) | `node_map_generator.gd` `_build`/`_add_forks` |
|
||||
| ⑥ | 提交切分与分支命名 | 分支 `iter35`(单工作区 `../g-plan2-iter35`);提交三笔=卡① 实现(含断言与 manifest)/fv4 判据三分码修订/致命面体例修正+断言判别力补强 | git log(63a6ea80、a039306f、16c776e9) |
|
||||
|
||||
## 白名单外、本批自认为需要呈报的两件
|
||||
|
||||
1. **致命面形态修正**:卡片 §7 写「push_error+assert/既有 fatal 路径」。实测 assert 在 headless `--script` 下只中断当前调用链、`OS.crash` 会阻塞跑批且无法被断言消费——故按仓库既有体例(`config_db.gd:78-88` Core 置 `_fatal_flag`+push_error,退出决策归组合根 `main.gd:396 _fatal_screen`)实现:`generate` 不合格时置致命封印+断言+**零交付**(不返回任何图),消费侧(卡② 起关路径)按 `is_fatal()` 停机。契约 4 语义(无兜底图、无软回退、不合格不进玩法)不变,只是停机点从生成器内移到组合根。
|
||||
2. **调试面曾加一件方法 `generate_for_debug(params, seed)`**——独立验收审计判定其零调用方=死代码,**本批已删除**(断言读产出规格违例改走公有 `validate_output(graph, params)` 直调,能力验证面仍由卡片 §8 两分法的现役 DebugOverlay/DS_DEBUG 承载)。此条不再请求追认,仅作处置留痕。
|
||||
|
||||
## 遗留读数义务(实现报告同步在册)
|
||||
|
||||
- 用户目检两条未做(登记不阻塞):三关生成图选路观感、生成规模随关卡变大可感知——呈现依赖卡②/卡④ 落地。
|
||||
- MC/对拍零跑:本批无数值改动、未涉核心结算,按红线不发起跑批。
|
||||
@@ -0,0 +1,22 @@
|
||||
# 迭代 35 卡① 反哺点清单(节点图生成器核心批)
|
||||
|
||||
- 日期:2026-10-24
|
||||
- 出处:`reports/需求卡片-节点图生成器核心批-2026-10-21.md` §6 关联文档同步面六条 + 实现中新发现一条设计面事实
|
||||
- 状态:待用户在会话里逐条裁决;裁决后由文档批写进 `design/`,本文件随之退役(doc-conventions §11 吸收即死)
|
||||
- 依据代号沿卡片 §2:D1=关卡节点图生成.md v1.1,D2=关卡节点地图.md v2.1,D3=冒险结构.md
|
||||
|
||||
| # | 文件与节 | 改成什么 | 依据 |
|
||||
|---|---|---|---|
|
||||
| ① | `design/gdd/systems/冒险循环/关卡节点图生成.md` 产出约束节(D1 :41-49 五条之后) | 增一条产出约束「多终点落在互异节点深度」;并在层高条目处成文「节点深度=自入口最长路径步数(多入口取距任一入口的最长路径)」 | 卡① 契约 2;D2 :119「定位信息只有两样(关卡序号+节点深度)」+D1 :96「多终点各配 Boss 行、走哪条路决定打谁」合取——同深度多终点在只有两样定位信息下不可区分;卡③ 契约 6 推论条款 A 的图侧兑现(卡③ §6 反哺点③ 派发) |
|
||||
| ② | `design/gdd/systems/冒险循环/关卡节点地图.md` :65 | 「节点深度=从入口沿路走过来的步数」补注为「自入口最长路径步数」,并说明合流图上为何不能用最短路径 | 卡① §11 推导:最短路径赋值下合流/多入口节点可出现「出边目标深度 ≤ 源」,使无环判据(D1 :51 连线只朝前进方向)失效;实现侧断言 `node_map_generator_test`〔fv1〕已按最长路径口径写死 |
|
||||
| ③ | `design/gdd/systems/冒险循环/冒险结构.md` :293 覆盖义务行 | 登记生成图实际深度域供覆盖核对:**L1 深度 0–6/L2 0–9/L3 0–13**(三档参数档 `layers` = 6/9/13 实测,2026-10-24 卡① 落地读数);覆盖义务措辞换代(精确行→敌侧带命中+板侧解析命中)仍归卡③ 反哺线,本条只补深度域数值 | D3 :293 覆盖义务+卡① §0 交付序列;`game/data/tables/level_gen_params.tres` 现值 |
|
||||
| ④ | `design/gdd/ux/level-map.md` | 登记呈现面随动需求:多入口首跳候选、多终点标记、含岔口图与深度域 0–13 的画布自适应;本批未改任何呈现代码 | 卡① §6 表行(06 条目连带面②);落地归卡④/level-map 文档批 |
|
||||
| ⑤ | `待办清单.md` 06「关卡节点图重构——可选节点太少」条目 | 状态推进为「卡① 已实现待合回;卡② 停工退回(覆盖批未立卡)」,并把第 143 行缺口段更新为「已实现卡① 深度域实测」版本——拟稿见 `reports/iter35-卡2-停机呈报-2026-10-24.md` 第五节 | 实现批对流程清单零改动(AGENTS.md 两类批红线),条目状态只由流水线各段推进 |
|
||||
| ⑥ | `roadmap.md` 迭代 35 行 | 卡片列与状态换代:卡① 标实现批已交付(合回后改「已完成」)、卡② 标停工退回上游;顶部「当前位」三行随之更新 | 卡① §6 表行;roadmap 属文档批职权,本批只列点不落笔 |
|
||||
| ⑦ | `design/gdd/systems/冒险循环/关卡节点图生成.md` 配比与摆放约束节(D1 :74-88) | 成文一条结构上限:出口个数 3 不只是调参表的取值域,而是摆放约束③「每支路至少一个别的支路没有的类型」的天花板——五类节点里商店与 Boss 每条支路必有,可供支路互斥的唯一类型只剩 battle/reward/rest 三类,故一条链上的岔口最多切出 3 条互异支路 | 实现实证:卡① 三档构型中,多终点图必须给每个归属区域指定互斥签名类型才能过支路互异断言(`node_map_generator.gd` REGION_SIGNATURES 三元素);D1 :107 出口 1–3 已锁范围与该推论一致,但文档未写理由 |
|
||||
|
||||
## 与实现差异/缺口逐条自证
|
||||
|
||||
- ①②:文档只写「步数」未写「最长」,实现取最长路径赋值——差异已按契约 2 落码并写断言,属文档缺位。
|
||||
- ⑦:文档「岔路取舍·支路构成」条目在三终点情形下隐含唯一可行解(每区域独占一类),实现按此构型;文档若不加此句,后续调参批可能误配四出口。
|
||||
- 本批未触碰 GDD 任何条款文本,也未新增/修改 `design/` 与两份根流程清单。
|
||||
@@ -0,0 +1,99 @@
|
||||
# 迭代 35 卡① 实现报告(节点图生成器核心批·参数化长图生成器)
|
||||
|
||||
- 日期:2026-10-24
|
||||
- 卡片:`reports/需求卡片-节点图生成器核心批-2026-10-21.md` v1.1
|
||||
- 工作区:独立 worktree `D:/code/g-plan2-iter35`(分支 `iter35`,整迭代单工作区)
|
||||
- 批性质:代码实现批——本批未写、未改、未新增 `design/` 与根规则文件;文档面义务全落本目录(见反哺点清单)
|
||||
- 提交序列:卡① 实现(生成器+参数档+断言+manifest)→ fv4 判据三分码 → 致命面体例修正+判别力补强 → 合流图夹具按契约 2 重写 → 战斗保底判据专测夹具
|
||||
|
||||
## 一、任务卡状态
|
||||
|
||||
| T 卡 | 状态 | 交付面 |
|
||||
|---|---|---|
|
||||
| T0 事实坐实与跨卡接口对齐 | 完成(卡② 侧对齐随停工移交) | §3 F1–F8 行号与 §2 取用条款抽查:六处关键锚点实查命中(`level_runner.gd:513-525` enc 组装、`enemy_table.gd:157-161` band_of、`config_db_cross_validator.gd:100-106` 迭代 34 检查、`node_map_templates_test.gd:32-104` 宿主、`rng_service.gd:34/:86` 双面、`run_all.gd` manifest 体例)——零漂移;接口定稿见 §三 注 1;RNG 纯流面与支路口径定稿见 `reports/iter35-卡①-代拍板-2026-10-24.md` ①② |
|
||||
| T1 参数档数据表 | 完成 | `game/data/schemas/level_gen_params.gd`(LevelGenParams,ROW_KEYS 十二键闭集+validate 域校验+row_by_level_index)、`game/data/tables/level_gen_params.tres`(三行 provisional,照卡片 §8.1 逐行填)、`game/core/config_db.gd`(注册+成员+装载+访问器 `level_gen_params_row()`)、manifest 登记一行 |
|
||||
| T2 生成器核心 | 完成 | `game/core/rng_service.gd` 新增 `pure_stream(seed_val, scope)`(纯派生、不推 `_counter`、不落事件日志);`game/core/node_map_generator.gd`=`generate(params, seed)` 产图+`validate_output(graph, params)` 产出自证+`check_params` 可行性判定+`is_fatal()` 致命封印读面 |
|
||||
| T3 验收断言批总装 | 完成 | 三宿主共 72 条断言:`level_gen_params_test.gd`〔fv2〕17 条、`node_map_generator_test.gd`〔fv1/fv3/fv5/fv6/fv7/fv9/fv10〕39 条、`node_map_growth_test.gd`〔fv4/fv8〕16 条;manifest 三行全登记(闸 02 实跑 58 套件全绿,本批 +3) |
|
||||
| T4 产出物落位 | 完成(实现报告=本文件;代拍板单、反哺点清单、卡② 停工呈报同批) | `reports/iter35-卡①-实现报告-2026-10-24.md`、`reports/iter35-卡①-代拍板-2026-10-24.md`、`reports/iter35-卡①-反哺点-2026-10-24.md`、`reports/iter35-卡2-停机呈报-2026-10-24.md` |
|
||||
|
||||
## 二、完成定义逐条对账(卡片 §1)
|
||||
|
||||
| # | 完成定义 | 现实读数 |
|
||||
|---|---|---|
|
||||
| 1 | 产出图必过五条+铁律+摆放三条 | `generate` 产前强制 `validate_output`;三档 × 20 种子(42–61)扫描自证零错误〔fv1〕 |
|
||||
| 2 | 参数档落地 ConfigDB 单点读、生成器不认关卡号 | 三行 .tres 加载零诊断;`generate(params, seed)` 无形参 level_index;件核=全仓 `generate` 调用点零传关卡号 |
|
||||
| 3 | 确定性(同种子同图、counter 无关、随机只经 RngService) | 同 (seed,params) 两次逐位同图〔fv3〕;两台服务处不同号位(1 vs 999999+7 次取号)产图逐位相同〔fv3〕;`counter()` 前后不变〔fv10〕;生成器内零 `randomize()`/零自建 RNG/零 `open_stream` |
|
||||
| 4 | 生长递增 | 同种子 42 读数:层高 6/9/13,节点总数 11/19/29,Δ(L1→L2)=8 < Δ(L2→L3)=10〔fv8〕 |
|
||||
| 5 | 专项兑现 | L1 出口恒 1、大岔口自 L2 起、下一跳互异、支路构成互异各有独有类型、每路径恰一店、多终点深度互异、战斗保底 ≥ 4/6/8——逐项断言〔fv5/fv6/fv7/fv9〕 |
|
||||
| 6 | 参数无解 fail-fast | `check_params` 拦两类无解夹具(层深 6 放三出口、保底超可放数)〔fv4〕;真实停机通道=致命封印置位→组合根停机(子进程退出码恰 1)〔fv4〕 |
|
||||
| 7 | 验收断言批全绿+变异记录表 | 三套件独立跑全绿(17+42+16 checks,退出码 0);变异记录表见 §四 |
|
||||
| 8 | 批末重闸全绿+产出物写齐 | 〔闸读数见 §五〕;三份 `reports/` 产出物+停工呈报在位 |
|
||||
|
||||
## 三、实际改动—验收对照表(卡片 §8.2 的行号落地)
|
||||
|
||||
> 断言宿主行号取实现当日实测;〔fv#〕尾缀可 grep。
|
||||
|
||||
| 尾缀 | 需求点 | 改动落点 | 验收测试(文件:行) | 真实入口 | 撤销/变异证明 |
|
||||
|---|---|---|---|---|---|
|
||||
| fv1 | 产出合格+产出自证 | `game/core/node_map_generator.gd` `validate_output()`、`_build()` | `node_map_generator_test.gd:88-89`、`:328-377`(正负向夹具) | `generate(params, seed)` 产前强制+`validate_output(graph, params)` 直调 | m4 首写深度→`:361` 红;m5 注掉终点恒 Boss→`:350` 红;夹具级:dup/dangling/enum/回边/合流各命中对应类别 |
|
||||
| fv2 | 参数档 schema+.tres+ConfigDB | `level_gen_params.gd`、`level_gen_params.tres`、`config_db.gd:22/:43/:146/:253` | `level_gen_params_test.gd:16,47,50,55,59`+域负向 `:66-101` | `LevelGenParams.validate()`/`ConfigDB.level_gen_params_row()` | m1 L1 出口改 2→`:16` 红;m2 配比合计 0.5→`:16` 红 |
|
||||
| fv3 | 可复现(纯函数+counter 无关) | `node_map_generator.gd` `generate/_setup_and_build`、`rng_service.gd` `pure_stream` | `node_map_generator_test.gd:99,109,119` | `generate(params, seed)` 同种子两次直调+跨号位双实例对拍 | m3 取数改 `open_stream("node_map")`→`:109` 红 |
|
||||
| fv4 | 执行边界(无兜底图/fail-fast) | `node_map_generator.gd` `check_params()`+`_fatal()`+`is_fatal()` | `node_map_growth_test.gd:64,70,74,91` | 无解参数档调 `generate`;子进程 `-- fatal-fixture` 真实停机通道 | m11 致命短路改 `pass`→`:91` 红(停机码不再是 1) |
|
||||
| fv5 | 岔路取舍两条 | `node_map_generator.gd` `_check_fork_rules()`、`_add_forks()` 双禁相邻扩岔 | `node_map_generator_test.gd:163-164`、`:412` | 产图岔口扫描+同类型下一跳夹具 | m8 侧支类型写死 battle→套件经致命通道转红(`:412` 夹具级判据直证下一跳互异) |
|
||||
| fv6 | 恰一店+归属锁定+多终点深度互异 | `node_map_generator.gd` 区域链/商店位构造、`_check_fork_rules` 独占段 | `node_map_generator_test.gd:228-230`、`:387,397,404` | `validate_output` 前驱/店数/DFS 路径+depth 比对;DFS 全路径枚举 | m6 商店位改 rest、m7 双终点同 depth→套件经致命通道转红;夹具级 `:387/:404` 直证两类判据 |
|
||||
| fv7 | 战斗保底 | `node_map_generator.gd` `_assign_types` 保底优先守卫、`validate_output` 保底判据 | `node_map_generator_test.gd:257`、`:343-346` | 产图战斗计数+合法形状低保底夹具 | m9b 注掉保底判据→`:343` 红;m9 砍穿保底→`:257` 所在套件转红(中止于空图读面) |
|
||||
| fv8 | 生长递增 | `level_gen_params.tres` 三档 provisional 值 | `node_map_growth_test.gd:57-58` | 三档 `generate` 读数对拍(同种子 42) | m10(L3 分支密度归零)NOT-RED——如实标注拦截边界,见 §四 注 |
|
||||
| fv9 | 含岔口与出口结构 | `node_map_generator.gd` `_add_forks` 起始 wanted、大岔口构造 | `node_map_generator_test.gd:285-287` | 三档 × 种子扫描结构计数 | m12 强制零岔口→`:285` 红 |
|
||||
| fv10 | RNG 单点(纯流面) | `rng_service.gd` `pure_stream()` | `node_map_generator_test.gd:313-318` | `pure_stream` 直调+`counter()` 前后读数 | 与 m3 同发注入(counter 不变断言红)+静态扫描 09 零 `randomize()` |
|
||||
| fv11 | 反哺点清单 | 无代码改动(非行为改动) | 件核:`reports/iter35-卡①-反哺点-2026-10-24.md` 七条落点 | T4 产出物件核 | 缺任一落点→件核不收口 |
|
||||
|
||||
注 1(接口定稿对卡② 的裁剪):`generate(params, seed)` **不收 level_index**(契约 6),返回 `{nodes:[{id,type,depth,next}], entries:[int], terminals:[int]}`;参数档字段名 = `layers/branch_density/battle_ratio/reward_ratio/rest_ratio/shop_ratio/entry_count/exit_count/battle_floor/fork_ways_min/fork_ways_max`;两条硬语义(同种子同图、纯函数复现)由〔fv3〕〔fv10〕锁住。卡② 重编时 T0 按本表对齐接线。
|
||||
|
||||
## 四、变异验证记录表(注入→最窄套件见红→`git checkout` 还原)
|
||||
|
||||
| # | 注入 | 期望红点 | 实测 |
|
||||
|---|---|---|---|
|
||||
| m1 | `level_gen_params.tres` L1 `exit_count` 1→2 | fv2 值域 | RED-OK:`生产 level_gen_params.tres 随全表加载 + validate 零违例〔fv2〕` 红 |
|
||||
| m2 | L3 `battle_ratio` .55→.05(合计 ≠1) | fv2 合计 1 | RED-OK:同上断言红 |
|
||||
| m3 | 生成器取数 `pure_stream` → `open_stream("node_map")` | fv3 counter 无关 | RED-OK:`counter 无关(不同号位态同种子同图)〔fv3〕` 红 |
|
||||
| m4 | 最长路径计算 `maxi(...)` → 首写 only | fv1 深度语义 | RED-OK:`合法合流图(第二入口直插主链,深度取最长路 3)自证零错误〔fv1〕` 红 |
|
||||
| m5 | `validate_output` 注掉「终点(无出边)非 Boss」判据 | fv1 | RED-OK:`非 Boss 终点夹具命中「终点(无出边)非 Boss」判据〔fv1〕` 红 |
|
||||
| m6 | 区域商店位 `_mk("shop",…)` → `_mk("rest",…)` | fv6 | 拦截发生(进程红),红点落在空图致命通道(套件在〔fv3〕后中止);判据本体由 `:404` 夹具级断言直证 |
|
||||
| m7 | 终点深度 `L - r` → `L - (r/2)`(两终点同深) | fv6 | 同 m6:进程红;判据本体由 `:387` 夹具级断言直证 |
|
||||
| m8 | 岔口侧支类型修型写死 `battle` | fv5 | 同 m6:进程红;判据本体由 `:412` 夹具级断言直证 |
|
||||
| m9 | 配比填余守卫 `if chain_battles - 1 < floor_n:` → `if False:` | fv7 | 拦截发生(进程红,中止于空图读面)——红点未落在〔fv7〕尾缀,改以 m9b 作判据级证明 |
|
||||
| m9b | `validate_output` 注掉「低于保底」判据 | fv7 | RED-OK:`战斗节点低于 battle_floor 的合法形状夹具命中「低于保底」判据〔fv7〕` 红 |
|
||||
| m10 | L3 `branch_density` 0.7→0.0 求增幅倒挂 | fv8 | **NOT-RED**(实测 L3 仍 27 节点、Δ 未倒挂)——本几何族下「只调分支密度」不足以破坏增幅递增,如实标注为拦截边界;层高/节点数的单调性由参数档 schema 域校验(层深递增且增幅变大)与〔fv8〕三档读数共同承载 |
|
||||
| m11 | `generate` 无解路径 `_fatal(...)` → `pass` | fv4 | RED-OK:`无解参数档置致命封印并由组合根停机(退出码恰 1)〔fv4〕` 红 |
|
||||
| m12 | 岔口数 `wanted` 写死 0 | fv9 | RED-OK:`三关参数档生成图各含 ≥1 岔口节点(第一关即含)〔fv9〕` 红 |
|
||||
|
||||
跑批前后各一遍三套件:BASE/FINAL 均 exit=0 all green(17/42/16 checks)。
|
||||
|
||||
## 五、批末重闸读数
|
||||
|
||||
- 首轮 `tools/run_all_gates.sh --code`(回执 commit `ba660446`):**09 静态扫描 基线绿 → 现红**——新增两类本批违例:S5 core 裸引用 autoload 点标识符 1 处(注释形态)、类型闸 R1 无类型容器 17 处(全在本批新文件)。
|
||||
- 修复=`c0059bc5`(容器声明补参数化或改推断形;注释去 autoload 点引用形态),单件复跑 `bash game/tests/static_scan.sh` → S1–S12 全 ok、`tools/audit_type_hints.py` → PASS 归零。
|
||||
- 二轮全量代码域闸(回执 commit `c0059bc5`):**「验收通过——闸况与基线一致,无新增红」**,退出码 0;未跑面=11 回放确定性对拍(本批 diff 触及 RNG/数据表面,对拍须用户批准发起,列存量红·对基线零新增)。00 冒烟/01 系统测试/02 单元与集成(58 套件,含本批新登记三件)/03 探针 --click/04 探针 --full/05 渲染闸/10 熵增闸均与基线一致。
|
||||
- 死代码清理(审计缺漏⑤)后的三套件与静态扫描复跑读数见 §六末。
|
||||
- **三轮全量代码域闸=本批最终回执**(清理与报告收口笔之后跑,回执 commit `90fd434a` 与分支尖对齐):结论「验收通过——闸况与基线一致,无新增红」,退出码 0;未跑面仍为 11 回放确定性对拍(本批 diff 触及 RNG/数据表形态,对拍须用户批准后设 `PARITY_APPROVED=1` 重跑才实跑)。
|
||||
|
||||
## 六、独立验收审计结论
|
||||
|
||||
审计件:`reports/iter35-卡①-验收审计-2026-10-24.md`(未参与实现的 loop 内子代理,只读卡+对照表+diff+测试,自跑窄测复现撤销证明,不采信本报告自述)。
|
||||
|
||||
- **结论=有条件通过**,无必须退回的不通过项:对照表 fv1–fv11 逐行核过,断言真实存在且尾缀可 grep;真实入口未绕过改动点(`generate(params, seed)` 不收关卡号、`validate_output` 在产图主路径被产前强制调用、`pure_stream` 为生成器唯一取数源,生成器内零 `randomize()`/零自建 RNG/零 `open_stream`);diff 零删除、未触 `design/` 与根规则文件;`level_gen_params.tres` 与卡片 §8.1 逐格对上;manifest 三行 `expect: 0` 齐;本报告对 m6–m10 的 NOT-RED 与红点漂移属**如实呈报**(未写成已拦截)。
|
||||
- 审计独立复现的撤销证明两条:m11(`_fatal(...)` → `pass`)→ 唯一红 `node_map_growth_test.gd:91`〔fv4〕(子进程停机码 1→2),还原后 16 checks 全绿;m4(最长路计算改首写)→ 唯一红 `node_map_generator_test.gd:361`〔fv1〕合法合流图自证,还原后 39 checks 全绿。
|
||||
- 缺漏五项处置:①断言计数(报告原写 42/合计 70,实测 39/72)→ 已改 §一 T3;②fv2 行 ConfigDB 行号漂移(:42/:250 → 实测 :43/:253)→ 已改 §三;③§五闸读数、§六审计结论原为「待补」→ 本节与上节补齐;④`:404`/`:412` 两条夹具断言按错误**类别**判定,存在同类掩蔽源(同类别的另一判据可顶掉专指性)→ 记为已知拦截边界,判据专指性由 m5/m9b 两条「注掉判据即红」的注入补足(已 RED-OK);⑤`generate_for_debug` 零调用方=本批引入的死代码 → 已删除(生产路径只留 `generate`;断言读产出规格违例改走 `validate_output` 公有面直调),代拍板单第 2 条随之撤销。
|
||||
- 清理后复跑读数(死代码删除同批):`level_gen_params_test` all green (17 checks)、`node_map_generator_test` all green (39 checks)、`node_map_growth_test` all green (16 checks),退出码均 0;`bash game/tests/static_scan.sh` → S1/S2/S3/S4/S5/S9/S10/S11/S12 全 ok。
|
||||
|
||||
## 七、实机目检总清单(登记不阻塞)
|
||||
|
||||
1. 三关生成图选路观感:多入口首跳、大岔口取舍、多终点走向可见、第一关即有选择——**依赖卡②/卡④ 落地后才能实机看到**(本批只产图不接运行时)。
|
||||
2. 生成图规模随关卡推进变大(11/19/29 节点、层高 6/9/13)可感知——同上。
|
||||
|
||||
## 八、遗留读数义务与停机面
|
||||
|
||||
- 参数档数值全 provisional;定值批须重跑〔fv8〕与 MC 对拍(本批零数值改动未跑 MC)。
|
||||
- 分支密度实际生效数受构型锚点几何限制:种子 42 实测侧支节点数 L1=4/L2=4/L3=3(总节点 11/19/29 减无岔口基线 7/15/26 之差),与 D1 调参表「一局十几次决策点」的意图有距离——已记入代拍板 ⑤ 与反哺点,定值批若要提密度须放宽锚点几何(属实现自由面,不改产出规格)。
|
||||
- 卡② 停工:其 §0 硬交付序列的「生成图深度扩面覆盖批」未立卡,本批未切运行时图源——详见 `reports/iter35-卡2-停机呈报-2026-10-24.md`。迭代 35 因此未做「一次合并」,合回与否待用户裁拆批。
|
||||
- 生成图深度域实测 L1 0–6/L2 0–9/L3 0–13 已写进反哺点③,供覆盖批立卡时当量程依据。
|
||||
@@ -0,0 +1,130 @@
|
||||
# 迭代 35 卡① 验收审计报告(节点图生成器核心批·合并前独立审计)
|
||||
|
||||
- 日期:2026-10-24
|
||||
- 审计方:独立会话(未参与本批实现)
|
||||
- 被审工作区:`D:/code/g-plan2-iter35`(分支 `iter35`,HEAD = `6074b3c3`,审读开始前 `git status --short` 为空)
|
||||
- 基线:`2931b1f2`(开工时 master)
|
||||
- 依据合同:`reports/需求卡片-节点图生成器核心批-2026-10-21.md` v1.1(§1 完成定义/§4 契约 1–7/§7 任务卡 T0–T4/§8.1 参数档取值表/§8.2 改动—验收对照表/§10 代拍板白名单)
|
||||
|
||||
## 一、审计范围与读数依据
|
||||
|
||||
### 1.1 实际读过的文件
|
||||
|
||||
| 类别 | 文件 |
|
||||
|---|---|
|
||||
| 输入合同 | `reports/需求卡片-节点图生成器核心批-2026-10-21.md`(全文 269 行) |
|
||||
| 被审产出物 | `reports/iter35-卡①-实现报告-2026-10-24.md`、`reports/iter35-卡①-代拍板-2026-10-24.md`、`reports/iter35-卡①-反哺点-2026-10-24.md` |
|
||||
| 实现本体 | `game/core/node_map_generator.gd`(547 行全文)、`game/core/rng_service.gd`(151 行全文)、`game/core/config_db.gd`(本批 diff 段)、`game/data/schemas/level_gen_params.gd`(81 行全文)、`game/data/tables/level_gen_params.tres`(49 行全文) |
|
||||
| 断言面 | `game/tests/unit/core/node_map_generator_test.gd`(445 行全文)、`game/tests/unit/core/node_map_growth_test.gd`(115 行全文)、`game/tests/unit/data_config/level_gen_params_test.gd`(106 行全文)、`game/tests/run_all.gd`(diff 段)、`game/tests/unit/test_kit.gd`(协议语义:check/finish/退出码) |
|
||||
| 闸回执 | `game/tests/.gates/receipt.json`(实现会话批末代码域闸产物,本审计未重跑全量闸) |
|
||||
| 规则源 | 根 `agents.md`(红线)、`tools/run_all_gates.sh`(09 静态扫描=`game/tests/static_scan.sh`) |
|
||||
|
||||
### 1.2 实际跑过的命令与读数
|
||||
|
||||
| # | 命令(均在 `D:/code/g-plan2-iter35` 内执行) | 读数 |
|
||||
|---|---|---|
|
||||
| R1 | `git log --oneline -5` / `git status --short` / `git branch --show-current` | HEAD `6074b3c3`、分支 `iter35`、工作区干净 |
|
||||
| R2 | `git diff --stat 2931b1f2..HEAD -- .` 与 `git diff --name-only 2931b1f2..HEAD -- . ':!game'` | `game/` 内 14 个文件、共 1562 行插入;`game/` 之外只有 `reports/` 四份产出物。**零文件触及 `design/`、`AGENTS.md`、`roadmap.md`、`待办清单.md`、`体验验证清单.md`**(`grep -Ei "design/\|AGENTS\|roadmap\|待办\|体验"` 命中 NONE) |
|
||||
| R3 | `git diff --numstat 2931b1f2..HEAD -- game/` | 14 行删除列全为 `0`——本批对既有代码与既有断言**零删除、零改写** |
|
||||
| R4 | 套件基线跑(逐条串行,每次先 `taskkill //IM Godot_v4.7.2-stable_win64.exe //F`):`--script res://tests/unit/data_config/level_gen_params_test.gd` | 退出码 0,`all green (17 checks)` |
|
||||
| R5 | `--script res://tests/unit/core/node_map_generator_test.gd` | 退出码 0,`all green (39 checks)` |
|
||||
| R6 | `--script res://tests/unit/core/node_map_growth_test.gd` | 退出码 0,`all green (16 checks)`,`[fv4] 无解子进程退出码 = 1` |
|
||||
| R7 | m11 注入复现(见 §二) | 退出码 1,唯一红点 = `〔fv4〕` 组合根停机码断言 |
|
||||
| R8 | m11 还原复跑 | 退出码 0,`all green (16 checks)` |
|
||||
| R9 | m4 注入复现(见 §二) | 退出码 1,`[node_map_generator_test] 1/39 项失败`,唯一红点 = `〔fv1〕` 合法合流图断言 |
|
||||
| R10 | m4 还原复跑 | 退出码 0,`all green (39 checks)`,工作区回到干净 |
|
||||
| R11 | 件核 grep:`grep -n "randomize(\|RandomNumberGenerator.new(\|open_stream(\|FileAccess" game/core/node_map_generator.gd` | **零命中**(连注释都没有取数面引用);`grep -n level_index` 只命中第 33 行注释「不收 level_index」 |
|
||||
| R12 | 件核 grep:全仓 `\.generate(` 非测试调用点 | 只命中 `board_generator.generate(...)`(另一类);`NodeMapGenerator` 的引用面 = 自身+`run_all.gd` manifest+两套新测试,**零生产消费方**(本批不接运行时,与卡片 §5 一致) |
|
||||
|
||||
### 1.3 批末代码域闸回执读数(未重跑,只核读)
|
||||
|
||||
`game/tests/.gates/receipt.json`:`commit = c0059bc5`、`domain = code`、`all_green = true`、`changed_vs_baseline = []`、`inherited_red = []`;闸 00/01/02/03/04/05/09/10 全 exit 0,闸 11(回放确定性对拍)= `NEED-APPROVAL` 未跑,计入 `not_run`——与「档位须用户发起」红线一致,不算新增红。**注意:实现报告 §五「批末重闸读数」仍是「待补」占位,回执读数没有回填进报告(见 §四 缺漏 3)。**
|
||||
|
||||
## 二、A 表:改动—验收对照表逐行核对(实现报告 §三 fv1–fv11)
|
||||
|
||||
每行三问:① 断言在测试文件里真实存在(file:line 已 grep);② 「真实入口」列确实走到被改代码;③ 「撤销或变异证明」在报告 §四 有对应注入记录。
|
||||
|
||||
| 尾缀 | ① 断言真实存在 | ② 真实入口核实 | ③ 变异记录对应 | 判定 |
|
||||
|---|---|---|---|---|
|
||||
| **fv1** 产出合格+产出自证 | 存在:`node_map_generator_test.gd:88,89`(三档×20 种子自证+入口/终点计数)、`:328,332,336,350,361,370,377`(违规夹具与合流夹具) | `generate()` 在 `node_map_generator.gd:47` 无条件调用 `validate_output(built, params)`,不过即 `_fatal` 且零交付(`:48-52`)——**产前强制成立,不是只在测试里直调**;`:82-83` 走 `_graph()`(真 `generate`)产图后再直调一次自证做二次核对 | m4(首写深度)→ 报告记 `:361` 红、m5(注掉终点恒 Boss 判据)→ 记 `:350` 红;**m4 本审计已实跑复现,红点逐字命中 `:361`** | **通过** |
|
||||
| **fv2** 参数档 schema+.tres+ConfigDB | 存在 17 条:`level_gen_params_test.gd:16,47,50,52,55,62,72,75,78,81,85,88,92,97,100,102,105` | `:16` 走 `db.load_tables("res://data/tables", true)`=生产装载真入口;访问器 `config_db.gd:253 level_gen_params_row()` 实存并被 `:47/50/52` 与测试侧 `_params()`(`node_map_generator_test.gd:38`)消费;schema 直调 `validate()` 是卡片 §8.2 自身指定的入口(数据层无更上层入口),非降级 | m1/m2 → 报告记 `:16` 红 | **通过**(报告 §三 行的落点行号 `config_db.gd:42/:250` 与 HEAD 实值 `:43/:253` 漂移,`/22/146` 正确——见 §四 缺漏 2) |
|
||||
| **fv3** 可复现(纯函数+counter 无关) | 存在:`:99`(同种子两次逐位同图)、`:109`(跨号位双实例对拍)、`:119`(异种子异图,10 种子去重 ≥8) | 三条全部经 `_graph()`→`NodeMapGenerator.generate(params, seed)`;`:101-111` 用两台服务处不同号位(`new_adventure(1)` vs `new_adventure(999999)`+7 次 `open_stream`)各建新生成器对拍——counter 无关确实从生产产图面进入 | m3(取数改 `open_stream("node_map")`)→ 报告记 `:109` 红 | **通过** |
|
||||
| **fv4** 执行边界(无兜底图/fail-fast) | 存在:`node_map_growth_test.gd:64,70,74,82,91` | 静态面 `:64/70/74` 直调 `NodeMapGenerator.check_params()`;`:79-91` 用 `OS.execute` 起真实子进程跑 `-- fatal-fixture`,夹具 `_trigger_fatal` 经 `gen.generate(unsolvable, 42)` 真入口产图,再由 `is_fatal()` 决定停机码——审计实跑读数 `[fv4] 无解子进程退出码 = 1`,通道真实 | m11(`_fatal(...)`→`pass`)→ 报告记 `:91` 红;**本审计已实跑复现** | **通过** |
|
||||
| **fv5** 岔路取舍两条 | 存在:`:163`(下一跳类型互异)、`:164`(支路构成互异+各有独有类型)、`:412`(同类型下一跳夹具)、`:162`(每档每种子的岔口存在性静默条) | `:127-161` 对 `generate` 产图逐岔口独立重算(不复用 `validate_output` 的实现),夹具面 `:412` 直调 `validate_output`;测试侧 `_branch_types()`(`:168-194`)与实现 `_check_fork_rules` 的独占段口径一致(代拍板 ②) | m8(侧支类型写死 battle)→ 报告如实记「进程红、红点落空图致命通道,判据本体由 `:412` 夹具级直证」 | **通过**(判别力短板见 §四 缺漏 4) |
|
||||
| **fv6** 恰一店+归属锁定+多终点深度互异 | 存在:`:228`(DFS 全路径枚举恰一店)、`:229`(每终点前一位恰一店+店数=终点数)、`:230`(多终点异深)、夹具 `:387,397,404` | 三条主断言走 `generate` 产图+测试自身 `_all_paths()` 枚举(独立实现,非套用被测函数);`:213-221` 另在测试侧重算店数与前驱类型 | m6(商店位改 rest)、m7(两终点同深)→ 报告如实记「进程红,红点落空图致命通道」,未写成 fv6 尾缀专红 | **通过**(判别力短板见 §四 缺漏 4) |
|
||||
| **fv7** 战斗保底 | 存在:`node_map_generator_test.gd:257`(三档×10 种子计数)、`:343`(低保底合法形状夹具必拒)、`:345`(保底 1 下不误伤) | `:255` 经 `_graph()` 真入口产图计数;`:343/345` 是 `ba660446` 专门为「让判据可被变异拦下」补的正反夹具,一拒一不误伤,判据按消息文本「低于保底」专断 | m9(砍穿保底)报告如实记「红点未落〔fv7〕尾缀、中止于空图读面」,并以 **m9b**(注掉判据)作判据级 RED-OK 记 `:343` 红 | **通过** |
|
||||
| **fv8** 生长递增 | 存在:`node_map_growth_test.gd:57`(步数逐关变大)、`:58`(节点总数递增且增幅变大)+`:43-52` 五种子静默逐档读数 | `_reading()`(`:31`)经 `gen.generate(db.level_gen_params_row(L), 42)` 真入口,参数档从生产表读回,非硬编码 | **m10 = NOT-RED**,报告在 §三 与 §四 两处均明写「NOT-RED/如实标注拦截边界」,未冒充已拦截 | **通过**(拦截边界已如实呈报,见 §四 缺漏 5) |
|
||||
| **fv9** 含岔口与出口结构 | 存在:`node_map_generator_test.gd:285`(三档各含 ≥1 岔口)、`:286`(L1 终点恰 1)、`:287`(L2/L3 含分区域大岔口) | 全走 `generate` 产图结构扫描+测试自身 BFS `_terminals_reachable()` | m12(`wanted` 写死 0)→ 记 `:285` 红 | **通过** |
|
||||
| **fv10** RNG 单点(纯流面) | 存在:`node_map_generator_test.gd:313`(产图前后 counter 同值)、`:316`(同 (seed,scope) 同流)、`:317`(不推计数器且产图非空) | `:308-318` 真实例化 `RngServiceCore` 注入生成器、走 `generate` 产图后读 `counter()`;`pure_stream` 直调亦存在。静态面:R11 grep 在 `node_map_generator.gd` 内零命中 `randomize(`/`RandomNumberGenerator.new(`/`open_stream(`,生成器唯一取数点为 `:66 _rng_service.pure_stream(seed_val, SCOPE_STRUCTURE)`;闸 09 静态扫描回执 exit 0 | 与 m3 同发注入(报告记 `:109` 红) | **通过** |
|
||||
| **fv11** 反哺点清单(非行为改动) | 件核对象存在:`reports/iter35-卡①-反哺点-2026-10-24.md` 七条表行 | 卡片 §6 六条逐格对上:①生成规格(多终点异深+最长路径赋值)②地图 :65 消歧③冒险结构 :293 深度域登记④level-map 呈现随动⑤待办清单 06 状态⑥roadmap 编入笔;另加实现新发现⑦(出口数 3 是支路互异天花板)。每条有「文件与节/改成什么/依据」三列 | 纯文档产出物,无代码变异,报告与卡片均标「非行为改动」 | **通过** |
|
||||
|
||||
**契约逐条复核(与 A 表交叉)**:契约 1(纯函数+counter 无关+纯流面)=fv3/fv10 过;契约 2(id/depth 拆两轴+最长路径赋值+多终点异深)=节点字段恰 `id/type/depth/next`(`_mk:414`)、`validate_output:174-179` 逐节点比对最长路值、`:219-224` 异深判据,过;**契约 6(`generate` 不收 level_index)**=签名 `generate(params: Dictionary, seed_val: int)`(`:34`),全仓调用点零传关卡号(R12),过;契约 4(无兜底图)=生成器内零模板/零上一张/零空图回退分支,三处 `return {}` 全在 `_fatal` 之后(`:41/45/52`,注释明写「致命中止后不可达;本行即无残图交付的显式证明」),过。
|
||||
|
||||
## 三、B 段:撤销证明复现记录(2 条,真跑)
|
||||
|
||||
前置:`git status --short` 空;`node_map_generator.gd` 为 UTF-8、547 行全 CRLF(注入后仍 547 CRLF,行尾未被工具改写)。
|
||||
|
||||
### B1 · m11 无解路径致命短路
|
||||
|
||||
- 锚点注入(`game/core/node_map_generator.gd:40`,唯一命中,1 行改):
|
||||
- 原文:`_fatal("参数无解致命中止(契约 4——无兜底图、禁软回退)")`
|
||||
- 注入:`pass # m11 mutation injection`
|
||||
- 跑:`--script res://tests/unit/core/node_map_growth_test.gd` → **退出码 1**
|
||||
- 红点读数(逐字):
|
||||
- `[fv4] 无解子进程退出码 = 2`(原为 1)
|
||||
- `ERROR: [FAIL] 无解参数档置致命封印并由组合根停机(退出码恰 1,无残图交付)〔fv4〕`
|
||||
- 同批其余三条〔fv4〕(`:64/70/74`)仍 PASS——红点精确落在子进程停机通道那条,未泛化。
|
||||
- 还原:`git checkout -- game/core/node_map_generator.gd`(HEAD 已含实现,安全)→ `git status --short` 空
|
||||
- 还原复跑:**退出码 0,`all green (16 checks)`**
|
||||
- 结论:**与报告 §四 m11 记录逐字一致,RED-OK 复现成立。**
|
||||
|
||||
### B2 · m4 最长路径计算改首写
|
||||
|
||||
- 锚点注入(`node_map_generator.gd:440`,唯一命中,1 行改):
|
||||
- 原文:`out[nid] = maxi(int(out.get(nid, 0)), int(out[id]) + 1)`
|
||||
- 注入:`if not out.has(nid): out[nid] = int(out[id]) + 1 # m4 mutation injection`
|
||||
- 跑:`--script res://tests/unit/core/node_map_generator_test.gd` → **退出码 1**
|
||||
- 红点读数(逐字):`ERROR: [node_map_generator_test] 1/39 项失败`,唯一 FAIL 行 =
|
||||
- `ERROR: [FAIL] 合法合流图(第二入口直插主链,深度取最长路 3)自证零错误〔fv1〕`
|
||||
- 首写口径使合流节点取到短路值(3→1),与 `:174-179` 最长路比对不符 → 自证报错,正是报告 §四 m4 声明的红点位置。
|
||||
- 关键旁证:三档×20 种子主自证〔fv1〕`:88` 仍 PASS(生成器构型无合流,取长/取首等价)——说明这条断言拦的正是「合流图深度语义」,不是泛泛报错。
|
||||
- 还原:`git checkout -- game/core/node_map_generator.gd` → 工作区干净
|
||||
- 还原复跑:**退出码 0,`all green (39 checks)`**
|
||||
- 结论:**与报告 §四 m4 记录逐字一致,RED-OK 复现成立。**
|
||||
|
||||
## 四、C 段:测试削弱检查(逐条给依据)
|
||||
|
||||
| 检查项 | 结论 | 依据 |
|
||||
|---|---|---|
|
||||
| 旧断言删改 | **零削弱** | `git diff --numstat` 14 个文件删除列全为 0;`run_all.gd`+3 行、`config_db.gd`+8 行、`rng_service.gd`+14 行(`pure_stream` 纯新增,`open_stream/derive_at` 未触);三套测试全为新建文件——与卡片 §8.2「零旧断言改写」预声明一致 |
|
||||
| manifest 登记(未登记=闸 02 不执行) | **齐且口径正确** | `run_all.gd` 三行新增均为 `"expect": 0`,路径与盘上文件一一对应:`tests/unit/data_config/level_gen_params_test.gd`、`tests/unit/core/node_map_generator_test.gd`、`tests/unit/core/node_map_growth_test.gd`;三套件单跑退出码实测均 0(R4/R5/R6),与 expect 0 自洽 |
|
||||
| 参数档数值 vs 卡片 §8.1 | **逐格对上,无放宽** | `level_gen_params.tres`:layers 6/9/13 ✓、branch_density 0.3/0.5/0.7 ✓、配比 .50/.20/.20/.10|.50/.20/.15/.15|.55/.15/.15/.15(各合计 1)✓、entry 1/2/2 ✓、exit 1/2/3 ✓、battle_floor 4/6/8 ✓、fork 2/3 三档 ✓;代拍板 ④ 自陈「取卡片原值、未做域内微调」与实物一致 |
|
||||
| 扫描面种子数是否被缩小 | **未见对卡片的缩小;有一处对报告自述的漂移** | 卡片 §8 V1/T3 措辞=「三关参数档×固定种子」,未规定种子条数。实测主自证面 `node_map_generator_test.gd:81 range(42, 62)`=42–61 共 **20 种子 × 3 档**,与报告 §二#1「三档 × 20 种子(42–61)」一致;其余面:fv5/fv6/fv9 各 3 种子〔42,43,44〕、fv7 10 种子、fv8 5 种子、fv3 异图 10 种子——均落在卡片授权面内。**漂移在报告侧**:报告记该套件 42 条、实测 39 条(见缺漏 1) |
|
||||
| 生成失败的回退分支(契约 4 禁) | **零回退分支** | 生成器不引用 ConfigDB/`level.tres`/模板池(R11/R12 grep 零命中),无「失败用模板/用上一张」路径;`_fatal` 后三个 `return {}` 是停机后的不可达行、并由 `is_fatal()` 要求消费侧拒收;本批**无任何生产调用方**取用产图(`NodeMapGenerator` 引用面=自身+manifest+两测试),故 `{}` 不存在被当合法图消费的现实通道 |
|
||||
| `--script` 禁裸 autoload(F8/code-conventions:71) | 合规 | 生成器 `_init(rng_service = null)` 构造注入,未注入即 `push_error`+致命(`:42-45`);测试侧 `RngScript.new()` 显式实例化后注入 |
|
||||
| NOT-RED 与「红点落别处」是否如实呈报 | **如实** | 报告 §四 m10 明写 `**NOT-RED**` 并在 §三 fv8 行再次标注;m6/m7/m8/m9 明写「拦截发生(进程红),红点落在空图致命通道/未落〔fv7〕尾缀」,并各自改以夹具级断言(`:404/:387/:412`)或 m9b 作判据级证明——四条**均未被写成 RED-OK/已拦截**,符合「无变异红记录只写已实现、拦截能力未证」红线 |
|
||||
| 像素纪律/数值单点 | 合规 | 本批无像素结构件;玩法数值只落 `.tres`,生成器不硬编码旋钮值(全部读 `params`)。实现自由面常量(`MAX_WIDTH=4`、三岔概率 `0.35`、`_trunk_len` 取 `L/2`)属 HOW 面,卡片 §10 ⑤ 授权、报告 §八与代拍板 ⑤ 已登记其副作用(分支密度实际生效数受锚点几何限制) |
|
||||
|
||||
### 缺漏与待办(审计判定,非红线违例)
|
||||
|
||||
1. **实现报告断言计数不符**:报告 §一 T3 与 §二#7 记 `node_map_generator_test` 42 条、合计 17/42/16;HEAD 实测 **39 条、合计 17/39/16 = 72 条**(另 `63a6ea80` 提交信息记 68 条,说明该数在批内漂移后未回填)。合回前须把报告读数订正为实测值。
|
||||
2. **实现报告 §三 fv2 行的落点行号漂移**:记 `config_db.gd:22/:42/:146/:250`,HEAD 实值 `:22/:43/:146/:253`。
|
||||
3. **实现报告 §五(批末重闸读数)与 §六(审计结论)仍为「待补」占位**:完成定义 #8 要求重闸读数入报告;回执已在盘(`c0059bc5`/`domain=code`/`all_green=true`/闸 11 按红线未跑),须把这段读数抄进 §五,本报告可作 §六 依据。
|
||||
4. **三条夹具级断言按错误类别判定、判别力弱于消息级**:`:404`(终点前驱非店)与 `:412`(岔口同类型下一跳)用 `_has_cat(..., "structural")`——若只注掉该条判据、同夹具另有其它 structural 违例,断言可能仍绿(`:404` 的夹具确实同时触发「店数≠终点数」「穿店数≠1」两类,存在掩蔽源);`:387`(多终点同深)经逐节点推演无同类掩蔽源,专指成立。对照:`:350`(终点非 Boss)与 `:343`(低于保底)已按消息文本专断,是应当效仿的体例。**不阻塞合回**——m5/m9b 已证这两条判据可被拦,m6/m7/m8 已从真实 `generate` 入口证得套件级拦截。
|
||||
5. **m10 拦截边界真实存在**:「只调分支密度」不足以破坏节点总数增幅递增。报告已把单调性交给参数档域校验(层深递增且增幅变大,`level_gen_params.gd:64-71`+`:85` 断言)与 fv8 三档读数共同承载,属如实标注,不是漏判。
|
||||
6. **新增旁路入口 `generate_for_debug`(`:58-61`)当前零调用方**:只构型、不自证、不致命。卡片 §8 两分法授权调试接口,但代拍板单把它列为「白名单外需呈报第 2 件」待用户追认;本批无运行时接线故不构成「不合格产出进玩法」通道。卡② 接线时须保持生产路径只走 `generate`。
|
||||
7. **代拍板六条+白名单外两件待用户追认**(致命面形态从「生成器内停机」改为「置封印+组合根停机」,fv4 判据随之改为「停机码恰 1」)——语义等价、有断言兜住,但追认动作未完成。
|
||||
|
||||
## 五、结论
|
||||
|
||||
**有条件通过(可以合回,条件是下列三件在合回动作前后立即补齐,无需重跑实现)**:
|
||||
|
||||
1. 实现报告 §五 回填批末代码域闸读数(回执 `c0059bc5`:domain=code、all_green=true、闸 11 按红线未跑)、§六 挂本审计报告,并把断言计数由 42 订正为实测 39(合计 72)。
|
||||
2. 实现报告 §三 fv2 行的 `config_db.gd` 行号按 HEAD 实值订正(`:43/:253`)。
|
||||
3. 用户对 `reports/iter35-卡①-代拍板-2026-10-24.md` 六条+白名单外两件(致命面形态、`generate_for_debug`)作追认或推翻;推翻则回写实现面。
|
||||
|
||||
判定依据:卡片 §1 完成定义八项逐条有真实入口断言兜住(§二 A 表全通过);两条撤销证明(m11/m4)独立复现且红点逐字命中报告声明;本批零删改既有代码与既有断言、零 `design/` 与根规则文件触碰、参数档数值与卡片 §8.1 逐格一致、无回退分支、RNG 单点静态核零命中;NOT-RED 与红点漂移均如实呈报。
|
||||
|
||||
**不通过项清单**:无(不存在必须退回实现或上游的缺漏;上述三件为产出物回填与用户裁决动作,非事实性缺陷)。
|
||||
|
||||
**另须留意(非本批验收范围)**:卡② 因 §0 硬交付序列的「生成图深度扩面覆盖批」未立卡而停工(`reports/iter35-卡2-停机呈报-2026-10-24.md`),迭代 35 因此不是一次合并——卡① 可否单独合回属用户拆批裁决,本审计只判定卡① 代码面验收闭合。
|
||||
Reference in new issue
Block a user