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:
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
|
||||
Reference in new issue
Block a user