feat(迭代35卡①): 节点图参数化生成器核心批落地——五旋钮参数档 schema+.tres 三行 provisional+ConfigDB 注册与 level_gen_params_row 单点读;RngService 新增 pure_stream 纯流面(不推计数器不落日志,derive_at 派生面流化);NodeMapGenerator 产图 generate(params,seed)+产出侧自证 validate_output(产出约束五条/结构铁律/摆放约束三条/多终点深度互异/深度=自入口最长路径步数)+参数无解致命中止无兜底图;三套断言宿主 68 条(fv1–fv10)+run_all manifest 三行登记;生长读数 L1/L2/L3 = 11/19/29 节点、层高 6/9/13。卡② 停工呈报随批(§0 硬交付序列——覆盖批未立卡)

This commit is contained in:
DKChan committed 2026-10-24 05:40:04 +08:00
1 parent 2931b1f250
commit 63a6ea8055
15 files changed
+1372

No files matched your search

@@ -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
@@ -0,0 +1 @@
uid://cxxjxju8g6txd
@@ -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()
@@ -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