Files
gplan-2/game/tests/integration/node_map_determinism_test.gd
T

148 lines
8.6 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# tests/integration/node_map_determinism_test.gd — L2 集成用例:关卡节点图生成·确定性承载
# 断言面 = 生成图确定性套(批 B 断言迁移自 smoke_all.gd,G12 施工组;
# 主会话裁决六整套集成不拆——跨四系统且走落盘)。
# 跨系统面 = RngService(纯派生流零计数器占用)×
# LevelRunner(start_level/start_level_external 受理·生成图装配·单一图源)×
# AdventureRunner(开局种子承载·断点写读后同图)×
# SaveManager+MetaProgression(首次冒险布尔位收束置位落盘——真实档写盘)。
# 与单元侧同入口同协议:extends SceneTree+Kit.check 主通道;--script 模式无 autoload,
# ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断展开,红由具体断言落)。
# 运行:godot --headless --path game --script res://tests/integration/node_map_determinism_test.gd
extends SceneTree
const Kit := preload("res://tests/unit/test_kit.gd")
const SaveManagerScript := preload("res://core/save_manager.gd")
const AdventureRunnerScript := preload("res://core/adventure_runner.gd")
var _db
var _bp_path := "user://smoke_node_map_save.json"
func _init() -> void:
_db = load("res://core/config_db.gd").new()
# 表加载失败不中止(承 G2/G3/G4/G5 同款:表损坏的红落在具体断言上)
var load_ok: bool = _db.load_tables("res://data/tables", true)
if not load_ok:
push_error("[node_map_determinism_test] 表加载返回失败——诊断如下(红由具体断言落)")
for d in _db.diagnostics():
if d.severity == "error" or d.severity == "fatal":
push_error(" [%s] %s: %s" % [d.severity, d.category, d.message])
_smoke_generated_map_determinism()
quit(Kit.finish("node_map_determinism_test"))
# ---- 关卡节点图生成·确定性承载断言面 ----
func _smoke_generated_map_determinism() -> void:
# ① 纯函数复现:图 = f(参数档, 种子)——同档同种子两次产图逐位同图,换种子必变图
var gen = NodeMapGenerator.new(_fresh_rng(910001))
var params2: Dictionary = _db.level_gen_params_row(2)
var g_a: Dictionary = gen.generate(params2, 910002)
var g_b: Dictionary = gen.generate(params2, 910002)
var g_c: Dictionary = gen.generate(params2, 910003)
var nodes_a: Array = g_a.get("nodes", [])
Kit.check("同参数档同种子两次产图逐位同图(D2 执行边界)〔v7〕",
nodes_a == (g_b.get("nodes", []) as Array))
Kit.check("同参数档换种子即变图(图确为种子函数,非恒定输出)",
nodes_a != (g_c.get("nodes", []) as Array))
Kit.check("产图节点携图内唯一句柄 id 与自入口 depth(消费侧寻址面)",
_not_empty_graph(nodes_a) and _handles_unique(nodes_a))
# ② 消费侧单一图源:起关装配的图与生成器直取逐位一致(runner 内不存第二份图数据)
var runner1 = load("res://core/level_runner.gd").new(_db, _fresh_rng(910004))
Kit.check("单关起关受理(种子 910004——生成图直供,无兜底图)",
runner1.start_level(2, 910004))
Kit.check("起关生效图 = 生成器同档同种子直取逐位一致(消费侧无第二图源)〔v7〕",
runner1.level_row.get("nodes", []) == (gen.generate(params2, 910004) as Dictionary).get("nodes", []))
# ③ 产图零统一流消费(纯派生流不占计数器号位——断点在任意号位重生成同图的机制承载)
var rng_c = _fresh_rng(910005)
var counter_before: int = rng_c.counter()
var runner2 = load("res://core/level_runner.gd").new(_db, rng_c)
Kit.check("冒险模式起关受理(入口停点)", runner2.start_level_external(3,
{"hero_hp": 20, "gp": 0}, {}, Callable(self, "_smoke_rest_stub"), {}, true))
Kit.check("起关产图零计数器消费(同图不依赖取数号位)", rng_c.counter() == counter_before)
var params3: Dictionary = _db.level_gen_params_row(3)
var entry_count: int = int(params3.get("entry_count", -1))
Kit.check("入口停点候选数=参数档入口个数(L3 档 %d——多入口首跳即选路,禁写死单入口)〔v2〕" % entry_count,
entry_count >= 1 and (runner2.next_candidates() as Array).size() == entry_count)
if entry_count >= 2:
var eids: Array = runner2.next_candidate_ids()
Kit.check("第二入口经句柄显式进入(句柄 %d)〔v2〕" % int(eids[1]),
runner2.pick_next_node(int(eids[1])) and int(runner2.current_node_id()) == int(eids[1]))
# ④ 开局种子承载:新档(首局)种子 42 两次开局逐位同图,且与生成器直取一致
DirAccess.remove_absolute(_bp_path) # 测试隔离——上轮落盘残留先删,断点面从零档起
var sv_w = SaveManagerScript.new(_bp_path)
var adr_x = AdventureRunnerScript.new(_db, _fresh_rng(42))
adr_x.set_save_manager(sv_w) # 先挂档再开局——停点快照由真实写手落盘
Kit.check("首局口径种子 42 开局受理(defer_entry 路径)", adr_x.start_adventure(42,
{"hero_override": {}, "treasure_unlocked": {}}))
var adr_y = AdventureRunnerScript.new(_db, _fresh_rng(42))
Kit.check("同种子第二次开局受理", adr_y.start_adventure(42,
{"hero_override": {}, "treasure_unlocked": {}}))
Kit.check("种子 42 两次开局图逐位同图(教学局恒同图——首局固定种子的确定性承载)〔v7〕",
adr_x.runner().nodes() == adr_y.runner().nodes())
Kit.check("开局图 = L1 参数档在种子 42 下的生成图(开局无隐式取数分叉)",
adr_x.runner().nodes() == (gen.generate(_db.level_gen_params_row(1), 42) as Dictionary).get("nodes", []))
# ⑤ 断点写→恢复→再产图同图(续玩按主种子+参数档重生成,断点不存图快照)
Kit.check("停点断点落盘在档", sv_w.has_breakpoint())
var bp: Dictionary = sv_w.breakpoint_snapshot()
var adr_z = AdventureRunnerScript.new(_db, load("res://core/rng_service.gd").new())
Kit.check("断点续玩受理(代差标记键在档)〔v8〕", adr_z.resume_adventure(bp,
{"hero_override": {}, "treasure_unlocked": {}}))
Kit.check("续玩重生成图与中断前逐位同图〔v7〕", adr_z.runner().nodes() == adr_x.runner().nodes())
var adr_old = AdventureRunnerScript.new(_db, load("res://core/rng_service.gd").new())
var bp_old: Dictionary = bp.duplicate(true)
bp_old.erase("graph_gen_v2") # 换代前旧断点形态(模板池时代无代差标记键)
Kit.check("缺生成图代差标记键的旧断点被显式拒绝(禁按新语义静默回放)〔v8〕",
adr_old.resume_adventure(bp_old, {}) == false)
# ⑥ 关卡表不再携带图数据(图源换代后 level 行只剩行级字段)
var tpl_free := true
for li in range(1, _db.level_count() + 1):
if (_db.level_row(li) as Dictionary).has("templates"):
tpl_free = false
Kit.check("level 表三行均无 templates 键(模板数据已清出)〔v9〕",
tpl_free and _db.level_count() == 3)
# ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等)
var sv = SaveManagerScript.new(_bp_path + ".flag.json")
sv.load_from_disk()
Kit.check("新档首次冒险布尔位 = false(默认域——首局恒走固定种子 42)",
sv.is_first_adventure_done() == false)
var mp = load("res://core/meta_progression.gd").new(_db, sv)
mp.settle_adventure({"levels_cleared": 1, "keystone_total": 10, "cleared": false, "result": "failed"})
Kit.check("冒险收束点置位(三结局同写——写入只在收束挂点)", sv.is_first_adventure_done() == true)
mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"})
Kit.check("置位幂等(重复收束不翻转)", sv.is_first_adventure_done() == true)
DirAccess.remove_absolute(_bp_path)
DirAccess.remove_absolute(_bp_path + ".flag.json")
# 图数据良构前置(防空集假绿——节点数 > 1、句柄唯一、depth 与 next 键在位)
func _not_empty_graph(nodes: Array) -> bool:
if nodes.size() < 2:
return false
for node in nodes:
var n := node as Dictionary
if not n.has("id") or not n.has("depth") or not n.has("next"):
return false
return true
func _handles_unique(nodes: Array) -> bool:
var seen := {}
for node in nodes:
var id: int = int((node as Dictionary).get("id", -1))
if id < 0 or seen.has(id):
return false
seen[id] = true
return true
func _fresh_rng(seed_val: int):
var r = load("res://core/rng_service.gd").new()
r.new_adventure(seed_val)
return r
# ---- 恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位;
# smoke_all.gd helper 的宿主自持私有副本——源文件收尾整体删除,宿主自给自足)----
func _smoke_rest_stub(pct: int, hp: int, max_hp: int) -> int:
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)