feat(m3-iter5-T5): 关卡节点图生成特性批·台阶一全落地——15张骨架(3关×5,模板0=现行图回滚档;L1单链排列变体/L2L3网状图变体,战斗id恒∈遭遇表行集,商店必经位承袭)+node_map扩签(标签不参与派生只进日志,黄金向量零动)+首次冒险固定脚本(存档布尔位唯一新增域,老档迁移=true,收束点置位幂等)+池内全模板加载校验(validate抽出与跨表校验模板感知)+MC分层出数(RUNSUM level_templates逐节点模板名,抽查五模板全被抽中);冒烟558→576(新增17断言:池参数/零scope强制/抽签确定性/N=1等价/首冒分支/收束置位幂等);生成器tools/t5_build_templates.py含生成前自校验

This commit is contained in:
DKChan committed 2026-09-28 23:20:00 +08:00
1 parent 8ffc5552f1
commit 3f73e16366
14 files changed
+471 -207

No files matched your search

+1
View File
@@ -67,6 +67,7 @@ func _initialize() -> void:
"encounter_max_hp_seq": st["encounter_max_hp_seq"],
"reward_events": st["reward_events"],
"shop_gp_seq": st["shop_gp_seq"],
"level_templates": st.get("level_templates", []),
"wallet_count": (st["wallet_events"] as Array).size(),
}
var rs := String(sum.get("result", ""))
+9 -3
View File
@@ -25,7 +25,7 @@ extends RefCounted
const AutoPlayer := preload("res://smoke/auto_player.gd") # O4 收口:自动玩家策略单一实现
static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: int, collect_rows: bool = false, logger = null, policy: String = "normal", route: String = "min") -> Dictionary:
static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct: int, collect_rows: bool = false, logger = null, policy: String = "normal", route: String = "min", template_pick: int = -1) -> Dictionary:
var own_session := logger == null
if own_session:
logger = load("res://presentation/playtest_logger.gd").new()
@@ -47,7 +47,7 @@ static func run_level(db, seed_val: int, level_index: int, atk_pct: int, hp_pct:
runner.node_entered.connect(_on_node_entered.bind(runner, st, logger))
runner.level_finished.connect(_on_level_finished.bind(st, logger))
runner.set_hero_override(atk_pct, hp_pct)
if not runner.start_level(level_index, seed_val):
if not runner.start_level(level_index, seed_val, {}, template_pick):
st.result = "start_failed"
runner.restore_hero_base()
if own_session:
@@ -118,6 +118,8 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_
"visited_ids": [], "choices": 0, "last_battle_enemies": [],
"summary": {}, "wallet_events": [], "encounter_max_hp_seq": [], "reward_events": [],
"shop_gp_seq": [], # 商店房到达余额序(逐关 1 次——GP 跨关延续验收取数面)
"level_templates": [], # 逐关图模板名(关卡节点图生成 v0.4 分层出数)
}
adr.step_row.connect(_on_step_row.bind(adr, st, collect_rows))
adr.node_entered.connect(_on_adventure_node_entered.bind(adr, st, logger))
@@ -125,7 +127,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_
adr.big_reward_paid.connect(_on_adventure_reward.bind(st, adr))
adr.adventure_finished.connect(_on_adventure_finished.bind(st, logger))
adr.set_hero_override(atk_pct, hp_pct)
if not adr.start_adventure(seed_val, {"hero_override": hero_override}):
if not adr.start_adventure(seed_val, {"hero_override": hero_override, "first_script": false}): # 测量夹具恒抽签(v0.4——首次脚本分支不进任何读数)
st.result = "start_failed"
if own_session:
logger.close_session()
@@ -152,6 +154,10 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr,
var r = adr.runner()
var t := String(node.get("type", ""))
st.visited_ids.append(int(r.node_id_of(node)))
if not st.has("level_templates"):
st["level_templates"] = []
st["level_templates"].append(r.active_template_name) # MC 分层出数(关卡×图模板——v0.4 测量对账条款)
if t == "battle" or t == "boss":
st.battles += 1
st.last_battle_enemies = [] # 末场战斗敌方名序列按战斗重置(失败局终值 = 死亡遭遇)
+78
View File
@@ -107,6 +107,84 @@ func _run_core_smoke(db, rng) -> void:
_smoke_adventure()
_smoke_save()
_smoke_meta()
_smoke_node_map_templates()
# ---- 关卡节点图生成·模板池冒烟(M-3 迭代 5 T5——v0.4 §P0.6 断言面)----
func _smoke_node_map_templates() -> void:
# ① 扩签:node_map 入用途标签封闭词表(派生零改动——标签只进事件日志)
var svc = load("res://core/rng_service.gd").new()
check((svc.PURPOSE_TAGS as Array).has("node_map"), "node_map 标签入 rng_service 封闭词表(扩签通道=改代码+冒烟守卫)")
# ② 池参数:三关各 5 模板、模板 0 存在(回滚档成员)
for li in range(1, 4):
var row := {}
for r in _db.level.rows:
if int(r.level_index) == li:
row = r
var tpls: Array = row.get("templates", [])
check(tpls.size() == 5, "L%d 模板池 = 5 张(拍板点④——首版池规模)" % li)
check(not tpls.is_empty() and String(tpls[0].get("display_name", "")).ends_with("(现行)"),
"L%d 模板 0 = 现行固定图(回滚档成员)" % li)
# ③ 强制取 0 = 模板 0 节点表逐位相等 + 零 scope 消费
var rng0 = load("res://core/rng_service.gd").new()
rng0.new_adventure(910001)
var runner0 = load("res://core/level_runner.gd").new(_db, rng0)
var c_before: int = rng0.counter()
check(runner0.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, {},
Callable(self, "_smoke_rest_stub"), {}, true, 0), "强制 template_pick=0 起关受理")
check(rng0.counter() == c_before, "强制取模板零 RNG 消耗(零抽签——首次脚本语义)")
var tpl0: Dictionary = {}
for r in _db.level.rows:
if int(r.level_index) == 2:
tpl0 = r.get("templates", [])[0]
check(runner0.level_row.get("nodes", []) == tpl0.get("nodes", []),
"强制 0 的生效节点表 = 模板 0 逐位相等(回滚档成立)")
# ④ 抽签确定性:同种子两实例同模板(同种子同操作序列 → 逐位同图)
var names := []
for i in range(2):
var rg = load("res://core/rng_service.gd").new()
rg.new_adventure(910002)
var rn = load("res://core/level_runner.gd").new(_db, rg)
rn.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, {},
Callable(self, "_smoke_rest_stub"), {}, true, -1)
names.append(rn.active_template_name)
check(names[0] == names[1] and String(names[0]) != "",
"抽签确定性:同种子逐位同图(实测 %s)" % String(names[0]))
# ⑤ N=1 池与现行固定图等价:单模板行过图校验(抽签取模 1 恒 0 = 回滚档)
var lt = load("res://data/schemas/level_table.gd").new()
lt.rows = [{"level_index": 9, "display_name": "t", "rest_heal_pct": 30,
"templates": [tpl0]}]
check(lt.validate().is_empty(), "N=1 池表通过图校验(回滚档形态合法)")
# ⑥ 首次脚本分支:first_script=true 全关模板 0;false = 抽签(scope 恰一差异)
var rngF = load("res://core/rng_service.gd").new()
rngF.new_adventure(910003)
var adrF = load("res://core/adventure_runner.gd").new(_db, rngF)
adrF.start_adventure(910003, {"first_script": true})
check(String(adrF.runner().active_template_name).ends_with("(现行)"),
"首次脚本模式:L1 取模板 0(全关零抽签——存档布尔位 false 语义)")
var cF: int = rngF.counter()
var rngT = load("res://core/rng_service.gd").new()
rngT.new_adventure(910003)
var adrT = load("res://core/adventure_runner.gd").new(_db, rngT)
adrT.start_adventure(910003, {"first_script": false})
check(rngT.counter() == cF + 1, "抽签模式恰多消费 1 scope(node_map 逐关恰一——L1 起关抽签)")
check(String(adrT.runner().active_template_name) != "",
"抽签模式 active_template_name 有值(MC 分层出数取数面在位)")
# ⑦ 收束置位:新档 false → settle 置 true 恰一次(幂等)
DirAccess.remove_absolute("user://smoke_node_map_save.json") # 测试隔离——上轮 settle 写盘的残留档先删
var sv = load("res://core/save_manager.gd").new("user://smoke_node_map_save.json")
sv.load_from_disk()
check(sv.is_first_adventure_done() == false, "新档首次冒险布尔位 = 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"})
check(sv.is_first_adventure_done() == true, "冒险收束点置位(三结局同写——写入只在收束挂点)")
mp.settle_adventure({"levels_cleared": 0, "keystone_total": 0, "cleared": false, "result": "failed"})
check(sv.is_first_adventure_done() == true, "置位幂等(重复收束不翻转)")
# 冒烟恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位)
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)
# ---- 冒险结构实现批冒烟(2026-09-21——AdventureTable 建表 + AdventureRunner + 冒险验收断言)----