Merge branch 'master' into reqcards-20261024

This commit is contained in:
DKChan committed 2026-10-25 01:11:45 +08:00
commit 6835f96df0
39 files changed
+2209 -935

No files matched your search

+10 -9
View File
@@ -42,7 +42,7 @@ var _pending_next_level := false # 大奖励已兑付、等 continue_to
var _last_level_payload := {} # 最近一次关卡结束载荷(失败结算的败退节点明细来源——数值验证取数面)
var _override := [0, 0] # 调试浮窗百分比通道 [atk_pct, hp_pct](每关新 runner 都要应用)
var _base_override := {"atk": 0, "max_hp": 0, "armor_class": 0}
var _first_script := false # 首次脚本模式(关卡节点图生成 v0.4——true = 全关零抽签取池内模板 0;测量夹具恒 false) # 永久强化 flat 注入(冒险开始参数面 params.hero_override 存入,共享给 LevelRunner;全零 = 白板)
# 永久强化 flat 注入(冒险开始参数面 params.hero_override 存入,共享给 LevelRunner;全零 = 白板)
var _last_treasure_cleared := 0 # additive(最近一次关卡末清空计数,summary 取数面)
var _treasure_unlocked := {} # aux 解锁门控集(params.treasure_unlocked 接收面)
var _attunement: Array[Variant] = [] # 同调装配集(params.attunement 接收面,start_level 外下传 LevelRunner)
@@ -99,7 +99,6 @@ func start_adventure(seed_val: int, params: Dictionary = {}) -> bool:
_base_override = _normalize_override(params.get("hero_override", {}))
hero_attack_type = String(params.get("attack_type", "")) # 两根类型轴快照(契约 8 锁死:开局后不随存档选中变化)
hero_armor_type = String(params.get("armor_type", ""))
_first_script = bool(params.get("first_script", false)) # 缺省 = 非首次(抽签路径;真实侧由呈现层读存档布尔位传入)
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 缺省 legacy(off 对照零扰动)
_treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {}
_attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive
@@ -144,6 +143,11 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool:
if bp.is_empty():
push_error("[冒险结构] resume_adventure 拒绝——空断点快照")
return false
if int(bp.get("graph_gen_v2", 0)) != 1:
# 旧代差断点(模板池时代的 node_id 与生成图句柄空间可碰撞错位)——显式拒绝,
# 禁按新语义静默回放;旧模板图已随模板池退役,无迁移动作可做(凭空捏图=兜底图红线)。
push_error("[冒险结构] resume_adventure 拒绝——断点缺生成图代差标记(graph_gen_v2),属换代前旧断点")
return false
master_seed = int(bp.get("master_seed", 0))
rng.restore_adventure(master_seed, int(bp.get("rng_counter", 0)))
ledger = {"hero_hp": int(bp.get("hero_hp", 0)), "gp": int(bp.get("gp", 0))}
@@ -191,7 +195,6 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool:
# 钳制用 flat 已含的保守口径即可,加算区恢复后其余战斗照常)。
ledger["hero_hp"] = mini(int(ledger.get("hero_hp", 0)), maxi(1,
int(round(float(int(db.hero.max_hp) + int(_base_override.get("max_hp", 0))) * (100.0 + float(_override[1])) / 100.0))))
_first_script = false # 续玩模板已随快照强制(已抽模板零重抽)——脚本模式不重放
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 续玩参数面同 start_adventure(缺省 legacy)
_treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {}
_attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive
@@ -844,14 +847,12 @@ func _start_current_level() -> bool:
_runner.wallet_changed.connect(_forward_wallet_changed)
_runner.level_finished.connect(_on_level_finished)
_runner.set_hero_override(int(_override[0]), int(_override[1]))
# 断点续玩分支:模板随快照强制(已抽模板零重抽零 RNG)、resume_state 带
# 停点节点与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。
# 断点续玩分支:图由主种子+参数档重生成同图(产图与计数器号位无关,停点零重抽语义随之消解),
# resume_state 带停点句柄与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。
var resume_state: Dictionary[Variant, Variant] = {}
var template_pick: int = 0 if _first_script else -1 # 首次脚本 = 每关固定模板 0 零抽签;否则逐关抽签
if not _resume_bp.is_empty():
resume_state = {"node_id": int(_resume_bp.get("node_id", -1)),
"visited_ids": _resume_bp.get("visited_ids", []) as Array}
template_pick = int(_resume_bp.get("template_pick", -1))
# 接口①开始关卡:生命经共享账本交接(账本当前值即开局生命——冒险开始置满 / 大奖励回满已写好);
# _base_override 共享引用随载荷传入(永久强化 flat——LevelRunner.effective_* 叠加序第二项)。
# defer_entry=true 起每关(含首关)——先 emit level_started(呈现层在
@@ -860,7 +861,7 @@ func _start_current_level() -> bool:
_runner.set_run_ledger(run_ledger) # 共享四账单类下传(run_ledger=四账类,ledger=关卡账)
_runner.sync_attune_inventory() # 能力件骑库存面注入(随账下传同拍)
var ok: bool = _runner.start_level_external(level_index, ledger, purchased,
apply_rest, _base_override, true, template_pick, resume_state)
apply_rest, _base_override, true, resume_state)
if ok:
_runner.set_attunement(_attunement) # 同调装配下传(enc 携带进战斗结算)
_runner.set_feats(_feats) # 特性持有集下传(begin_battle enc.feats 求值面)
@@ -1010,7 +1011,7 @@ func _snapshot_breakpoint() -> void:
"shop_shelf": _runner.shop_bp_shelf() if _runner != null else [], # 货架入断点(离开店/非店停点 = 空架合法态)
"shop_visit_buys": _runner.shop_bp_visit_buys() if _runner != null else 0, # 本次进店已购次数(A 链定价输入)
"attunement_charges": run_ledger.attune_charges.duplicate(true), # 同调主动件充能余量随断点(save v4 域)
"template_pick": _runner.active_template_index if _runner != null else -1,
"graph_gen_v2": 1, # 生成图换代代差标记(缺键 = 换代前旧断点 → resume 显式拒绝)
"master_seed": master_seed,
"rng_counter": rng.counter(),
"keystone": keystone,
+1 -53
View File
@@ -51,60 +51,8 @@ func cross_validate(loaded: Dictionary, push_diag: Callable) -> void:
for s in required_sources:
if not st_tab.source_label_map.has(s):
push_diag.call("error", "segorder_violation", "source_label_map 缺必需键: %s" % s)
# ④ LevelTable 节点悬空引用(battle/boss 节点:board_gen 侧须有精确行;enemy 侧按
# (关卡序号, 行类) 带寻址——落带即设计语义,节点深度高于顶带键=顶 clamp 消解属调参事故,加载期直接拦。
# 同关同一张图多终点 Boss 节点解析带键须互异(共带=两终点命中同一 Boss 行集,「走哪条路打谁」落空))
var lv: LevelTable = loaded.get("level")
var bg: BoardGenTable = loaded.get("board_gen")
var en: EnemyTable = loaded.get("enemy")
if lv != null and bg != null and en != null:
var bg_keys := {}
for row in bg.rows:
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var en_bands := {} # "%d_%s" % [L, row_kind] → 升序带键数组
for row in en.rows:
var ek := "%d_%s" % [int(row.level_index), String(row.get("row_kind", ""))]
if not en_bands.has(ek):
en_bands[ek] = []
var eb := int(row.get("depth_band", -1))
if not (en_bands[ek] as Array).has(eb):
(en_bands[ek] as Array).append(eb)
for ek in en_bands.keys():
(en_bands[ek] as Array).sort()
for lrow in lv.rows:
var li: int = int(lrow.level_index)
# 模板池感知(关卡节点图生成 v0.4——池内全模板跨表校验,不只 row.nodes)
var node_lists: Array[Array] = []
var tpls: Array[Variant] = lrow.get("templates", [])
if tpls.is_empty():
node_lists.append(lrow.get("nodes", []))
else:
for tpl in tpls:
node_lists.append(tpl.get("nodes", []))
for tpl_i in range(node_lists.size()):
var boss_bands := {} # 本图已占 Boss 带键
for node in node_lists[tpl_i]:
var t: String = String(node.get("type", ""))
if t != "battle" and t != "boss":
continue
var nd: int = int(node.get("node_depth", -1))
var key := "%d_%d" % [li, nd]
if not bg_keys.has(key):
push_diag.call("error", "level_node_dangling", "level %d %s 节点 (%s) 在 board_gen 无精确行" % [li, t, key])
var nk := "%d_%s" % [li, t]
if not en_bands.has(nk):
push_diag.call("error", "level_node_dangling", "level %d %s 节点 (%d) 在 enemy 无该行类行集" % [li, t, nd])
continue
var hit_band: int = EnemyTable.band_of(en_bands[nk], nd)
if hit_band < 0:
push_diag.call("error", "level_node_dangling", "level %d %s 节点深度 %d 高于 (level %d, %s) 顶带键 %d——顶 clamp 消解" % [
li, t, nd, li, t, int((en_bands[nk] as Array)[-1])])
continue
if t == "boss":
if boss_bands.has(hit_band):
push_diag.call("error", "rowset_rule", "level %d 模板 %d 多终点 Boss 节点同带(band %d)——共带使「走哪条路打谁」落空" % [li, tpl_i, hit_band])
boss_bands[hit_band] = true
# ⑤ 技能池引用完整性(敌人技能池批——敌人表池 × 技能表;独立入口 validate_skill_pool_links 供冒烟直验)
var en: EnemyTable = loaded.get("enemy")
var skt = loaded.get("skill")
if skt != null and en != null:
for e in validate_skill_pool_links(skt, en, loaded.get("status_effects")):
+71 -84
View File
@@ -64,7 +64,7 @@ var _phase: int = Phase.IDLE
var level_result := "" # "" / "victory" / "defeat"(外部契约面)
var _candidates: Array[Dictionary] = [] # 当前选路候选(节点引用数组)
var visited: Array[Dictionary] = [] # 已兑付/到达节点序列(呈现层图条数据源;含当前节点)
var _graph_mode := false # 图形态缓存(行集任一节点带 next;单链 = false)
var _generator # 参数化长图生成器(惰性建——NodeMapGenerator 持注入的 RngService)
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
@@ -127,7 +127,7 @@ func restore_hero_base() -> void:
# ---- 关卡生命周期 ----
func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = {}, template_pick: int = -1) -> bool:
func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = {}) -> bool:
# 单关测试模式:私有账本、内置恢复数学、自调 new_adventure(漂移①
# 单关=冒险的历史简化在测试模式保留;正式种子域归 AdventureRunner.start_adventure)
# base_override_in = 永久强化 flat 注入尾参(缺省 {} 全零白板,
@@ -146,34 +146,31 @@ func start_level(level_index: int, seed_val: int, base_override_in: Dictionary =
_phase = Phase.IDLE
_candidates = []
visited = []
_graph_mode = false
gp = 0 # 冒险钱包清零开局(局内货币生命周期起点)
run_ledger.bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} # 加算区冒险开始全 0(单关入口零元进度——注入面见上)
base_override = {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat:缺省全零白板(单关入口零元进度——注入面见上)
_shop_purchased = {} # 已购档数清零(跨冒险不延续——递增定价按冒险内累计)
for k in base_override.keys():
base_override[k] = maxi(0, int(base_override_in.get(k, 0))) # 注入规范化(契约同冒险开始参数面)
for node in nodes():
if node.has("next"):
_graph_mode = true
break
hero_hp = effective_max_hp()
rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构)
level_row.assign(_resolve_template_row(row, template_pick)) # 池解析在播种后(node_map 抽签须已播种——EC7)
_phase = Phase.RUNNING
var entry := _entry_node()
if entry.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
if not _assign_generated_graph(row, seed_val):
_phase = Phase.ENDED
return false
return _enter_node(entry)
_phase = Phase.RUNNING
var entries := _entry_nodes()
if entries.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)")
_phase = Phase.ENDED
return false
return _enter_node(entries[0])
# 冒险结构接口①:开始关卡(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接;
# 种子域归冒险结构:本路径不重播种;账本归冒险结构:本路径不重置 gp/bonus/_shop_purchased,
# 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在 ledger 写好)。
# base_override = 永久强化 flat 共享引用(缺省 {} 全零白板,与 B1 空参数路径同值)。
# 流程与 start_level 同款(_find_level_row → 状态初始化 → _graph_mode 探测 → _enter_node(entry)),
# 流程与 start_level 同款(_find_level_row → 生成图装配 → 状态初始化 → _enter_node(入口)),
# 差异仅在账本/加算区/已购档/恢复执行体的注入与零重置。
# defer_entry 尾参(缺省 false = 直接进节点)——true 时不自动
# _enter_node(entry),改置选路态(awaiting_choice + 单候选 = 入口节点),由 AdventureRunner 在
@@ -183,7 +180,7 @@ func start_level(level_index: int, seed_val: int, base_override_in: Dictionary =
# _enter_node(entry)——测量面零漂移红线。
func start_level_external(level_index: int, ledger: Dictionary,
purchased_ref: Dictionary, rest_handler: Callable, base_override_ref: Dictionary = {},
defer_entry: bool = false, template_pick: int = -1, resume_state: Dictionary = {}) -> bool:
defer_entry: bool = false, resume_state: Dictionary = {}) -> bool:
var row := _find_level_row(level_index)
if row.is_empty():
push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index)
@@ -193,34 +190,30 @@ func start_level_external(level_index: int, ledger: Dictionary,
base_override = base_override_ref
_shop_purchased = purchased_ref
_rest_handler = rest_handler
level_row.assign(_resolve_template_row(row, template_pick))
if not _assign_generated_graph(row, rng.master_seed):
return false
node_index = -1
level_result = ""
_phase = Phase.IDLE
_candidates = []
visited = []
_graph_mode = false
for node in nodes():
if node.has("next"):
_graph_mode = true
break
_phase = Phase.RUNNING
# 断点续玩路径(defer_entry 之外的第三种停点入口):直接重建到停点所在节点,
# 零 RNG 消耗(模板已随 template_pick 强制、节点状态纯重建不 _enter_node——当场战斗从头打)。
# 零 RNG 消耗(图由主种子+参数档重生成同图、节点状态纯重建不 _enter_node——当场战斗从头打)。
# resume_state = {"node_id": int(-1 = 入口停点), "visited_ids": Array[int]};空字典 = 既有路径逐位不变。
if not resume_state.is_empty():
return _restore_to_stop(int(resume_state.get("node_id", -1)),
resume_state.get("visited_ids", []) as Array)
var entry := _entry_node()
if entry.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
var entries := _entry_nodes()
if entries.is_empty():
push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)")
_phase = Phase.ENDED
return false
if defer_entry:
_phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1——进节点前无 current_node)
_candidates = [entry]
_phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1)——候选=全部入口(多入口首跳即选路)
_candidates = entries
return true
return _enter_node(entry)
return _enter_node(entries[0])
# 入口停点呈现(defer_entry=true 的第二步):发出入口候选供呈现层弹
@@ -240,14 +233,14 @@ func present_entry_choice() -> bool:
# 候选 = 停点节点出边(与 choices_presented 停点语义逐位一致)。
func _restore_to_stop(node_id: int, visited_ids: Array) -> bool:
if node_id < 0:
# 入口停点:与 defer_entry 同款形态(node_index 保持 -1,单候选 = 入口节点)
var entry := _entry_node()
if entry.is_empty():
# 入口停点:与 defer_entry 同款形态(node_index 保持 -1,候选 = 全部入口)
var entries := _entry_nodes()
if entries.is_empty():
push_error("[关卡节点地图] 断点恢复失败——入口节点缺失")
_phase = Phase.ENDED
return false
_phase = Phase.AWAITING_CHOICE
_candidates = [entry]
_candidates = entries
return true
var stop: Dictionary[Variant, Variant] = {}
for node in nodes():
@@ -296,32 +289,31 @@ func _find_level_row(level_index: int) -> Dictionary:
return db.level_row(level_index) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器)
# ---- 模板池解析(关卡节点图生成 v0.4——M-3)----
# row.templates 非空 = 池形态:template_pick >= 0 = 强制取该模板(首次脚本模式/冒烟固定图,零 RNG 消费);
# -1 = 抽签(node_map 用途标签恰一 scope,均匀取模)。空池 = 旧单图形态原样返回(向后兼容零迁移)。
# 解析产物 = row 副本换 nodes(下游 nodes()/校验/呈现全透明);active_template_name 供 MC 分层出数;
# active_template_index 供断点快照备案(已抽模板续玩零重抽零 RNG 消耗)。
var active_template_name := ""
var active_template_index := -1 # -1 = 非池形态(旧单图)/未解析
func _resolve_template_row(row: Dictionary, template_pick: int) -> Dictionary:
var templates: Array[Variant] = row.get("templates", [])
active_template_name = ""
active_template_index = -1
if templates.is_empty():
return row
var idx := 0
if template_pick >= 0:
idx = clampi(template_pick, 0, templates.size() - 1)
else:
var stream: RandomNumberGenerator = rng.open_stream("node_map")
idx = stream.randi_range(0, templates.size() - 1)
var tpl: Dictionary[Variant, Variant] = templates[idx]
active_template_name = String(tpl.get("display_name", ""))
active_template_index = idx
var out := row.duplicate(true)
out["nodes"] = tpl.get("nodes", [])
return out
# ---- 图源装配(关卡节点图生成 v1.1——图 = f(参数档, 种子) 纯函数;无模板池、无兜底图)----
# 参数档按关卡序号解析(消费侧职责——生成器不认关卡号),种子取本局口径:单关模式用传入种子、
# 冒险模式用主种子(产图与全局计数器号位无关,断点在停点重生成同图)。产图先过消费侧独立校验
# LevelTable.validate_graph(与产出侧自证同源规格、分工不重叠);违规=起关拒绝,不降级不回退。
func _assign_generated_graph(row: Dictionary, seed_val: int) -> bool:
var level_index: int = int(row.get("level_index", 0))
var params: Dictionary[Variant, Variant] = db.level_gen_params_row(level_index)
if params.is_empty():
push_error("[关卡节点地图] level %d 无参数档行(level_gen_params 缺行——配置错误)" % level_index)
return false
if _generator == null:
_generator = NodeMapGenerator.new(rng)
var graph: Dictionary[Variant, Variant] = _generator.generate(params, seed_val)
if graph.is_empty() or _generator.is_fatal():
push_error("[关卡节点地图] level %d 生成图失败(参数无解或产出不合格——无兜底图)" % level_index)
return false
var bad := LevelTable.validate_graph(graph["nodes"] as Array)
if not bad.is_empty():
for e in bad:
push_error("[关卡节点地图] level %d 生成图违规: %s" % [level_index, String(e["message"])])
return false
var merged := row.duplicate(true)
merged["nodes"] = graph["nodes"]
level_row.assign(merged)
return true
func nodes() -> Array:
@@ -334,37 +326,30 @@ func current_node() -> Dictionary:
return nodes()[node_index]
# ---- 图版行进面(v1.0:出边表选路 · 零自动推进 · 单链 = 隐式出边退化形态)----
# 编号空间(与 LevelTable 校验器同一解析):图形态 = node_depth(全节点编号化);单链 = 数组下标。
# 混用两空间会在单链上碰撞(如 reward 下标 2 vs 战斗 node_depth 2)——节点寻址一律经 _node_id。
# ---- 图版行进面(生成图恒带显式出边;单链退化形态 = 出度 ≤1 的图,无数组序隐式边)----
# 节点身份=生成图句柄 id(图内唯一)。定位信息另算(enc 携带关卡序号 + 节点深度),
# 禁再以深度当身份(句柄/位置两轴语义单一)。
func _node_id(node: Dictionary) -> int:
if _graph_mode:
return int(node.get("node_depth", -1))
return nodes().find(node)
return int(node.get("id", -1))
# 入口节点 = 无入边唯一节点(图合法性校验保证恰一)
func _entry_node() -> Dictionary:
# 入口节点集 = 全部无入边节点(个数=参数档 entry_count;多入口时首跳即选路)
func _entry_nodes() -> Array[Dictionary]:
var inbound := {}
for node in nodes():
for tid in _out_ids(node):
inbound[tid] = true
inbound[int(tid)] = true
var out: Array[Dictionary] = []
for node in nodes():
if not inbound.has(_node_id(node)):
return node
return {}
out.append(node)
return out
# 出边编号表:显式 next 优先;单链(无 next 键)= 数组顺序下一项(退化形态)
# 出边句柄表(生成图恒显式 next)
func _out_ids(node: Dictionary) -> Array:
if node.has("next"):
return node.get("next", [])
var nodes: Array[Variant] = nodes()
var i := nodes.find(node)
if i >= 0 and i < nodes.size() - 1:
return [i + 1]
return []
return node.get("next", [])
# 出边候选(节点引用数组——选路口/呈现层共用读面)
@@ -442,11 +427,13 @@ func type_visible_ids() -> Array[int]:
# 不可达节点不落表(加载期孤儿校验应已拦截)。
func _bfs_layers() -> Dictionary:
var layers := {}
var entry := _entry_node()
if entry.is_empty():
var entries := _entry_nodes()
if entries.is_empty():
return layers
var queue: Array[Dictionary] = [entry]
layers[_node_id(entry)] = 0
var queue: Array[Dictionary] = []
for entry in entries:
layers[_node_id(entry)] = 0
queue.append(entry)
var i := 0
while i < queue.size():
var cur: Dictionary[Variant, Variant] = queue[i]
@@ -510,7 +497,7 @@ func _start_battle(node: Dictionary) -> bool:
# node_entered 先于 begin_battle:呈现层须先拿到 settlement 引用,才能响应 begin 期间发出的
# board_changed(首板安装)并重建棋盘——后发会导致首战棋盘不渲染
node_entered.emit(node, {})
if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("node_depth", 0)),
if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("depth", 0)),
"hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp(),
"hero_armor_class": effective_ac(), "hero_ap_bonus": effective_ap_bonus(),
"attack_type": _hero_attack_type, "armor_type": _hero_armor_type, # 两根类型轴(spawn 英雄快照;空串=锻造配置默认值回退)
@@ -523,7 +510,7 @@ func _start_battle(node: Dictionary) -> bool:
"voucher_rap": _voucher_rap,
"voucher_time_sand": _voucher_time_sand,
"node_type": String(node.get("type", ""))}):
push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("node_depth", -1)))
push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("depth", -1)))
return false
_voucher_rap = 0 # 节点级一次性:注入即消费(战斗未启动则保留至下次注入前不清)
_voucher_time_sand = 0
@@ -535,7 +522,7 @@ func _forward_step_row(row: Dictionary) -> void:
# battle_index=第几场战斗(关卡域 1 基序——visited 已含当前节点〔_enter_node :437 先追加后启动〕;
# 按已到节点 type=battle 计数,与图形态 node_depth/单链数组下标两编号空间解耦〔混编碰撞免疫〕;
# 按已到节点 type=battle 计数,与句柄 id / 节点深度两轴解耦〔身份与位置互不兼任〕;
# 开局姿态组「每关首轮窗」条件面)
func _battle_index() -> int:
var n := 0
+4 -3
View File
@@ -299,9 +299,10 @@ func _default_data() -> Dictionary[String, Variant]:
}
# ---- 首次冒险布尔位(关卡节点图生成 v0.4)----
# false = 首次脚本模式(全关零抽签取池内模板 0=现行固定图);置位只在冒险收束挂点(MetaProgression.settle_adventure)。
# 老档迁移:缺字段 = 该档已有游玩史 → true(不重放教学脚本)。
# ---- 首次冒险布尔位(关卡节点图生成 v1.1 确定性承载)----
# false = 本档尚无游玩史 → 首局走固定种子 42 + 参数档生成图(教学与存档复现的固定图由此承载,无兜底模板);
# 置位只在冒险收束挂点(MetaProgression.settle_adventure)。
# 老档迁移:缺字段 = 该档已有游玩史 → true(不重放首局固定图)。
func is_first_adventure_done() -> bool:
return bool(_data["meta"].get("first_adventure_done", true))
+117 -165
View File
@@ -1,13 +1,14 @@
## VERTICAL SLICE - NOT FOR PRODUCTION
## 关卡节点表 schema:逐关节点链(单链/节点图两形态)的类型化读取(垂直切片验证:线性第一关承载元进度循环)。
## 两形态同跑一套图合法性算法,违例加载期致命(validate 报错由 ConfigDB 收敛为 fail-fast)。
## 关卡节点表 schema:逐关行集(关数/显示名/休整回复/迷雾层数)的类型化读取(垂直切片验证:改表即改关卡)。
## 图合法性只在 `validate_graph` 一处陈述(消费侧起关校验入口——关卡节点图生成 v1.1 产出规格为准);
## 表加载期不校验图(关卡表不再携带节点数据,图源=参数化生成器)。
class_name LevelTable
extends Resource
@export var rows: Array[Dictionary] = [] # Array[Dictionary]
@export var info_visible_layers: int = 2 # 信息迷雾可见层数 N(int ≥1;类型仅在邻近 N 层内可见——关卡节点地图 v2.1)
const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 五类(关卡节点地图 v1.1)
const NODE_TYPES := ["battle", "reward", "rest", "boss", "shop"] # 五类(关卡节点地图 v2.1)
# 行单点读取(表访问收口——LevelRunner._find_level_row 禁直扫;键 = level_index)
@@ -38,182 +39,133 @@ func validate() -> Array:
var heal: int = int(row.get("rest_heal_pct", -1))
if heal < 0 or heal > 100:
errs.append({"category": "domain", "message": "level %d rest_heal_pct 越域 [0,100]" % L})
# 模板池(关卡节点图生成 v0.4):row.templates 非空 = 池形态,池内全模板加载期校验
# (违规致命——不只抽中的那张);空 = 单图形态(row.nodes 直读,向后兼容)。
var node_lists: Array[Array] = [] # [ [上下文标签, nodes 数组], ... ]
var templates: Array[Variant] = row.get("templates", [])
if templates.is_empty():
node_lists.append(["", row.get("nodes", [])])
else:
for j in templates.size():
var tpl = templates[j]
if typeof(tpl) != TYPE_DICTIONARY or (tpl.get("nodes", []) as Array).is_empty():
errs.append({"category": "domain", "message": "level %d 模板[%d] 缺 nodes 或为空" % [L, j]})
continue
node_lists.append(["模板[%d]=%s" % [j, String(tpl.get("display_name", ""))], tpl.get("nodes", [])])
for nl in node_lists:
for e in _validate_level_nodes(L, nl[0], nl[1]):
errs.append(e)
return errs
# 单节点链校验体(validate 抽出——row.nodes 与池内每张模板同跑一套;ctx = 消息上下文标签)
func _validate_level_nodes(L: int, ctx: String, nodes: Array) -> Array:
var errs: Array[Dictionary] = []
var tag: String = "" if ctx.is_empty() else "〔%s〕" % ctx
# 图合法性校验单点(消费侧起关入口——生成图到手、进节点前调用;违规=致命,无软回退)。
# 判据=四检+良构基底:① 入口(无入边)≥1 且终点(无出边)≥1;② 全部节点从某入口可达且可达某终点
#(每条路径恰从一个入口出发、恰到好处一个终点,无孤儿无断头);③ 终点恒为 Boss(终点 type=boss 且 boss 无出边);
# ④ 无环(出边目标深度严格大于源)。良构=句柄唯一·出边全命中·type 在五类枚举·battle/boss 带深度。
# 入口/终点个数与参数档的一致性归产出侧(关卡节点图生成 v1.1),本处不核个数;商店位归冒烟断言把关,本处禁检。
static func validate_graph(nodes: Array) -> Array:
if nodes.is_empty():
errs.append({"category": "domain", "message": "level %d%s 节点链为空" % [L, tag]})
return errs
var boss_count := 0
var battle_count := 0
var is_graph := false
for node in nodes:
if node.has("next"):
is_graph = true
var t: String = String(node.get("type", ""))
if not NODE_TYPES.has(t):
errs.append({"category": "enum", "message": "level %d%s 未知节点类型: %s" % [L, tag, t]})
continue
if t == "battle" or t == "boss":
if int(node.get("node_depth", -1)) < 0:
errs.append({"category": "domain", "message": "level %d%s %s 节点缺 node_depth" % [L, tag, t]})
battle_count += 1
if t == "boss":
boss_count += 1
if boss_count != 1:
errs.append({"category": "structural", "message": "level %d%s Boss 节点数 = %d(须恰一)" % [L, tag, boss_count]})
if battle_count < 2:
errs.append({"category": "structural", "message": "level %d%s 战斗节点(含 Boss)= %d(≥2 才构成关卡)" % [L, tag, battle_count]})
if not is_graph:
# 单链形态纪律(第一关零迁移):末节点必须是 Boss(关底收尾——冒险结构 关卡定义)
if String(nodes[nodes.size() - 1].get("type", "")) != "boss":
errs.append({"category": "structural", "message": "level %d%s 末节点非 Boss(单链以 Boss 收尾)" % [L, tag]})
# 图合法性(单链与图同一套算法——单链 = 出边恰一的退化形态,违规同样加载致命)
for e in _validate_graph(L, nodes, is_graph, tag):
errs.append(e)
# U2 目检规则(两条,加载期同检):①单链前三位纯战斗(奖励/休整禁入前三)
if not is_graph and nodes.size() >= 3:
for i in 3:
if String(nodes[i].get("type", "")) != "battle":
errs.append({"category": "structural", "message": "level %d%s 第 %d 位非战斗(单链前三位纯战斗——U2 目检规则)" % [L, tag, i + 1]})
return errs
return [{"category": "structural", "message": "图节点集为空"}]
var well := _check_nodes_wellformed(nodes)
if not (well["errs"] as Array).is_empty():
return well["errs"]
var by_id: Dictionary = well["by_id"]
var edges := _check_edges(by_id, well["indeg"])
if not (edges["errs"] as Array).is_empty():
return edges["errs"]
var stops := _check_terminals(by_id, edges["outs"], edges["indeg"])
if not (stops["errs"] as Array).is_empty():
return stops["errs"]
return _check_reachability(by_id, edges["outs"], stops["entries"], stops["terminals"])
# 图合法性核心(返回错误列表):id 解析(图=node_depth / 单链=数组下标)→ 出边表 →
# 唯一性/前进方向/入口唯一/Boss 终点/双向可达六检。参考 关卡节点地图 v1.0 分支节点图规则。
func _validate_graph(L: int, nodes: Array, is_graph: bool, tag: String = "") -> Array:
# 良构基底:字段齐·type 在枚举·深度非负·句柄唯一(返回 by_id 与入度骨架供后续检复用)
static func _check_nodes_wellformed(nodes: Array) -> Dictionary:
var errs: Array[Dictionary] = []
var ids: Array[int] = [] # 每节点 id(下标对齐)
var nexts: Array[Array] = [] # 每节点出边 id 数组
var id_to_idx := {}
var by_id := {}
var indeg := {}
for i in nodes.size():
var node: Dictionary[Variant, Variant] = nodes[i]
var nid: int = int(node.get("node_depth", i)) if is_graph else i
if is_graph and not node.has("node_depth"):
errs.append({"category": "structural", "message": "level %d%s 图形态节点[%d] 缺 node_depth(全节点须编号化)" % [L, tag, i]})
nid = i # 降级继续(后续校验仍有意义)
ids.append(nid)
if id_to_idx.has(nid):
errs.append({"category": "dup_key", "message": "level %d%s 节点编号重复: %d(关卡内唯一定位地址)" % [L, tag, nid]})
id_to_idx[nid] = i
var nx: Array[int] = []
if node.has("next"):
for v in node.get("next", []):
nx.append(int(v))
elif i < nodes.size() - 1:
nx.append(i + 1) # 单链隐式出边 = 数组下一项(id 即下标)
nexts.append(nx)
# ② 出边引用存在 + 前进方向(目标 id 严格递增 ⇒ 无环——环必含非递增边)
for i in nodes.size():
for tid in nexts[i]:
if not id_to_idx.has(tid):
errs.append({"category": "dangling_next", "message": "level %d%s 节点 %d 出边指向不存在编号: %d" % [L, tag, ids[i], tid]})
elif tid <= ids[i]:
errs.append({"category": "structural", "message": "level %d%s 连线 %d→%d 违反前进方向(目标编号须严格更大)" % [L, tag, ids[i], tid]})
# ③b 出度上限(「节点出度最大为 2」):单节点出边 >2 即加载致命。
# 规则连带:出度 ≤2 + 入口唯一下 BFS 层宽上限 = 4(序列 1,2,4——层宽上限 5 与出度约束
# 不相容,模板池按层宽 ≤4 构造)。
for i in nodes.size():
if nexts[i].size() > 2:
errs.append({"category": "fanout_violation", "message": "level %d%s 节点 %d 出度 %d > 2(出度上限拍板 2026-10-01)" % [L, tag, ids[i], nexts[i].size()]})
# ④ 入口唯一(无入边节点恰一——唯一入口)
var has_inbound := {}
for i in nodes.size():
for tid in nexts[i]:
has_inbound[tid] = true
var entries: Array[int] = []
for i in nodes.size():
if not has_inbound.has(ids[i]):
entries.append(i)
if entries.size() != 1:
errs.append({"category": "structural", "message": "level %d%s 入口节点数 = %d(须恰一——无入边节点唯一)" % [L, tag, entries.size()]})
# ⑤ Boss 恒唯一终点:无出边 + 编号最大(图形态;单链由"末节点 Boss"既有校验覆盖终点性)
for i in nodes.size():
if String(nodes[i].get("type", "")) != "boss":
for k in ["id", "type", "depth", "next"]:
if not node.has(k):
errs.append({"category": "structural", "message": "节点[%d] 缺字段 %s" % [i, k]})
var t := String(node.get("type", ""))
if not NODE_TYPES.has(t):
errs.append({"category": "enum", "message": "未知节点类型: %s" % t})
var d: int = int(node.get("depth", -1))
if d < 0:
errs.append({"category": "domain", "message": "节点 %s 深度越域(≥0): %d" % [str(node.get("id", i)), d]})
if t == "battle" and not node.has("depth"):
errs.append({"category": "structural", "message": "battle 节点 %s 缺深度" % str(node.get("id", i))})
var nid: int = int(node.get("id", i))
if by_id.has(nid):
errs.append({"category": "dup_key", "message": "节点句柄重复: %d" % nid})
by_id[nid] = node
if not indeg.has(nid):
indeg[nid] = 0
return {"errs": errs, "by_id": by_id, "indeg": indeg}
# 出边良构+无环判据(④):目标句柄存在、目标深度严格大于源;同时产出去边表与入度计数
static func _check_edges(by_id: Dictionary, indeg: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var outs := {}
for nid in by_id.keys():
outs[nid] = []
for nid in by_id.keys():
for tid in (by_id[nid]["next"] as Array):
if not by_id.has(int(tid)):
errs.append({"category": "dangling_next", "message": "节点 %d 出边指向不存在句柄: %s" % [nid, tid]})
continue
if int(by_id[int(tid)]["depth"]) <= int(by_id[nid]["depth"]):
errs.append({"category": "structural", "message": "连线 %d→%d 违反前进方向(目标深度须严格大于源)" % [nid, int(tid)]})
outs[nid].append(int(tid))
indeg[int(tid)] = int(indeg[int(tid)]) + 1
return {"errs": errs, "outs": outs, "indeg": indeg}
# 检①+检③:入口(无入边)≥1、终点(无出边)≥1、终点恒为 Boss 且 Boss 无出边
static func _check_terminals(by_id: Dictionary, outs: Dictionary, indeg: Dictionary) -> Dictionary:
var errs: Array[Dictionary] = []
var entries := []
var terminals := []
for nid in by_id.keys():
if int(indeg[nid]) == 0:
entries.append(nid)
if not (outs[nid] as Array).is_empty():
if String(by_id[nid]["type"]) == "boss":
errs.append({"category": "structural", "message": "Boss 节点 %d 带出边(终点不可外出)" % nid})
continue
if not nexts[i].is_empty():
errs.append({"category": "structural", "message": "level %d Boss 节点 %d 带出边(终点不可外出)" % [L, ids[i]]})
if is_graph and ids[i] != ids.max():
errs.append({"category": "structural", "message": "level %d Boss 编号 %d 非最大 %d(终点编号最大)" % [L, ids[i], ids.max()]})
if entries.size() == 1:
# ⑥ 从入口可达全部(无孤儿)· ⑦ 全部节点可达 Boss(逆向)——邻接表统一下标空间
var fwd := []
var back := []
for i in nodes.size():
fwd.append([])
back.append([])
for i in nodes.size():
for tid in nexts[i]:
if id_to_idx.has(tid):
fwd[i].append(id_to_idx[tid])
back[id_to_idx[tid]].append(i)
var reach := _reachable(entries[0], fwd, nodes.size())
for i in nodes.size():
if not reach.has(i):
errs.append({"category": "structural", "message": "level %d 节点 %d 从入口不可达(孤儿节点)" % [L, ids[i]]})
var boss_idx := -1
for i in nodes.size():
if String(nodes[i].get("type", "")) == "boss":
boss_idx = i
if boss_idx >= 0:
var to_boss := _reachable(boss_idx, back, nodes.size())
for i in nodes.size():
if not to_boss.has(i):
errs.append({"category": "structural", "message": "level %d 节点 %d 沿连线不可达 Boss" % [L, ids[i]]})
# ⑧ U2 目检规则:BFS 同深度层节点数上限 5(分支宽度天花板)
var layer_of := {}
var bfs: Array[Variant] = [entries[0]]
layer_of[entries[0]] = 0
while not bfs.is_empty():
var cur: int = bfs.pop_front()
for tid in nexts[cur]:
var tidx: int = int(id_to_idx.get(tid, -1))
if tidx >= 0 and not layer_of.has(tidx):
layer_of[tidx] = int(layer_of[cur]) + 1
bfs.append(tidx)
var width := {}
for i in nodes.size():
if layer_of.has(i):
width[layer_of[i]] = int(width.get(layer_of[i], 0)) + 1
for d in width.keys():
if int(width[d]) > 5:
errs.append({"category": "structural", "message": "level %d BFS 第 %d 层 %d 节点(同层上限 5——U2 目检规则)" % [L, d, int(width[d])]})
terminals.append(nid)
if String(by_id[nid]["type"]) != "boss":
errs.append({"category": "structural", "message": "终点 %d 非 Boss(终点恒为 Boss)" % nid})
if entries.is_empty():
errs.append({"category": "structural", "message": "入口节点数 = 0(须 ≥1)"})
if terminals.is_empty():
errs.append({"category": "structural", "message": "终点节点数 = 0(须 ≥1)"})
return {"errs": errs, "entries": entries, "terminals": terminals}
# 检②:全部节点从某入口可达且可达某终点(无孤儿、无断头)
static func _check_reachability(by_id: Dictionary, outs: Dictionary, entries: Array, terminals: Array) -> Array:
var errs: Array[Dictionary] = []
var from_entry := {}
for eid in entries:
_mark_reachable(int(eid), outs, from_entry)
var to_terminal := {}
for tid in terminals:
_mark_backward(int(tid), outs, to_terminal)
for nid in by_id.keys():
if not from_entry.has(nid):
errs.append({"category": "structural", "message": "节点 %d 从任何入口都不可达(孤儿)" % nid})
if not to_terminal.has(nid):
errs.append({"category": "structural", "message": "节点 %d 沿连线到不了任何终点(断头)" % nid})
return errs
# BFS 可达集(邻接表 adj: 节点下标 → 下标数组)
func _reachable(start: int, adj: Array, n: int) -> Dictionary:
var seen := {}
if start < 0 or start >= n:
return seen
# 正向可达集(含起点)
static func _mark_reachable(start: int, outs: Dictionary, seen: Dictionary) -> void:
var queue: Array[Variant] = [start]
seen[start] = true
while not queue.is_empty():
var cur: int = queue.pop_front()
if cur >= adj.size():
continue
for nb in adj[cur]:
if not seen.has(nb):
seen[nb] = true
queue.append(nb)
return seen
for tid in outs[cur]:
if not seen.has(int(tid)):
seen[int(tid)] = true
queue.append(int(tid))
# 逆向可达集(含起点)——反向邻接由 outs 现推(图规模小,禁第二份邻接表持有)
static func _mark_backward(target: int, outs: Dictionary, seen: Dictionary) -> void:
var queue: Array[Variant] = [target]
seen[target] = true
while not queue.is_empty():
var cur: int = queue.pop_front()
for nid in outs.keys():
if (outs[nid] as Array).has(cur) and not seen.has(int(nid)):
seen[int(nid)] = true
queue.append(int(nid))
+156
View File
@@ -70,6 +70,32 @@ rows = [{
"shave_ring_radius": 1,
"r0_max_cap": 43
}, {
"level_index": 1,
"node_depth": 5,
"board_width": 12,
"board_height": 12,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 60,
"max_gen_attempts": 24,
"board_type": "dungeon",
"shave_ring_radius": 1,
"r0_max_cap": 43
}, {
"level_index": 1,
"node_depth": 6,
"board_width": 12,
"board_height": 12,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 60,
"max_gen_attempts": 24,
"board_type": "dungeon",
"shave_ring_radius": 1,
"r0_max_cap": 43
}, {
"level_index": 2,
"node_depth": 0,
"board_width": 12,
@@ -97,6 +123,32 @@ rows = [{
"r0_max_cap": 43
}, {
"level_index": 2,
"node_depth": 2,
"board_width": 12,
"board_height": 12,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 60,
"max_gen_attempts": 24,
"board_type": "underdark",
"shave_ring_radius": 1,
"r0_max_cap": 43
}, {
"level_index": 2,
"node_depth": 3,
"board_width": 12,
"board_height": 12,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 60,
"max_gen_attempts": 24,
"board_type": "underdark",
"shave_ring_radius": 1,
"r0_max_cap": 43
}, {
"level_index": 2,
"node_depth": 4,
"board_width": 12,
"board_height": 12,
@@ -149,6 +201,19 @@ rows = [{
"r0_max_cap": 43
}, {
"level_index": 2,
"node_depth": 8,
"board_width": 12,
"board_height": 12,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 60,
"max_gen_attempts": 24,
"board_type": "underdark",
"shave_ring_radius": 1,
"r0_max_cap": 43
}, {
"level_index": 2,
"node_depth": 9,
"board_width": 12,
"board_height": 12,
@@ -188,6 +253,32 @@ rows = [{
"r0_max_cap": 51
}, {
"level_index": 3,
"node_depth": 2,
"board_width": 13,
"board_height": 13,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 70,
"max_gen_attempts": 32,
"board_type": "deep_underdark",
"shave_ring_radius": 1,
"r0_max_cap": 51
}, {
"level_index": 3,
"node_depth": 3,
"board_width": 13,
"board_height": 13,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 70,
"max_gen_attempts": 32,
"board_type": "deep_underdark",
"shave_ring_radius": 1,
"r0_max_cap": 51
}, {
"level_index": 3,
"node_depth": 4,
"board_width": 13,
"board_height": 13,
@@ -240,6 +331,19 @@ rows = [{
"r0_max_cap": 51
}, {
"level_index": 3,
"node_depth": 8,
"board_width": 13,
"board_height": 13,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 70,
"max_gen_attempts": 32,
"board_type": "deep_underdark",
"shave_ring_radius": 1,
"r0_max_cap": 51
}, {
"level_index": 3,
"node_depth": 9,
"board_width": 14,
"board_height": 14,
@@ -251,6 +355,58 @@ rows = [{
"board_type": "deep_underdark",
"shave_ring_radius": 1,
"r0_max_cap": 59
}, {
"level_index": 3,
"node_depth": 10,
"board_width": 14,
"board_height": 14,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 82,
"max_gen_attempts": 32,
"board_type": "deep_underdark",
"shave_ring_radius": 1,
"r0_max_cap": 59
}, {
"level_index": 3,
"node_depth": 11,
"board_width": 14,
"board_height": 14,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 82,
"max_gen_attempts": 32,
"board_type": "deep_underdark",
"shave_ring_radius": 1,
"r0_max_cap": 59
}, {
"level_index": 3,
"node_depth": 12,
"board_width": 14,
"board_height": 14,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 82,
"max_gen_attempts": 32,
"board_type": "deep_underdark",
"shave_ring_radius": 1,
"r0_max_cap": 59
}, {
"level_index": 3,
"node_depth": 13,
"board_width": 14,
"board_height": 14,
"mine_density": 0.25,
"inferability_target": 0.88,
"solver_rules": "single_subset",
"min_safe_count": 82,
"max_gen_attempts": 32,
"board_type": "deep_underdark",
"shave_ring_radius": 1,
"r0_max_cap": 59
}]
; 切片调参登记(SLICE-TUNED provisional,调参所有权仍归 C3):
+224
View File
@@ -99,6 +99,31 @@ rows = [{
"armor_class": 2
}, {
"level_index": 1,
"row_kind": "battle",
"depth_band": 6,
"enemy_id": "bugbear",
"display_name": "熊地精",
"unit_count": 1,
"unit_count_max": 2,
"hp": 30,
"atk": 1,
"atk_min": 0,
"atk_max": 2,
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "charge",
"weight": 2
}, {
"skill_id": "heavy",
"weight": 2
}],
"attack_type": "normal",
"armor_type": "light",
"armor_class": 4
}, {
"level_index": 1,
"row_kind": "boss",
"depth_band": 4,
"enemy_id": "ogre_chief",
@@ -126,6 +151,34 @@ rows = [{
"armor_type": "light",
"armor_class": 8
}, {
"level_index": 1,
"row_kind": "boss",
"depth_band": 6,
"enemy_id": "ogre_chief",
"display_name": "食人魔酋长",
"unit_count": 1,
"hp": 88,
"atk": 1,
"atk_min": 0,
"atk_max": 2,
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "heavy",
"weight": 2
}, {
"skill_id": "harden",
"weight": 2
}, {
"skill_id": "quake_smash",
"weight": 2,
"unlock_hp_pct": 50
}],
"attack_type": "normal",
"armor_type": "light",
"armor_class": 8
}, {
"level_index": 2,
"row_kind": "battle",
"depth_band": 0,
@@ -308,6 +361,65 @@ rows = [{
"armor_class": 3
}, {
"level_index": 2,
"row_kind": "battle",
"depth_band": 9,
"enemy_id": "giant_spider",
"display_name": "巨蜘蛛巢群",
"unit_count": 2,
"unit_count_max": 3,
"hp": 40,
"atk": 2,
"atk_min": 1,
"atk_max": 3,
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "bite",
"weight": 2
}, {
"skill_id": "charge",
"weight": 1
}, {
"skill_id": "venom_spit",
"weight": 2
}],
"attack_type": "normal",
"armor_type": "light",
"armor_class": 3
}, {
"level_index": 2,
"row_kind": "boss",
"depth_band": 8,
"enemy_id": "drider",
"display_name": "蛛化卓尔精灵",
"unit_count": 1,
"hp": 112,
"atk": 3,
"atk_min": 2,
"atk_max": 4,
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "heavy",
"weight": 2
}, {
"skill_id": "venom_burst",
"weight": 2
}, {
"skill_id": "web_snare",
"weight": 2
}, {
"skill_id": "venom_curb",
"weight": 2,
"unlock_hp_pct": 50
}],
"attack_type": "normal",
"armor_type": "ward",
"armor_class": 10
}, {
"level_index": 2,
"row_kind": "boss",
"depth_band": 9,
"enemy_id": "drider",
@@ -495,6 +607,34 @@ rows = [{
"armor_class": 4
}, {
"level_index": 3,
"row_kind": "battle",
"depth_band": 13,
"enemy_id": "duergar",
"display_name": "灰矮人小队",
"unit_count": 2,
"unit_count_max": 3,
"hp": 42,
"atk": 3,
"atk_min": 2,
"atk_max": 4,
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "charge",
"weight": 2
}, {
"skill_id": "heavy",
"weight": 2
}, {
"skill_id": "shield_bash",
"weight": 2
}],
"attack_type": "normal",
"armor_type": "heavy",
"armor_class": 4
}, {
"level_index": 3,
"row_kind": "boss",
"depth_band": 9,
"enemy_id": "duergar_king",
@@ -521,6 +661,90 @@ rows = [{
"attack_type": "normal",
"armor_type": "heavy",
"armor_class": 12
}, {
"level_index": 3,
"row_kind": "boss",
"depth_band": 11,
"enemy_id": "duergar_king",
"display_name": "灰矮人石君王",
"unit_count": 1,
"hp": 150,
"atk": 3,
"atk_min": 2,
"atk_max": 4,
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "heavy",
"weight": 2
}, {
"skill_id": "earth_pulse",
"weight": 2
}, {
"skill_id": "rockslide",
"weight": 2,
"unlock_hp_pct": 50
}],
"attack_type": "normal",
"armor_type": "heavy",
"armor_class": 12
}, {
"level_index": 3,
"row_kind": "boss",
"depth_band": 12,
"enemy_id": "duergar_king",
"display_name": "灰矮人石君王",
"unit_count": 1,
"hp": 162,
"atk": 3,
"atk_min": 2,
"atk_max": 4,
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "heavy",
"weight": 2
}, {
"skill_id": "earth_pulse",
"weight": 2
}, {
"skill_id": "rockslide",
"weight": 2,
"unlock_hp_pct": 50
}],
"attack_type": "normal",
"armor_type": "heavy",
"armor_class": 12
}, {
"level_index": 3,
"row_kind": "boss",
"depth_band": 13,
"enemy_id": "duergar_king",
"display_name": "灰矮人石君王",
"unit_count": 1,
"hp": 176,
"atk": 3,
"atk_min": 2,
"atk_max": 4,
"pool": [{
"skill_id": "attack",
"weight": 6
}, {
"skill_id": "heavy",
"weight": 2
}, {
"skill_id": "earth_pulse",
"weight": 2
}, {
"skill_id": "rockslide",
"weight": 2,
"unlock_hp_pct": 50
}],
"attack_type": "normal",
"armor_type": "heavy",
"armor_class": 12
}]
; 切片调参登记 v3(2026-09-12 试玩批次 1 触发——F7 流程;C3 GDD F5 对照):
+6 -15
View File
@@ -6,20 +6,11 @@
script = ExtResource("1")
info_visible_layers = 2
rows = [
{"level_index": 1, "display_name": "第一层 · 地精洞窟", "rest_heal_pct": 30, "templates": [{"display_name": "地精洞窟·主线洞道(现行)", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"node_depth": 3, "type": "battle"}, {"type": "reward"}, {"type": "rest"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·先赏后整", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "reward"}, {"type": "rest"}, {"node_depth": 3, "type": "battle"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·先整后赏", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "rest"}, {"type": "reward"}, {"node_depth": 3, "type": "battle"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·赏战整", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "reward"}, {"node_depth": 3, "type": "battle"}, {"type": "rest"}, {"node_depth": 4, "type": "boss"}]}, {"display_name": "地精洞窟·整战赏", "nodes": [{"node_depth": 0, "type": "battle"}, {"node_depth": 1, "type": "battle"}, {"node_depth": 2, "type": "battle"}, {"type": "rest"}, {"node_depth": 3, "type": "battle"}, {"type": "reward"}, {"node_depth": 4, "type": "boss"}]}]},
{"level_index": 2, "display_name": "第二层 · 幽暗地域", "rest_heal_pct": 30, "templates": [{"display_name": "宽廊双干(现行)", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干中廊", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "宽廊斜岔", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干宽门", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6, 7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "窄廊短支", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5]}, {"node_depth": 3, "type": "rest", "next": [5, 6]}, {"node_depth": 4, "type": "battle", "next": [6]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}]},
{"level_index": 3, "display_name": "第三层 · 灰矮人矿城", "rest_heal_pct": 30, "templates": [{"display_name": "宽廊双干(现行)", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干中廊", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6]}, {"node_depth": 4, "type": "battle", "next": [6, 7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "宽廊斜岔", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 5]}, {"node_depth": 2, "type": "reward", "next": [4, 6]}, {"node_depth": 3, "type": "rest", "next": [7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "三干宽门", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [4, 5]}, {"node_depth": 3, "type": "rest", "next": [6, 7]}, {"node_depth": 4, "type": "battle", "next": [7]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [8]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}, {"display_name": "窄廊短支", "nodes": [{"node_depth": 0, "type": "battle", "next": [1, 2]}, {"node_depth": 1, "type": "battle", "next": [3, 4]}, {"node_depth": 2, "type": "reward", "next": [5]}, {"node_depth": 3, "type": "rest", "next": [5, 6]}, {"node_depth": 4, "type": "battle", "next": [6]}, {"node_depth": 5, "type": "battle", "next": [7]}, {"node_depth": 6, "type": "battle", "next": [7]}, {"node_depth": 7, "type": "battle", "next": [8]}, {"node_depth": 8, "type": "shop", "next": [9]}, {"node_depth": 9, "type": "boss", "next": []}]}]}
{"level_index": 1, "display_name": "第一层 · 地精洞窟", "rest_heal_pct": 30},
{"level_index": 2, "display_name": "第二层 · 幽暗地域", "rest_heal_pct": 30},
{"level_index": 3, "display_name": "第三层 · 灰矮人矿城", "rest_heal_pct": 30}
]
; 关卡节点图生成 v0.4 台阶一内容批(M-3 迭代 5 T5,2026-09-28——15 张骨架 = 3 关 × 5,拍板点④):
; L2/L3 网状图约束(承袭):战斗节点编号恒 ∈ 遭遇表行集 {0,1,4,5,6,7}、Boss 9 最大编号、
; 商店房 8→Boss 必经位承袭;遭遇表按编号共用——零新增敌人数值。L1 五模板 = 单链排列变体
; (战斗深度序单调保难度坡,变化面 = 奖励/恢复位次)。
; 池内全模板加载期校验(level_table.gd validate + config_db 跨表校验模板感知);
; 行为读 LevelRunner._resolve_template_row(node_map 标签恰一 scope 抽签 / template_pick 强制)。
; 生成器 = tools/t5_build_templates.py(生成前自校验,一次性内容批工具随批入库留档)。
; U2 目检返工(M-3 迭代 5 返工批,2026-09-29——U2 目检 15/15 不过两规则缺口,全池重生成):
; ① L1 奖励/休整禁入前三位(用户目检裁决)——五链全部前三位纯战斗,位 4-6 = {战斗3,奖励,休整} 排列;
; ② L2/L3 BFS 同深度层上限 5 且应用足(每张最大层宽 4-5,旧池层宽最大 3 被否);模板 0 一并重排
; (待批点甲按推荐=随新规则重生成——首次冒险/钉固断言期望随新图更新;模板 0 名保持「(现行)」后缀=新现行图锚)。
; 加载校验两规则同落 level_table.gd validate(单链前三纯战斗/图形态层宽 ≤5)。
; 节点图源已换代为参数化生成器(关卡节点图生成 v1.1)——本表逐关行只承载关卡序号/显示名/恢复比例,
; 不再携带节点数据;图 = f(参数档, 种子),参数档见 level_gen_params.tres 三行(消费侧按 level_index 解析后交 generate)。
; 旧 15 张模板池(3 关 × 5)随迭代 35 卡② 清出,历史查 git 提交史与 tools/t5_build_templates.py、t17_fanout2_rebuild.py 留档。
+2 -4
View File
@@ -589,11 +589,9 @@ func _start_adventure() -> void:
logger.start_session(seed_val)
run_scope.adventure.set_hero_override(debug_overlay.atk_pct(), debug_overlay.hp_pct())
# 永久强化注入:冒险开始参数面——meta 现算 override 显式传参(全零 = 白板)
# 首次冒险脚本(关卡节点图生成 v0.4):存档布尔位 false = 全关固定模板 0(教学重放);置位后逐关抽签
# 同调三键随开局下传(build_adventure_params 统一构造,与营地投影同源)
var first_script: bool = not save_manager.is_first_adventure_done()
# 开局参数面(同调三键同源构造——确定性承载=固定种子+参数档,首局教学图由种子域三分判定给 42)
if not run_scope.adventure.start_adventure(seed_val, build_adventure_params(save_manager, meta,
{"first_script": first_script, "treasure_unlocked": _treasure_ui.unlocked_set(), "shop_mode": "pool"})):
{"treasure_unlocked": _treasure_ui.unlocked_set(), "shop_mode": "pool"})):
_fatal_screen("冒险启动失败——见输出日志")
return
+1 -1
View File
@@ -54,7 +54,7 @@ func _ready() -> void:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(420000 + level_index)
var runner = load("res://core/level_runner.gd").new(db, rng)
if not runner.start_level(level_index, 420000 + level_index, {}, 0):
if not runner.start_level(level_index, 420000 + level_index):
push_error("[board_shot] 开关失败 level=%d" % level_index)
get_tree().quit(1)
return
+1 -1
View File
@@ -10,7 +10,7 @@
# 每屏双帧 + frame_post_draw 同步后抓 get_viewport().get_texture(),PNG 落输出目录;
# 最小自检 = 七件存在且非零字节(像素方差断言归 tools/shot_checker.py——B 档骨架分工)。
# 商店房不纳屏(真实路径口径):真实路径必经 L1 打穿→L2 商店节点
# 选路(商店层在 L2 Boss 前必经位)——探针现行驱动只进首战节点,低代价确定性
# 选路(商店按摆放约束恒在终点前一位,每局必经恰一店)——探针现行驱动只进首战节点,低代价确定性
# 到达不成立;合成投喂(ui_struct_probe 同款)破坏本探针真实路径口径 → 不纳屏。
extends Node
+1 -1
View File
@@ -46,7 +46,7 @@ func _ready() -> void:
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(421001)
var runner = load("res://core/level_runner.gd").new(db, rng)
if not runner.start_level(1, 421001, {}, 0):
if not runner.start_level(1, 421001):
push_error("[type_glow_shot] 开局失败")
get_tree().quit(1)
return
+2 -2
View File
@@ -433,7 +433,7 @@ func _ready() -> void:
_seals_placed.clear()
continue
if main.shop_overlay.visible:
# 商店房覆盖层(L2 商店为 Boss 前必经位):零购离开(真实 leave 信号路径)放行选路。
# 商店房覆盖层(摆放约束:每终点前一位恰一店,真实路径恒必经一店):零购离开(真实 leave 信号路径)放行选路。
# 分派链序:商店层=选路页上的模态覆盖(停点呈现为
# level_screen+shop 双 visible)——必须先于 level_screen 分派,否则下层候选点击
# 空转烧尽预算(实证:lv2 商店停点双 visible 循环 12000 帧 done=false)。
@@ -676,7 +676,7 @@ func _ready() -> void:
"master_seed": 42, "rng_counter": 0, "hero_hp": 30, "gp": 10, "keystone": 0,
"level_index": 1, "bonus": {"atk": 5, "max_hp": 0, "armor_class": 0, "ap": 0},
"shop_purchased": {}, "relics": [], "keystone_drops": [], "bonus_taken": {},
"node_id": -1, "visited_ids": [], "template_pick": -1,
"node_id": -1, "visited_ids": [], "graph_gen_v2": 1,
}
main.save_manager.set_breakpoint(bp_ol)
# 判定面=续玩开局清日志后的新局日志(_resume_from_menu 内 log_view.clear()——选路停点形
+2 -1
View File
@@ -257,11 +257,12 @@ func _ready() -> void:
get_tree().quit(1)
# ⑦resume 夹具:最小合法断点快照——bonus_pending 非空=三选一停点分支(合计 gp=24 供守卫兜底断言)
# ⑦resume 夹具:最小合法断点快照(含生成图代差标记键)——bonus_pending 非空=三选一停点分支(合计 gp=24 供守卫兜底断言)
func _resume_bp_fixture() -> Dictionary:
return {
"master_seed": 42, "rng_counter": 0, "hero_hp": 20, "gp": 24, "keystone": 0,
"level_index": 1, "bonus": {}, "shop_purchased": {}, "relics": [],
"graph_gen_v2": 1, # 生成图换代代差标记键(缺键=换代前旧断点,resume_adventure 显式拒绝)
"keystone_drops": [], "bonus_taken": {},
"bonus_pending": {"candidates": [{"kind": "heal", "tier": "common"}], "payload": {"gp_dropped": 0}},
}
+1 -1
View File
@@ -136,7 +136,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", []),
"level_maps": st.get("level_maps", []),
"wallet_count": (st["wallet_events"] as Array).size(),
}
if shop_mode == "on":
+11 -11
View File
@@ -9,7 +9,7 @@
# 策略档 policy:"normal"(默认)/ "perfect"(零盲挖/全封印/卡死刷新——上界测量仪,求解器与
# 动作语义见 auto_player.gd 完美策略档节)。
# 选路驱动(零自动推进):兑付确认/战斗胜利后 runner 进选路态,驱动循环按 route 策略显式
# pick_next_node;route = "min"/"max"(候选节点编号最小/最大——确定性路径轴,零随机流自开;默认 "min")。
# pick_next_node;route = "min"/"max"(候选节点句柄最小/最大——确定性路径轴,零随机流自开;默认 "min")。
# run_adventure 三关连探驱动(AdventureRunner——主种子恰一次、通关兑付停点确认、adventure_finished
# 置 result/summary;外层每关重取 runner 实例经 _drive_level 复用单关驱动)。
# 大奖励停点 = 三选一驱动(候选取首件/替换放弃新件)。
@@ -34,7 +34,7 @@ const NORMAL_SKIP_DIG_P := 0.05
const NORMAL_IDLE_GAMBLE_P := 0.10
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:
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:
var own_session := logger == null
if own_session:
logger = load("res://presentation/playtest_logger.gd").new()
@@ -60,7 +60,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, {}, template_pick):
if not runner.start_level(level_index, seed_val, {}):
st.result = "start_failed"
runner.restore_hero_base()
if own_session:
@@ -138,7 +138,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_
"prng": _policy_rng(seed_val), "mine_slips": 0, "skip_digs": 0, "shop_buys": 0,
"adv": true, # v3 档位行为开关(选路评分/失误注入/商店购买只挂冒险测量面——单关夹具语义不变)
"level_templates": [], # 逐关图模板名(关卡节点图生成 v0.4 分层出数)
"level_maps": [], # 逐关节点图标识(生成图 = 关卡序号+本局主种子——MC 分层出数)
}
adr.step_row.connect(_on_step_row.bind(adr, st, collect_rows))
adr.node_entered.connect(_on_adventure_node_entered.bind(adr, st, logger))
@@ -150,7 +150,7 @@ static func run_adventure(db, seed_val: int, atk_pct: int, hp_pct: int, collect_
adr.adventure_finished.connect(_on_adventure_finished.bind(st, logger))
adr.set_hero_override(atk_pct, hp_pct)
# 同调分层注入(缺省 {} 与既有调用方逐位一致零行为变更)
var adv_params := {"hero_override": hero_override, "first_script": false}
var adv_params := {"hero_override": hero_override}
if not attune.is_empty():
adv_params["attunement"] = attune.get("equipped", [])
adv_params["attune_refresh_y"] = int(attune.get("refresh_y", 0))
@@ -197,9 +197,9 @@ 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 not st.has("level_maps"):
st["level_maps"] = []
st["level_maps"].append("%d#%d" % [int(r.level_row.get("level_index", 0)), int(adr.master_seed)]) # MC 分层出数(关卡×生成图标识)
if t == "battle" or t == "boss":
st.battles += 1
@@ -208,7 +208,7 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr,
if logger != null and r.current_battle() != null:
logger.attach_battle(r.current_battle())
if t == "boss":
st.boss_depth = int(node.get("node_depth", -1))
st.boss_depth = int(node.get("depth", -1))
st.hp_at_boss = int(adr.ledger["hero_hp"])
elif t == "reward":
# 奖励节点三选一(共享池确定性策略;TP0 起 316-f 判读:1 级最稀有、
@@ -376,7 +376,7 @@ static func _policy_rng(seed_val: int) -> RandomNumberGenerator:
# 路径轴选路(目标价值评分制):候选按节点类型评分取最高,
# 同分 tie-break 沿 route 方向(min=小编号/max=大编号——verifier 双窗轴保持)。
# 同分 tie-break 沿 route 方向(min=小句柄/max=大句柄——verifier 双窗轴保持)。
# 评分语义(确定性,零随机流):rest=伤重高值(缺口比例×100)/shop=买得起高值(GP 局内死钱,
# 真人实证两店全购)/reward=占位低值(MC 夹具解锁空集=零兑付直通)/boss=缓冲低值(有店有_rest
# 先补给再打)/battle=基线。编号取 runner.next_candidate_ids()(编号空间解析单一出口)。
@@ -478,7 +478,7 @@ static func _on_node_entered(node: Dictionary, info: Dictionary, runner, st: Dic
if logger != null and runner.current_battle() != null:
logger.attach_battle(runner.current_battle())
if t == "boss":
st.boss_depth = int(node.get("node_depth", -1))
st.boss_depth = int(node.get("depth", -1))
st.hp_at_boss = runner.hero_hp
elif t == "reward":
runner.continue_after_node() # 奖励点 = 内容未定直通节点
@@ -76,7 +76,7 @@ func _feats_breakpoint_roundtrip() -> void:
var live_rows: FeatTable = _db.feats
_db.feats = rt
var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72))
a_r.start_adventure(72, {"first_script": true}) # 源 :723(受理断言留单元宿主)
a_r.start_adventure(72) # 源 :723(受理断言留单元宿主)
a_r._curio_pending = {"candidates": [{"kind": "relic", "tier": "rare", "feat_id": "x3"}, {"kind": "gp", "tier": "common"}], "payload": {}}
a_r.pick_curio("relic", "rare", "x3") # 源 :781(兑付断言留单元宿主)
# 断点往返(持有集+余库 feat_id 随行)
+112 -72
View File
@@ -1,18 +1,21 @@
# tests/integration/node_map_templates_test.gd — L2 集成用例:关卡节点图生成·模板池
# 断言面 = 模板池套(批 B 断言迁移自 smoke_all.gd `_smoke_node_map_templates`,G12 施工组;
# tests/integration/node_map_templates_test.gd — L2 集成用例:关卡节点图生成·确定性承载
# 断言面 = 生成图确定性套(批 B 断言迁移自 smoke_all.gd,G12 施工组;
# 主会话裁决六整套集成不拆——跨四系统且走落盘)。
# 跨系统面 = RngService(node_map 用途标签/抽签确定性/零消耗计账)×
# LevelRunner(start_level_external 受理/模板 0 回滚档)×
# AdventureRunner(first_script 全关零抽签/逐关恰一抽签)×
# 跨系统面 = RngService(纯派生流零计数器占用)×
# LevelRunner(start_level/start_level_external 受理·生成图装配·单一图源)×
# AdventureRunner(开局种子承载·断点写读后同图)×
# SaveManager+MetaProgression(首次冒险布尔位收束置位落盘——真实档写盘)。
# 与单元侧同入口同协议:extends SceneTree+Kit.check 主通道;--script 模式无 autoload,
# ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断展开,红由断言落)。
# ConfigDB 显式实例化构造注入;表加载失败不中止(沿被迁断言面的源语义——诊断展开,红由具体断言落)。
# 运行:godot --headless --path game --script res://tests/integration/node_map_templates_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:
@@ -24,84 +27,121 @@ func _init() -> void:
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_node_map_templates()
_smoke_generated_map_determinism()
quit(Kit.finish("node_map_templates_test"))
# ---- 关卡节点图生成·模板池冒烟(v0.4 §P0.6 断言面)----
func _smoke_node_map_templates() -> void:
# ① 扩签:node_map 入用途标签封闭词表(派生零改动——标签只进事件日志)
var svc = load("res://core/rng_service.gd").new()
Kit.check("node_map 标签入 rng_service 封闭词表(扩签通道=改代码+冒烟守卫)",
(svc.PURPOSE_TAGS as Array).has("node_map"))
# ② 池参数:三关各 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", [])
Kit.check("L%d 模板池 = 5 张(拍板点④——首版池规模)" % li, tpls.size() == 5)
Kit.check("L%d 模板 0 = 现行固定图(回滚档成员)" % li,
not tpls.is_empty() and String(tpls[0].get("display_name", "")).ends_with("(现行)"))
# ③ 强制取 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()
Kit.check("强制 template_pick=0 起关受理", runner0.start_level_external(2, {"hero_hp": 20, "gp": 0}, {}, # start_level_external 签名收口(bonus 参数由 RunLedger 单类下传)
Callable(self, "_smoke_rest_stub"), {}, true, 0))
Kit.check("强制取模板零 RNG 消耗(零抽签——首次脚本语义)", rng0.counter() == c_before)
var tpl0: Dictionary = {}
for r in _db.level.rows:
if int(r.level_index) == 2:
tpl0 = r.get("templates", [])[0]
Kit.check("强制 0 的生效节点表 = 模板 0 逐位相等(回滚档成立)",
runner0.level_row.get("nodes", []) == tpl0.get("nodes", []))
# ④ 抽签确定性:同种子两实例同模板(同种子同操作序列 → 逐位同图)
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)
Kit.check("抽签确定性:同种子逐位同图(实测 %s)" % String(names[0]),
names[0] == names[1] and String(names[0]) != "")
# ⑤ N=1 池与现行固定图等价:单模板行过图校验(抽签取模 1 恒 0 = 回滚档)
var lt = load("res://data/schemas/level_table.gd").new()
lt.rows.assign([{"level_index": 9, "display_name": "t", "rest_heal_pct": 30,
"templates": [tpl0]}])
Kit.check("N=1 池表通过图校验(回滚档形态合法)", lt.validate().is_empty())
# ⑥ 首次脚本分支: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})
Kit.check("首次脚本模式:L1 取模板 0(全关零抽签——存档布尔位 false 语义)",
String(adrF.runner().active_template_name).ends_with("(现行)"))
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})
Kit.check("抽签模式恰多消费 1 scope(node_map 逐关恰一——L1 起关抽签)", rngT.counter() == cF + 1)
Kit.check("抽签模式 active_template_name 有值(MC 分层出数取数面在位)",
String(adrT.runner().active_template_name) != "")
# ---- 关卡节点图生成·确定性承载断言面 ----
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 恰一次(幂等)
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")
var sv = SaveManagerScript.new(_bp_path + ".flag.json")
sv.load_from_disk()
Kit.check("新档首次冒险布尔位 = false(默认域)", sv.is_first_adventure_done() == false)
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")
# ---- 冒烟恢复执行体(LevelRunner 注入用——与 AdventureRunner.apply_rest 同式占位;
# 图数据良构前置(防空集假绿——节点数 > 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)
+2 -1
View File
@@ -54,6 +54,7 @@ func _suites() -> Array:
{"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/level_table_validate_test.gd", "expect": 0}, # 消费侧加载校验四检+良构基底正负向(卡② T1/V1〔v1〕)
{"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 读表+续玩同值)
@@ -86,7 +87,7 @@ func _suites() -> Array:
{"path": "res://tests/unit/presentation/modal_flow_test.gd", "expect": 0}, # 模态编排纯逻辑(V6:直接 new/互斥域谓词/三例外锁/演播后排窗)
# ---- L2 集成层(跨系统用例,与 unit/ 的隔离断言面分工)----
{"path": "res://tests/integration/save_rng_roundtrip_test.gd", "expect": 0}, # 存档⇄RNG 快照往返(SaveManager 迁移链 × 断点消费 × RNG 续跑 × 停点态确定性)
{"path": "res://tests/integration/node_map_templates_test.gd", "expect": 0}, # 关卡节点图生成·模板池(模板池跨系统×首冒险置位落盘——批 B 自 smoke_all 迁入,裁决六整套集成不拆)
{"path": "res://tests/integration/node_map_templates_test.gd", "expect": 0}, # 关卡节点图生成·确定性承载(生成图复现×首冒险置位落盘——批 B 自 smoke_all 迁入,裁决六整套集成不拆)
{"path": "res://tests/integration/cross_system_flow_test.gd", "expect": 0}, # 跨系统真实流×落盘(RNG 事件日志落盘/专长断点往返/存档摘要集成/试玩记录仪真实结算流——批 B 转办块宿主,裁决一/二/三/七)
# ---- core 四件与呈现层两件的直接单测 ----
{"path": "res://tests/unit/core/board_generator_test.gd", "expect": 0}, # 棋盘生成(尺寸合表/数字=八邻雷数/同种子逐位一致/可推理自洽)
@@ -290,7 +290,7 @@ func _smoke_encounter_roll() -> void:
# 战斗结算 roll_lo 下限 + 敌人系统 spawn count<1 fail-fast 双闸;单深度单种子是盲区,全域扫描才钉死不变式)
var depths: Array = []
for node in _level_battle_nodes(1):
depths.append(int(node.get("node_depth", -1)))
depths.append(int(node.get("depth", -1)))
var scan_bad := 0
var scan_total := 0
for i in range(12):
@@ -317,22 +317,11 @@ func _smoke_encounter_roll() -> void:
# 节点类型 → 敌人表 row_kind(非 Boss 战斗位恒 battle——深度带批寻址第三维)
func _row_kind_of(level_index: int, depth: int) -> String:
for n in _level_active_nodes(level_index):
if int(n.get("node_depth", -1)) == depth and String(n.get("type", "")) == "boss":
if int(n.get("depth", -1)) == depth and String(n.get("type", "")) == "boss":
return "boss"
return "battle"
# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标)
# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标)
func _boss_depth(level_index: int) -> int:
for n in _level_active_nodes(level_index):
if String(n.get("type", "")) == "boss":
return int(n.get("node_depth", -1))
return -1
# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字
@@ -350,18 +339,12 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int:
return best
# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义)
# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义)
# 生成图取数面(图 = f(关卡行参数档, 种子 42)——与 runner 起关同图;节点键 id/type/depth/next)
func _level_active_nodes(level_index: int) -> Array:
for row in _db.level.rows:
if int(row.level_index) == level_index:
var tpls: Array = row.get("templates", [])
if not tpls.is_empty():
return tpls[0].get("nodes", [])
return row.get("nodes", [])
return []
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(42)
var gen = NodeMapGenerator.new(rng)
return (gen.generate(_db.level_gen_params_row(level_index), 42) as Dictionary).get("nodes", [])
func _level_battle_nodes(level_index: int) -> Array:
@@ -596,6 +579,16 @@ func _smoke_skill_pool_data() -> void:
"3_battle_6_hook_horror": {"attack": 6, "bite": 2, "charge": 1},
"3_battle_7_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2},
"3_boss_9_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
# 迭代 35 生成图深度扩面覆盖批八行=同关同 row_kind 顶档行的池克隆(零新池、零新技能引用):
# 战斗带顶 L1→6/L2→9/L3→13;Boss 按终点深度分带 L1→6、L2→8、L3→11/12/13
"1_battle_6_bugbear": {"attack": 6, "charge": 2, "heavy": 2},
"1_boss_6_ogre_chief": {"attack": 6, "heavy": 2, "harden": 2, "quake_smash": 2},
"2_battle_9_giant_spider": {"attack": 6, "bite": 2, "charge": 1, "venom_spit": 2},
"2_boss_8_drider": {"attack": 6, "heavy": 2, "venom_burst": 2, "web_snare": 2, "venom_curb": 2},
"3_battle_13_duergar": {"attack": 6, "charge": 2, "heavy": 2, "shield_bash": 2},
"3_boss_11_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
"3_boss_12_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
"3_boss_13_duergar_king": {"attack": 6, "heavy": 2, "earth_pulse": 2, "rockslide": 2},
}
var pools_ok := true
for row in _db.enemy.rows:
@@ -608,7 +601,7 @@ func _smoke_skill_pool_data() -> void:
got[String(entry.skill_id)] = int(entry.weight)
if got != expect_pools[rk]:
pools_ok = false
Kit.check("二十行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕,其余带键仍恰一行)", pools_ok)
Kit.check("二十八行九种池模板 = 建议池行值((L,row_kind,depth_band,enemy_id) 四元键逐行精确比对;L1 地精 9/1 · 熊地精 6/2/2 · 酋长 6/2/2;L2 熊地精 6/1/2 · 蜘蛛 6/2/1+毒吐2〔首遇带不投——迭代 24 N2〕 · 重击手 5/2/2 · 蛛化卓尔精灵 6/2/2/2〔Boss〕;L3 灰矮人 6/2/2+盾击2 · 钩爪恐魔 6/2/1 · 重装手 5/2/2+盾击2 · 石君王 6/2/2〔earth_pulse〕——全部 provisional;三 Boss 池尾 +1 门控条目〔quake_smash/venom_curb/rockslide 权重 2+unlock_hp_pct 50——迭代14④ T5〕;混编行集同带多行各一池合法——首批投放 (2,battle,5) 双行〔迭代 18 卡⑤:重击手+巢群〕;迭代 35 覆盖批八行为顶档池克隆,逐行同检)", pools_ok)
var basic_count_ok := true
for row in _db.enemy.rows:
var n := 0
+113 -36
View File
@@ -58,7 +58,7 @@ func _smoke_adventure_api() -> void:
adr.level_started.connect(_api_mark_started.bind(sig_seq))
adr.choices_presented.connect(_api_mark_choices.bind(sig_seq, obs))
adr.node_entered.connect(_api_mark_node.bind(sig_seq))
Kit.check("冒险启动受理(主种子注入恰一次——state=running;first_script=固定模板 0——精确路径断言需要现行图,零抽签)", adr.start_adventure(42, {"first_script": true}))
Kit.check("冒险启动受理(主种子注入恰一次——state=running;种子 42=首局固定种子生成图——精确路径断言需要确定图(卡② 代差))", adr.start_adventure(42))
Kit.check("进行中重复 start_adventure 拒绝(重开须待结算完毕)", not adr.start_adventure(43))
Kit.check("停点前移次序契约:level_started 先于入口候选呈现(反序选路口会被呈现层 runner 空防御丢弃)", sig_seq.size() == 2 and String(sig_seq[0]) == "level_started" and String(sig_seq[1]) == "choices_presented")
Kit.check("入口停点:选路态 + 入口单候选 + 首战未开(呈现零 RNG 消耗——pick 前零战斗域)",
@@ -108,26 +108,27 @@ func _api_mark_node(_node: Dictionary, _info: Dictionary, seq: Array) -> void:
func _smoke_adventure() -> void:
print("--- 冒险结构验收(三关序列 · 大奖励 · 摘要五件事 · 账本封闭)---")
var autoplay = load("res://sim/level_autoplay.gd")
# ① 表与覆盖义务(序列关数 = 行集关数;行集内每个战斗/Boss 节点跨表精确行集——覆盖义务断言;
# enemy 侧键域 = (level_index, row_kind, depth_band)——战斗节点查 battle 键域、Boss 节点查 boss 键域,
# 同带多行合法,带键聚合天然兼容〔fv24〕)
# ① 表与覆盖义务(序列关数 = 行集关数;节点源 = 参数档在固定种子下的生成图,
# 每个战斗/Boss 节点经 ConfigDB 真实解析链查敌行集与棋盘行——顶 clamp 消解禁〔fv24〕)
Kit.check("序列关数 = 行集关数(三关正式序列生效)", int(_db.level.rows.size()) == 3)
var bg_keys := {}
for row in _db.board_gen.rows:
bg_keys["%d_%d" % [int(row.level_index), int(row.node_depth)]] = true
var en_keys := {}
for row in _db.enemy.rows:
en_keys["%d_%s_%d" % [int(row.level_index), String(row.get("row_kind", "")), int(row.get("depth_band", -1))]] = true
var miss := 0
for row in _db.level.rows:
for n in _level_active_nodes(int(row.level_index)):
var cov_miss := 0
var cov_scan := 0
for li in range(1, int(_db.level.rows.size()) + 1):
for n in _level_active_nodes(li):
var nt := String(n.get("type", ""))
if nt != "battle" and nt != "boss":
continue
var key := "%d_%s_%d" % [int(row.level_index), nt, int(n.get("node_depth", -1))]
if not bg_keys.has("%d_%d" % [int(row.level_index), int(n.get("node_depth", -1))]) or not en_keys.has(key):
miss += 1
Kit.check("行集内每个战斗/Boss 节点在 board_gen + enemy 有精确行集(缺失 %d——带键 (L,row_kind,depth_band))" % miss, miss == 0)
var d: int = int(n.get("depth", -1))
cov_scan += 1
var rk := "boss" if nt == "boss" else "battle"
var rs: Array[Dictionary] = _db.resolve_enemy_rowset(li, d, rk)
if rs.is_empty() or int(rs[0].get("depth_band", -1)) < d:
cov_miss += 1
if not (_db.resolve_board_gen(li, d) as Dictionary).is_empty():
continue
cov_miss += 1
Kit.check("生成图每个战斗/Boss 节点经解析链命中敌行集与棋盘行(扫描 %d 节点,缺失 %d)〔fv24〕" % [cov_scan, cov_miss],
cov_scan > 0 and cov_miss == 0)
# ② 满配通关 + ④ 三层奖励兑付(三选一恰 2 次 · 回血封顶 · 钥石逐杀 · 候选零重复)
var st: Dictionary = autoplay.run_adventure(_db, 42, 900, 400)
var sum: Dictionary = st["summary"]
@@ -151,7 +152,44 @@ func _smoke_adventure() -> void:
elif (ev as Dictionary).has("curio_resolved"):
resolves.append((ev as Dictionary)["curio_resolved"])
Kit.check("通关三选一恰呈现 2 次、恰兑付 2 次(L1/L2 后——末关不发;resolves 含节点回执另计)", picks.size() == 2 and resolves.size() == 2 + node_picks)
Kit.check("奖励节点三选一 ≥1 次(L1 链必经 1 个;L2/L3 图可选路经若干——实际经停 %d)" % node_picks, node_picks >= 1)
# 奖励节点在生成图里是可选路位(旧单链模板的「L1 必经 1 个」形状前提随模板消亡)——
# 接线证明拆两半:图侧每关含 ≥1 奖励节点,行进侧种子扫描内确有经停(扫描零经停=三选一接线不可达)
var reward_levels := 0
for li in range(1, int(_db.level.rows.size()) + 1):
var rc := 0
for n in _level_active_nodes(li):
if String(n.get("type", "")) == "reward":
rc += 1
if rc >= 1:
reward_levels += 1
Kit.check("三关生成图各含 ≥1 奖励节点(节点三选一候选位在图)〔v4〕", reward_levels == 3)
# 行进侧两断言走真实选路入口(满配赢下途中战→岔口按偏好显式点选):奖励支路经停即呈三选一、
# 进战载荷带节点深度——确定性评分策略恒选推进支路,奖励位不在扫描路由上,故显式点选验证接线
var rw: Dictionary = _drive_to_pref(autoplay, "reward")
Kit.check("奖励分支点选→三选一候选可取且停点在途(候选 %s 件)" % str((rw.get("cands", []) as Array).size()),
bool(rw.get("pending", false)) and (rw.get("cands", []) as Array).size() >= 1)
var rw_pc: Dictionary = {}
for c in (rw.get("cands", []) as Array):
var cd := c as Dictionary
if String(cd.get("kind", "")) != "feat_ticket":
rw_pc = cd
break
if rw_pc.is_empty() and not (rw.get("cands", []) as Array).is_empty():
rw_pc = (rw.get("cands", []) as Array)[0] as Dictionary
var rw_pick_ok := false
if not rw_pc.is_empty() and rw.get("adr") != null:
rw_pick_ok = rw["adr"].apply_node_curio(String(rw_pc.get("kind", "")), String(rw_pc.get("tier", "")), String(rw_pc.get("feat_id", "")))
Kit.check("奖励三选一点选兑付受理(件=%s)" % String(rw_pc.get("kind", "")), rw_pick_ok)
var enc_run: Dictionary = _drive_to_pref(autoplay, "battle")
var enc_probe: Dictionary = enc_run.get("enc", {}) as Dictionary
var node_probe: Dictionary = enc_run.get("node", {}) as Dictionary
# 定位信息两样(契约 2):战斗侧收到的 node_depth=节点深度,且命中节点句柄≠深度——
# 变异「enc 取句柄」必红(V6)
Kit.check("进战载荷两样=关卡序号+节点深度(实测 depth=%s 句柄=%s)〔v6〕" % [str(enc_probe.get("node_depth", -1)), str(node_probe.get("id", -1))],
not enc_probe.is_empty()
and int(enc_probe.get("node_depth", -1)) == int(node_probe.get("depth", -1))
and int(enc_probe.get("level_index", -1)) == 1
and int(node_probe.get("id", -1)) != int(node_probe.get("depth", -1)))
var node_resolves := 0
for r0 in resolves:
if String((r0 as Dictionary).get("context", "")) == "reward_node":
@@ -260,7 +298,7 @@ func _smoke_adventure() -> void:
# ⑥ 重复宣布拒绝(settle 后再调 _on_level_finished——摘要零变化)
var adr6 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(7))
adr6.set_hero_override(900, 400)
Kit.check("重复拒绝场景:冒险启动(seed 7——固定模板 0 同上)", adr6.start_adventure(7, {"first_script": true}))
Kit.check("重复拒绝场景:冒险启动(seed 7——生成图确定同上)", adr6.start_adventure(7))
adr6.runner().pick_next_node(int(adr6.runner().next_candidate_ids()[0])) # 入口停点 pick 进首战
var b6 = adr6.runner().current_battle()
var st6 := _adv_st()
@@ -284,13 +322,13 @@ func _smoke_adventure() -> void:
Kit.check("每战完成入账恰一次(gain %d = battles %d——GP 袋件入账单列)" % [gains, int(st["battles"])], gains == int(st["battles"]))
Kit.check("钱包 clear 恰一次且为末事件(关卡收束不清——GP 冒险收束清零归冒险结构)", clears == 1 and wallet.size() > 0 and String(wallet[wallet.size() - 1]["event"]) == "clear")
var shop_seq: Array = st["shop_gp_seq"]
Kit.check("L2 商店房到达余额 > 0(%d——L1 积累跨关延续;两关商店房必经各 1)" % (shop_seq[0] if shop_seq.size() > 0 else -1), shop_seq.size() == 2 and int(shop_seq[0]) > 0)
Kit.check("三关商店房各到达一次(每关入口→终点路径恰穿一店的驱动投影)且首达余额 > 0(%d——GP 跨关延续)" % (shop_seq[0] if shop_seq.size() > 0 else -1), shop_seq.size() == 3 and int(shop_seq[0]) > 0)
# ⑧ 加算区同账(手工场景:L2 商店购买 → 三选一直调——同一本账累加 + 部分回血封顶)
var same := {}
for s in range(42, 92):
var a8 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(s))
a8.set_hero_override(900, 400)
if not a8.start_adventure(s, {"first_script": true}): # 固定模板 0——种子扫描语义按现行图
if not a8.start_adventure(s): # 种子扫描语义按生成图(同种子同图)
continue
if _adv_drive_to_shop(a8, _adv_st(), autoplay):
same = {"a": a8, "s": s}
@@ -335,7 +373,7 @@ func _smoke_adventure() -> void:
# ⑩ 放弃(战斗中拒绝 · 非战斗期受理同失败型结算)
var a10 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(15))
a10.set_hero_override(900, 400)
Kit.check("放弃场景:冒险启动(seed 15——固定模板 0 同上)", a10.start_adventure(15, {"first_script": true}))
Kit.check("放弃场景:冒险启动(seed 15——生成图确定同上)", a10.start_adventure(15))
Kit.check("放弃场景:入口停点 pick 进首战(停点前移批 2026-09-23 驱动口径)", a10.runner().pick_next_node(int(a10.runner().next_candidate_ids()[0])))
Kit.check("战斗中 abandon 拒绝(战斗域单事务在飞)", not a10.abandon())
var b10 = a10.runner().current_battle()
@@ -348,7 +386,7 @@ func _smoke_adventure() -> void:
# ⑪ 池耗即枯竭 + 池收缩 + 满血回 0(TP0——white-box:耗尽 attack/ap 令牌后候选零出现;
# 无种类次数上限,「达上限剔除」语义由牌库耗尽承载)
var a11 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(11))
Kit.check("剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)", a11.start_adventure(11, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}))
Kit.check("剔牌库/替换场景:冒险启动(seed 11——道具解锁集只含 wide_scout_band)", a11.start_adventure(11, {"treasure_unlocked": {"wide_scout_band": true}}))
for di in range(a11._curio_deck.size() - 1, -1, -1):
var dk := String((a11._curio_deck[di] as Dictionary).get("kind", ""))
if dk == "attack" or dk == "ap":
@@ -370,7 +408,7 @@ func _smoke_adventure() -> void:
Kit.check("池收缩——40 次重抽零 aux 候选(停点渠道 aux 卡退役,迭代 11 第①批 T7)", not treasure_seen)
# 满血回 0 合法(通关回血封顶口径)
var a12 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(12))
a12.start_adventure(12, {"first_script": true})
a12.start_adventure(12)
var cap := {"payload": {}}
a12.curio_choice_presented.connect(func(p): cap["payload"] = p)
var max_full := int(_db.hero.max_hp) + int(a12._base_override.get("max_hp", 0))
@@ -379,7 +417,7 @@ func _smoke_adventure() -> void:
Kit.check("满血通关回血 0 合法(封顶口径——heal_gained=0 非负溢出)", int(cap["payload"].get("heal_gained", -1)) == 0)
# ⑬ 奖励节点共享池(同库同池同摘牌;TP0:attack 令牌耗尽后节点域同样零出现)
var a13 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(13))
Kit.check("节点共享池场景:冒险启动(seed 13)", a13.start_adventure(13, {"first_script": true, "treasure_unlocked": {"wide_scout_band": true}}))
Kit.check("节点共享池场景:冒险启动(seed 13)", a13.start_adventure(13, {"treasure_unlocked": {"wide_scout_band": true}}))
for di13 in range(a13._curio_deck.size() - 1, -1, -1):
if String((a13._curio_deck[di13] as Dictionary).get("kind", "")) == "attack":
a13._curio_deck.remove_at(di13)
@@ -411,7 +449,7 @@ func _smoke_feats() -> void:
Kit.check("feat_table 已加载(30 件=基础 27+迁入 2+卡② 完美保险 1——迭代 31 卡② 换代)", _db.feats != null and (_db.feats.rows as Array).size() == 30)
Kit.check("feat_table.validate 零违例(四级 rarity/cap 域/trigger 词表/行键白名单全过)", _db.feats.validate().is_empty())
var a_r0 = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(71))
Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71, {"first_script": true}))
Kit.check("专长带场景:冒险启动(seed 71)", a_r0.start_adventure(71))
var feat_tokens := 0
var tier_counts := {}
for t in a_r0._curio_deck:
@@ -465,7 +503,7 @@ func _smoke_feats() -> void:
var live_rows: FeatTable = _db.feats
_db.feats = rt
var a_r = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(72))
Kit.check("专长带场景:冒险启动(seed 72——注入 5 行临时表)", a_r.start_adventure(72, {"first_script": true}))
Kit.check("专长带场景:冒险启动(seed 72——注入 5 行临时表)", a_r.start_adventure(72))
var deck_relic := 0
for t in a_r._curio_deck:
if String((t as Dictionary).get("kind", "")) == "relic":
@@ -485,7 +523,7 @@ func _smoke_feats() -> void:
Kit.check("节点渠道零专长(60 次重抽零 relic——专长在非 Boss 渠道只以专长凭证形态出现)", not node_relic_seen)
# T5 专长凭证行为断言(A5 拿到即开 / A6 保底计数——契约④四口径;独立 runner 防污染 a_r 牌库)
var a_t = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(91))
a_t.start_adventure(91, {"first_script": true})
a_t.start_adventure(91)
var stock_before := {}
for kk in ["attack", "gp", "heal"]:
stock_before[kk] = a_t.pool_stock(kk)
@@ -769,6 +807,50 @@ func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool:
# 手工场景状态袋(_drive_level/_autoplay_battle 消费的最小字段族——run_adventure st 的同构子集)
# 真实选路驱动到偏好支路:赢下途中战斗,遇候选含目标类型即显式点选(reward=首个奖励节点、
# battle=首个句柄≠深度的战斗节点);返回 {node, enc, cands, pending, adr}
func _drive_to_pref(autoplay, prefer: String) -> Dictionary:
var adr = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(42))
adr.set_hero_override(900, 400)
var out := {"node": {}, "enc": {}, "cands": [] as Array, "pending": false, "adr": adr}
if not adr.start_adventure(42):
return out
var r = adr.runner()
var st := _adv_st()
var guard := 30
while r != null and guard > 0:
guard -= 1
if r.awaiting_choice():
var hit: Dictionary = {}
for c in r.next_candidates():
var cd := c as Dictionary
if prefer == "reward" and String(cd.get("type", "")) == "reward":
hit = cd
break
if prefer == "battle" and String(cd.get("type", "")) == "battle" and int(cd.get("id", -1)) != int(cd.get("depth", -1)):
hit = cd
break
if not hit.is_empty():
r.pick_next_node(int(hit.get("id", -1)))
out["node"] = hit
if prefer == "battle":
out["enc"] = r.current_battle().encounter
else:
var cands: Array = (adr.node_curio_candidates() as Dictionary).get("candidates", []) as Array
adr.present_node_curio(cands)
out["cands"] = cands
out["pending"] = not (adr._node_curio_pending as Dictionary).is_empty()
return out
var ids: Array = r.next_candidate_ids()
if ids.is_empty():
return out
r.pick_next_node(int(ids[0]))
continue
var b = r.current_battle()
if b == null:
return out
autoplay._autoplay_battle(r, b, st, null)
return out
func _adv_st() -> Dictionary:
return {"result": "", "hp_left": 0, "battles": 0, "rounds": 0, "hero_dmg": 0,
"rest_healed": 0, "hp_before_rest": -1, "defeat_node_index": -1, "defeat_node_type": "",
@@ -809,12 +891,7 @@ func _fresh_rng(seed_val: int):
# ---- ① GP 冒险钱包·到账 ----
# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义)
# 生成图取数面(图 = f(关卡行参数档, 种子 42)——与 runner 起关同图;节点键 id/type/depth/next)
func _level_active_nodes(level_index: int) -> Array:
for row in _db.level.rows:
if int(row.level_index) == level_index:
var tpls: Array = row.get("templates", [])
if not tpls.is_empty():
return tpls[0].get("nodes", [])
return row.get("nodes", [])
return []
var gen = NodeMapGenerator.new(_fresh_rng(42))
return (gen.generate(_db.level_gen_params_row(level_index), 42) as Dictionary).get("nodes", [])
+17 -2
View File
@@ -134,9 +134,24 @@ func _smoke_board_generator() -> void:
var row = _db.resolve_board_gen(L, 0)
Kit.check("BoardGen 行解析 (%d,0)" % L, not row.is_empty())
if L == 2:
# R1 ceil 口径:无精确行向上取整保证偏难——L2 深度 3 为恢复点无行,解析到深度 4
# 迭代 35 生成图深度扩面覆盖批随动(声明):旧口径「(2,3) 无精确行→ceil 上取到 4」的判据对象
# 是模板时代键域缺口;覆盖批补齐 0–13 后 (2,3) 有精确行,生产表上不再存在可走 ceil 的空位。
# 判据不丢——精确命中改判在册,ceil 能力用「临时摘除该行」的夹具面保留,随后原样还原。
var exact_row: Dictionary = _db.resolve_board_gen(2, 3)
Kit.check("BoardGen 精确命中 (2,3)(覆盖批键域补齐)", int(exact_row.get("node_depth", -1)) == 3)
var gap_idx := -1
for i in _db.board_gen.rows.size():
if int(_db.board_gen.rows[i].get("level_index", -1)) == 2 and int(_db.board_gen.rows[i].get("node_depth", -1)) == 3:
gap_idx = i
break
var saved: Dictionary = _db.board_gen.rows[gap_idx]
var rows_before: int = _db.board_gen.rows.size()
_db.board_gen.rows.remove_at(gap_idx)
var ceil_row: Dictionary = _db.resolve_board_gen(2, 3)
Kit.check("R1 ceil 解析 (2,3)→(2,4)(向上取整偏难)", int(ceil_row.get("node_depth", -1)) == 4)
Kit.check("R1 ceil 能力不失(夹具摘除 (2,3)→向上取整到 4·偏难口径)", int(ceil_row.get("node_depth", -1)) == 4)
_db.board_gen.rows.insert(gap_idx, saved)
Kit.check("夹具表原样还原(行数与解析回到覆盖批现值)",
_db.board_gen.rows.size() == rows_before and int(_db.resolve_board_gen(2, 3).get("node_depth", -1)) == 3)
var b1 = board_generator.generate(L, 0, _rng.open_stream("board_gen"), _db)
Kit.check("L%d 生成产物非空" % L, b1 != null)
if b1 == null:
+45 -1
View File
@@ -147,8 +147,52 @@ func _init() -> void:
Kit.check_quiet(adr_i.runner().pick_next_node(int(adr_i.runner().next_candidate_ids()[0])), "P6 续玩选路进首战")
Kit.check_quiet(adr_i.runner().current_battle().attune_has("morning_order"), "P6 回局首战装配集仍含 morning_order(停点往返载体态一致)")
# ---- Part 7:生成图换代断言面(快照键换代、停点句柄往返、续玩按种子+参数档重生成同图)----
var rng_j = load("res://core/rng_service.gd").new()
var sm_j = SaveManagerScript.new(_bp_path)
var adr_j = AdventureRunnerScript.new(db, rng_j)
adr_j.set_save_manager(sm_j)
Kit.check_quiet(adr_j.start_adventure(910042, params), "P7 冒险启动(seed 910042——生成图开局)")
var runner_j = adr_j.runner()
var graph_j: Array = runner_j.nodes()
var bp_j: Dictionary = sm_j.breakpoint_snapshot()
Kit.check_quiet(not bp_j.has("template_pick"),
"P7 断点快照无已抽模板键(模板链路退役——件核面在档)〔v9〕")
Kit.check_quiet(int(bp_j.get("graph_gen_v2", 0)) == 1,
"P7 断点含生成图代差标记键(换代承载)〔v8〕")
var entry_id: int = int(runner_j.next_candidate_ids()[0])
var mid_id: int = _first_out_id(graph_j, entry_id)
var bp_mid: Dictionary = bp_j.duplicate(true)
bp_mid["node_id"] = mid_id
bp_mid["visited_ids"] = [entry_id]
var adr_m = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
Kit.check_quiet(adr_m.resume_adventure(bp_mid, params), "P7 中途停点断点续玩受理(句柄落档形态)")
var runner_m = adr_m.runner()
Kit.check_quiet(int(runner_m.current_node_id()) == mid_id,
"P7 停点句柄回读一致(图内 id 单空间寻址)〔v8〕")
Kit.check_quiet(runner_m.visited_ids() == [entry_id], "P7 已到节点序按句柄往返一致")
Kit.check_quiet(runner_m.nodes() == graph_j, "P7 续玩重生成图与中断前逐位同图(断点不存图快照)〔v7〕")
var adr_l = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
var bp_legacy: Dictionary = bp_mid.duplicate(true)
bp_legacy.erase("graph_gen_v2")
Kit.check_quiet(adr_l.resume_adventure(bp_legacy, params) == false,
"P7 缺生成图代差标记键的旧断点显式拒绝(禁按新句柄空间静默回放)〔v8〕")
var adr_n = AdventureRunnerScript.new(db, load("res://core/rng_service.gd").new())
Kit.check_quiet(adr_n.start_adventure(910042, params) and adr_n.runner().nodes() == graph_j,
"P7 同种子重开局同图(开局入口复现承载)〔v7〕")
_clear_disk()
quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归 全绿"))
quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归/生成图换代 全绿"))
# 句柄寻址夹具辅助:取生成图中指定句柄节点的首个出边目标(构造中途停点落档形态)
func _first_out_id(nodes: Array, node_id: int) -> int:
for node in nodes:
var n := node as Dictionary
if int(n.get("id", -1)) == node_id:
var outs: Array = n.get("next", [])
return int(outs[0]) if not outs.is_empty() else -1
return -1
func _board_fingerprint(b) -> String:
@@ -16,7 +16,6 @@ func _init() -> void:
_test_all_links_valid()
_test_dangling_skill_id()
_test_cast_side_mismatch()
_test_level_node_band_rules()
quit(Kit.finish("config_db_cross_validator_test"))
@@ -83,61 +82,3 @@ func _test_cast_side_mismatch() -> void:
var status := _status([{"effect_id": "st_hero_side", "target_side": "hero"}])
var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, status)
Kit.check("buff 挂主角侧状态被报(施放类 side 守卫)", _cats(errs).has("cast_side"))
# ---- ④ 节点 × 敌人表带寻址(单终点零误报/同带双 Boss 报错/顶 clamp 消解报错)----
const LevelTableScript := preload("res://data/schemas/level_table.gd")
const BoardGenTableScript := preload("res://data/schemas/board_gen_table.gd")
const RewardTableScript := preload("res://data/schemas/reward_table.gd")
const SettlementTableScript := preload("res://data/schemas/settlement_table.gd")
func _loaded(level_nodes: Array, bg_depths: Array, boss_bands: Array, battle_bands: Array) -> Dictionary:
var lv := LevelTableScript.new()
lv.rows.assign([{"level_index": 1, "display_name": "夹具关", "templates": [{"nodes": level_nodes}]}])
var bg := BoardGenTableScript.new()
var bg_rows: Array = []
for d in bg_depths:
bg_rows.append({"level_index": 1, "node_depth": int(d), "board_width": 10})
bg.rows.assign(bg_rows)
var en := EnemyTableScript.new()
var en_rows: Array = []
for b in battle_bands:
en_rows.append({"level_index": 1, "row_kind": "battle", "depth_band": int(b), "enemy_id": "goblin",
"unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]})
for b in boss_bands:
en_rows.append({"level_index": 1, "row_kind": "boss", "depth_band": int(b), "enemy_id": "ogre_chief",
"unit_count": 1, "hp": 10, "atk": 2, "pool": [{"skill_id": "attack", "weight": 9}]})
en.rows.assign(en_rows)
var st := SettlementTableScript.new()
st.source_label_map.assign({"safe_cell": "a", "seal_hit": "b", "mine_hit": "c", "clear_reward": "d",
"hero_attack": "e", "enemy_action": "f", "seal_miss": "g"})
return {"status_effects": StatusTableScript.new(), "reward": RewardTableScript.new(),
"settlement": st, "level": lv, "board_gen": bg, "enemy": en}
func _cross_cats(loaded: Dictionary, needle: String) -> Array[String]:
var out: Array[String] = []
ValidatorScript.new().cross_validate(loaded,
func(_sev, cat, msg) -> void:
if String(msg).contains(needle):
out.append(String(cat)))
return out
func _test_level_node_band_rules() -> void:
# 正向:单终点三关同构(battle 节点落 band 0、boss 节点精确落顶带)→ ④ 零误报
var ok_nodes := [{"type": "battle", "node_depth": 0}, {"type": "boss", "node_depth": 9}]
var ok_cats := _cross_cats(_loaded(ok_nodes, [0, 9], [9], [0]), "level 1")
Kit.check("单终点图 ④ 敌侧带寻址零误报〔fv17〕", ok_cats.is_empty())
# 负向:一路两终点落同一 Boss 带(深度 3/4 都落带 4)→ rowset_rule
var same_band_nodes := [{"type": "battle", "node_depth": 0},
{"type": "boss", "node_depth": 3}, {"type": "boss", "node_depth": 4}]
var same_band_cats := _cross_cats(_loaded(same_band_nodes, [0, 3, 4], [4, 9], [0]), "同带")
Kit.check("同关双终点共带报 rowset_rule〔fv15〕",
same_band_cats.has("rowset_rule"))
# 负向:Boss 节点深度高于顶带键(顶 clamp 消解)→ level_node_dangling
var over_nodes := [{"type": "battle", "node_depth": 0}, {"type": "boss", "node_depth": 10}]
var over_cats := _cross_cats(_loaded(over_nodes, [0, 10], [9], [0]), "顶 clamp 消解")
Kit.check("Boss 节点越顶带报 level_node_dangling〔fv16〕",
over_cats.has("level_node_dangling"))
@@ -53,12 +53,12 @@ func _init() -> void:
Kit.check_quiet(lr.spend_gp(20) == true and int(lr.gp) == 10, "足额支出扣账(30→10)")
# ---- ⑥ Y 断点随存(缺陷锚:续玩自参数面整额重注入=用完 Y 退菜单再续玩满血复活)----
var bp_y := {"level_index": 1, "hero_hp": 10, "attune_refresh_y": 2}
var bp_y := {"level_index": 1, "hero_hp": 10, "attune_refresh_y": 2, "graph_gen_v2": 1}
var adr_r = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
Kit.check_quiet(adr_r.resume_adventure(bp_y, {"hero_override": {}, "attune_refresh_y": 3}), "续玩受理(最小断点+参数面)")
Kit.check_quiet(adr_r._attune_refresh_y == 2, "断点有键=Y 余量随断点还原(花 1 剩 2——不再整额重注入=满血复活堵死)")
var adr_o = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
Kit.check_quiet(adr_o.resume_adventure({"level_index": 1, "hero_hp": 10}, {"hero_override": {}, "attune_refresh_y": 3}), "旧断点续玩受理(快照缺键)")
Kit.check_quiet(adr_o.resume_adventure({"level_index": 1, "hero_hp": 10, "graph_gen_v2": 1}, {"hero_override": {}, "attune_refresh_y": 3}), "旧断点续玩受理(快照缺键)")
Kit.check_quiet(adr_o._attune_refresh_y == 3, "旧断点缺键=回退参数面整额注入(旧档行为零变化)")
quit(Kit.finish("curio_refresh_paid"))
+1 -1
View File
@@ -22,7 +22,7 @@ func _init() -> void:
quit(1)
return
var a = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
if not a.start_adventure(301, {"first_script": true}):
if not a.start_adventure(301):
Kit.check_quiet(false, "V9 前置:冒险启动")
quit(Kit.finish("feat_vouchers"))
return
+66 -39
View File
@@ -29,61 +29,71 @@ func _init() -> void:
# ---- 夹具 ----
# 图形态夹具(id = node_depth):单干 0→1→2→3→4→5→6→7 加一条 6→3 跨层跳边。
# BFS 层:0=0/1=1/2=1/3=2/4=3/5=4/6=5/7=6——锚 6 时候选 3 层距 3 = N+1。
# 节点构造器(生成图口径:id=图内唯一句柄、depth=自入口最长路径步数、next=出边句柄数组)
static func _gn(id: int, depth: int, type_: String, next_ids: Array) -> Dictionary:
return {"id": id, "type": type_, "depth": depth, "next": next_ids}
# 图形态夹具(id = 图内句柄,depth = 自入口沿边严格前进的深度):干 0→1→{2,8},2→3,
# 8→4→5→6,长跳边 6→3(与 2→3 同落节点 3,目标 depth 严格大于源——生成图连线铁律),
# 3 经尾链 9→10→11 汇入终点 7(7 的 depth = 自入口最长路径步数 10)。
# BFS 层:0=0/1=1/2=1/3=2/4=3/5=4/6=5/7=6(8=2/9=3/10=4/11=5)——锚 6 时候选 3 层距 3 = N+1。
func _graph_nodes() -> Array:
return [
{"node_depth": 0, "type": "battle", "next": [1, 2]},
{"node_depth": 1, "type": "battle", "next": [2]},
{"node_depth": 2, "type": "reward", "next": [3]},
{"node_depth": 3, "type": "rest", "next": [4]},
{"node_depth": 4, "type": "battle", "next": [5]},
{"node_depth": 5, "type": "battle", "next": [6]},
{"node_depth": 6, "type": "battle", "next": [7, 3]},
{"node_depth": 7, "type": "boss", "next": []},
_gn(0, 0, "battle", [1, 2]),
_gn(1, 1, "battle", [2, 8]),
_gn(2, 2, "reward", [3]),
_gn(3, 6, "rest", [9]),
_gn(4, 3, "battle", [5]),
_gn(5, 4, "battle", [6]),
_gn(6, 5, "battle", [7, 3]),
_gn(7, 10, "boss", []),
_gn(8, 2, "battle", [4]),
_gn(9, 7, "battle", [10]),
_gn(10, 8, "battle", [11]),
_gn(11, 9, "battle", [7]),
]
# 单链夹具(无 next 键,仿 L1 现役模板形态):编号空间 = 数组下标,node_depth 只出现在战斗/Boss 上
# 单链退化形态 = 出度 ≤1 的图(显式 next 出边、编号 = id 句柄——无数组下标隐式边)
func _chain_nodes() -> Array:
return [
{"node_depth": 0, "type": "battle"},
{"node_depth": 1, "type": "battle"},
{"node_depth": 2, "type": "battle"},
{"node_depth": 3, "type": "battle"},
{"type": "reward"},
{"type": "rest"},
{"node_depth": 4, "type": "boss"},
_gn(0, 0, "battle", [1]),
_gn(1, 1, "battle", [2]),
_gn(2, 2, "battle", [3]),
_gn(3, 3, "battle", [4]),
_gn(4, 4, "reward", [5]),
_gn(5, 5, "rest", [6]),
_gn(6, 6, "boss", []),
]
# validate 正样本(无跨层跳边的合法图:入口唯一、Boss 编号最大收尾、层宽 ≤5)
# 表行夹具的正样本(无跨层跳边的合法图——表加载期只核行级字段,图合法性判据见 validate_graph·卡② T1 换代)
func _valid_graph_nodes() -> Array:
return [
{"node_depth": 0, "type": "battle", "next": [1, 2]},
{"node_depth": 1, "type": "battle", "next": [3]},
{"node_depth": 2, "type": "reward", "next": [3]},
{"node_depth": 3, "type": "battle", "next": [4]},
{"node_depth": 4, "type": "boss", "next": []},
_gn(0, 0, "battle", [1, 2]),
_gn(1, 1, "battle", [3]),
_gn(2, 1, "reward", [3]),
_gn(3, 2, "battle", [4]),
_gn(4, 3, "boss", []),
]
# 白盒 runner:夹具行集注入 db.level + level_row,停点态直设(anchor_id < 0 = 入口停点)
func _runner(db, nodes: Array, anchor_id: int, candidates: Array,
graph_mode: bool = true, n: int = 2):
# 白盒 runner:夹具行集注入 db.level + level_row,停点态直设(anchor_id < 0 = 入口停点;
# 锚点与候选的编号空间 = 节点 id 句柄——runner._node_id 单点)
func _runner(db, nodes: Array, anchor_id: int, candidates: Array, n: int = 2):
var t := LevelTableScript.new()
t.info_visible_layers = n
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}]
db.level = t
var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes})
r._graph_mode = graph_mode
r.visited.clear()
r._candidates.assign(candidates)
r.node_index = -1
if anchor_id >= 0:
for i in nodes.size():
if int((nodes[i] as Dictionary).get("node_depth", -1)) == anchor_id:
if int((nodes[i] as Dictionary).get("id", -1)) == anchor_id:
r.node_index = i
break
return r
@@ -157,15 +167,15 @@ func _test_single_circle(db) -> void:
Kit.check("单圈只按锚层取窗:已过近端节点 4(层距 2)仍可见〔fv7〕", vis.has(4))
# ---- ⑤ 单链退化形态(编号空间 = 数组下标)〔fv1〕----
# ---- ⑤ 单链退化形态(出度 ≤1 图,编号 = id 句柄)〔fv1〕----
func _test_single_chain_entry(db) -> void:
var nodes := _chain_nodes()
var r = _runner(db, nodes, -1, [nodes[0]], false)
var r = _runner(db, nodes, -1, [nodes[0]])
var vis: Array = r.type_visible_ids()
Kit.check("单链退化形态:入口锚层 0,下标 2 可见〔fv1〕", vis.has(2))
Kit.check("单链退化形态:层距 N+1(下标 3)不可见〔fv1〕", not vis.has(3))
Kit.check("单链编号空间 = 数组下标:Boss 以末位下标 6 入集(非 node_depth 4)〔fv1〕",
Kit.check("单链退化形态:入口锚层 0,句柄 2 可见〔fv1〕", vis.has(2))
Kit.check("单链退化形态:层距 N+1(句柄 3)不可见〔fv1〕", not vis.has(3))
Kit.check("单链编号空间 = 图句柄 id:Boss 以句柄 6 入集(链上层距 4 的战斗节点 4 入雾)〔fv1〕",
vis.has(6) and not vis.has(4))
@@ -189,20 +199,37 @@ func _test_n_from_table(db) -> void:
# ---- ⑦ 续玩重建同值〔fv7〕----
func _test_resume_rebuild(db) -> void:
var nodes := _graph_nodes()
var t := LevelTableScript.new()
t.info_visible_layers = 2
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}]
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30}]
db.level = t
var ledger := {"hero_hp": 10, "gp": 0}
var resumed = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
var ok: bool = resumed.start_level_external(1, ledger, {}, Callable(), {}, false, 0,
var ok: bool = resumed.start_level_external(1, ledger, {}, Callable(), {}, false,
{"node_id": 2, "visited_ids": [0, 1, 2]})
Kit.check_quiet(ok, "续玩用例:resume_state 重建停点成功")
if not ok:
return
Kit.check_quiet(resumed.awaiting_choice(), "续玩用例:重建后处于选路停点")
var direct = _runner(db, nodes, 2, [nodes[3]])
direct.visited.assign([nodes[0], nodes[1], nodes[2]])
# 图源 = 参数化生成图(图 = f(参数档, 种子))——直入对照侧与 resume 侧同吃一张生成图,
# 停点态按句柄对齐:锚 = id 2、visited = id 0/1/2、候选 = id 2 出边指向的节点
var gnodes: Array = resumed.nodes()
var stop: Dictionary = {}
var by_id := {}
for node in gnodes:
var nid: int = int((node as Dictionary).get("id", -1))
by_id[nid] = node
if nid == 2:
stop = node
var cand_nodes: Array = []
for tid in (stop.get("next", []) as Array):
if by_id.has(int(tid)):
cand_nodes.append(by_id[int(tid)])
var visited_nodes: Array = []
for vid in [0, 1, 2]:
if by_id.has(vid):
visited_nodes.append(by_id[vid])
var direct = _runner(db, gnodes, 2, cand_nodes)
direct.visited.assign(visited_nodes)
Kit.check("续玩重建同值:resume 路径与同停点直入 type_visible_ids() 逐位一致〔fv7〕",
resumed.type_visible_ids() == direct.type_visible_ids())
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,102 @@
# tests/unit/core/level_table_validate_test.gd — 消费侧加载校验四检单测(迭代 35 卡② T1/V1)
# 覆盖:validate_graph 正例(多入口多终点/含岔口单终点/单链退化显式出边)+负例八类
#(非 Boss 终点/Boss 带出边/环=回边与自环/双独立链全员可达正证/悬空出边/句柄重复/battle 缺深度/空图)
#+「勿扩勿缩」反证:商店位与入口终点个数不在本检范围(关卡节点地图 v2.1「位置约束由冒烟断言把关」)。
# 运行:godot --headless --path game --script res://tests/unit/core/level_table_validate_test.gd(expect 0)
# 协议(tests/README):extends SceneTree + quit(code);夹具资源直构不碰生产表。
extends SceneTree
const Kit := preload("res://tests/unit/test_kit.gd")
const LevelTableScript := preload("res://data/schemas/level_table.gd")
func _init() -> void:
_test_positive()
_test_negative()
_test_scope_not_extended()
quit(Kit.finish("level_table_validate_test"))
func _n(id: int, type_: String, depth: int, outs: Array) -> Dictionary:
return {"id": id, "type": type_, "depth": depth, "next": outs}
func _errs(nodes: Array) -> Array:
return LevelTableScript.validate_graph(nodes)
func _has(errs: Array, cat: String, needle: String = "") -> bool:
for e in errs:
if String(e["category"]) != cat:
continue
if needle == "" or String(e["message"]).contains(needle):
return true
return false
# ---- 正例(四检全过)----
func _test_positive() -> void:
# 多入口多终点:两入口共汇入一岔口,两终点各在互异深度
var multi := [
_n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]),
_n(2, "battle", 1, [3, 5]),
_n(3, "rest", 2, [4]), _n(4, "shop", 3, [6]), _n(6, "boss", 4, []),
_n(5, "reward", 2, [7]), _n(7, "shop", 3, [8]), _n(8, "boss", 5, [])]
Kit.check("四检正例:多入口多终点图零错误〔v1〕", _errs(multi).is_empty())
# 含岔口单终点:岔口两侧链与主去路同深、汇入同一后继
var forked := [
_n(0, "battle", 0, [1]),
_n(1, "battle", 1, [2, 3]),
_n(2, "reward", 2, [4]), _n(3, "rest", 2, [4]),
_n(4, "shop", 3, [5]), _n(5, "boss", 4, [])]
Kit.check("四检正例:含岔口单终点图零错误〔v1〕", _errs(forked).is_empty())
# 单链退化形态合法(校验不设「必有岔口」)
var chain := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [3]), _n(3, "boss", 3, [])]
Kit.check("四检正例:单链退化图零错误(不设必有岔口)〔v1〕", _errs(chain).is_empty())
# ---- 负例九类(各报对应类别与判据消息)----
func _test_negative() -> void:
var non_boss_end := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [])]
Kit.check("负例:终点非 Boss 命中「终点恒为 Boss」〔v1〕",
_has(_errs(non_boss_end), "structural", "终点 1 非 Boss"))
var boss_out := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, [2]), _n(2, "shop", 2, [])]
Kit.check("负例:Boss 带出边被拒〔v1〕", _has(_errs(boss_out), "structural", "Boss 节点 1 带出边"))
var back_edge := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 0, [3]), _n(3, "boss", 4, [])]
Kit.check("负例:回边(目标深度不严格大于源)命中前进方向判据〔v1〕",
_has(_errs(back_edge), "structural", "违反前进方向"))
var cycle := [_n(0, "battle", 0, [1]), _n(1, "battle", 1, [2]), _n(2, "shop", 2, [1]), _n(3, "boss", 3, [])]
Kit.check("负例:环(2→1 回边)被前进方向拦下〔v1〕", _has(_errs(cycle), "structural", "违反前进方向"))
# 判据②(无孤儿无断头)与判据④(前进方向)在无环图上互斥:DAG 的每个源点都是入口、每条极长路都止于终点,
# 故 ② 是④失效时的纵深防御面,构造不出独立负例——此处以「两独立链双入口双终点」正证 ② 不误伤。
var two_components := [
_n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []),
_n(3, "battle", 0, [4]), _n(4, "rest", 1, [5]), _n(5, "boss", 2, [])]
Kit.check("判据②正证:两独立链(双入口双终点)不误伤·全员可达〔v1〕", _errs(two_components).is_empty())
var self_loop := [_n(0, "battle", 0, [1]), _n(1, "shop", 1, [2]), _n(2, "boss", 2, []), _n(3, "battle", 1, [3])]
Kit.check("判据②兜底面:自环先被前进方向拦下(④ 与 ② 互斥的实证)〔v1〕",
_has(_errs(self_loop), "structural", "违反前进方向"))
var dangling := [_n(0, "battle", 0, [9]), _n(1, "boss", 1, [])]
Kit.check("负例:悬空出边报 dangling_next〔v1〕", _has(_errs(dangling), "dangling_next"))
var dup := [_n(0, "battle", 0, [1]), _n(1, "boss", 1, []), _n(1, "shop", 2, [])]
Kit.check("负例:句柄重复报 dup_key〔v1〕", _has(_errs(dup), "dup_key"))
var no_depth := [_n(0, "battle", 0, [1]), {"id": 1, "type": "battle", "next": [2]}, _n(2, "boss", 2, [])]
Kit.check("负例:battle 节点缺深度被良构基底拒〔v1〕", _has(_errs(no_depth), "structural", "缺深度"))
Kit.check("负例:空节点集被拒〔v1〕", _has(_errs([]), "structural", "图节点集为空"))
var bad_type := [_n(0, "mine", 0, [1]), _n(1, "boss", 1, [])]
Kit.check("负例:类型出域报 enum〔v1〕", _has(_errs(bad_type), "enum"))
# ---- 「勿扩勿缩」反证:本检不含商店位与个数对参数档的核对 ----
func _test_scope_not_extended() -> void:
# 合法形状但全程无商店/三个终点同入口图——四检不该因此报错(商店位归冒烟断言、个数归产出侧)
var no_shop := [_n(0, "battle", 0, [1, 2]), _n(1, "battle", 1, [3]), _n(2, "rest", 1, [3]), _n(3, "boss", 2, [])]
Kit.check("范围反证:无商店图不在加载校验内报错(商店位由冒烟断言把关)〔v1〕", _errs(no_shop).is_empty())
var two_entries := [_n(0, "battle", 0, [2]), _n(1, "battle", 0, [2]), _n(2, "boss", 1, [])]
Kit.check("范围反证:双入口合法(入口唯一判据已随模板池退役,个数归产出侧)〔v1〕", _errs(two_entries).is_empty())
# 良构字段缺失(无 next 键)该报错——不属被退役判据
var missing_key := [{"id": 0, "type": "battle", "depth": 0}, _n(1, "boss", 1, [])]
Kit.check("范围反证:节点缺 next 字段仍被良构基底拒〔v1〕", _has(_errs(missing_key), "structural", "缺字段 next"))
@@ -0,0 +1 @@
uid://bhymincbe2j4m
@@ -313,15 +313,15 @@ func _meta_settle_and_workshop() -> void:
Kit.check("开局传参夹具前置:Fork 侧 a 激活 rule 键非空 + Y=Lv2 行表值 2(防断言空转)",
rules_e.has("posture_first_strike") and mp_e.attune_refresh_charges() == 2)
var p_new: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e,
{"first_script": true, "treasure_unlocked": {}, "shop_mode": "pool"})
{"treasure_unlocked": {"wide_scout_band": true}, "shop_mode": "pool"})
Kit.check("主菜单开新冒险参数面:同调装配 = 存档装配集(营地装了什么开局就带什么)",
(p_new.get("attunement", []) as Array) == (sv_e.attunement_equipped() as Array))
Kit.check("主菜单开新冒险参数面:Y 刷新池 = 当前等级行表值(营地升几档开局就给几次)",
int(p_new.get("attune_refresh_y", -1)) == mp_e.attune_refresh_charges())
Kit.check("主菜单开新冒险参数面:树 rule 键集 = Fork 激活侧 effects(点哪侧带哪侧)",
(p_new.get("tree_rules", []) as Array) == rules_e)
Kit.check("主菜单开新冒险参数面:入口特有键(first_script/shop_mode)随 extra 原样并入",
bool(p_new.get("first_script", false)) and String(p_new.get("shop_mode", "")) == "pool")
Kit.check("主菜单开新冒险参数面:入口特有键(treasure_unlocked/shop_mode)随 extra 原样并入",
bool((p_new.get("treasure_unlocked", {}) as Dictionary).get("wide_scout_band", false)) and String(p_new.get("shop_mode", "")) == "pool")
var p_resume: Dictionary = load("res://presentation/main.gd").build_adventure_params(sv_e, mp_e,
{"treasure_unlocked": {}})
Kit.check("主菜单继续上局参数面:三键与开新冒险同源同值(续玩不吃白板开局)",
@@ -333,7 +333,7 @@ func _meta_settle_and_workshop() -> void:
var adr_res = load("res://core/adventure_runner.gd").new(_db, _fresh_rng(19))
var bp_min: Dictionary = {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5,
"bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {},
"template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
"graph_gen_v2": 1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
"visited_ids": []}
Kit.check("继续上局受理面:resume_adventure 受理三键参数面(尾裁微批b 受理闭环——受理不再吃白板)",
adr_res.resume_adventure(bp_min, p_resume))
+5 -5
View File
@@ -34,7 +34,7 @@ func _init() -> void:
# T10——空配置验收闸(三选一池空兜底/商店空货架)
var e = load("res://core/adventure_runner.gd").new(db, load("res://core/rng_service.gd").new())
e.start_adventure(501, {"first_script": true})
e.start_adventure(501)
e._curio_pending = {"candidates": []}
Kit.check_quiet(e.skip_empty_curio() and e._curio_pending.is_empty(),
"空配置闸:三选一池空=停点整体跳过(skip_empty_curio 消化)〔专长批 T10〕")
@@ -65,7 +65,7 @@ func _tiers_of(cands: Array) -> Array:
func check_v7(a, db) -> void:
if not a.start_adventure(201, {"first_script": true}):
if not a.start_adventure(201):
Kit.check_quiet(false, "V7 前置:冒险启动")
return
# 构成①:全池含优秀档以上——曝光槽应出 1 格高档(rank≥excellent)
@@ -110,7 +110,7 @@ func check_v7(a, db) -> void:
func check_v6(b, db) -> void:
if not b.start_adventure(202, {"first_script": true}):
if not b.start_adventure(202):
Kit.check_quiet(false, "V6 前置:冒险启动")
return
# 拿了消耗:直置停点候选后 pick_curio——牌库摘牌单点
@@ -139,7 +139,7 @@ func check_v6(b, db) -> void:
func check_v12(m, db) -> void:
if not m.start_adventure(401, {"first_script": true}):
if not m.start_adventure(401):
Kit.check_quiet(false, "V12 前置:冒险启动")
return
var tier_n := {"common": 0, "excellent": 0, "rare": 0, "legendary": 0}
@@ -157,7 +157,7 @@ func check_v12(m, db) -> void:
func check_v8(c, db) -> void:
if not c.start_adventure(203, {"first_script": true}):
if not c.start_adventure(203):
Kit.check_quiet(false, "V8 前置:冒险启动")
return
# 概率位与 cap 两属性独立:改 cap 不动牌库(概率位)、改牌库不动 cap 字段
@@ -184,7 +184,7 @@ func _smoke_save() -> void:
"level_index": 2, "node_id": 5, "hero_hp": 13,
"bonus": {"atk": 2, "max_hp": 5, "armor_class": 1},
"gp": 17, "shop_purchased": {"hp_small": 2, "atk_small": 1},
"template_pick": 3,
"graph_gen_v2": 1,
"master_seed": 424242, "rng_counter": 38, "keystone": 6,
"visited_ids": [0, 2, 5],
"aux_items": [{"item_id": "aux_scout", "source_class": "reward_node",
@@ -205,7 +205,7 @@ func _smoke_save() -> void:
var bp_shop: Dictionary = bp.get("shop_purchased", {})
Kit.check("断点七字段往返:商店已购档",
int(bp_shop.get("hp_small", -1)) == 2 and int(bp_shop.get("atk_small", -1)) == 1)
Kit.check("断点七字段往返:已抽模板", int(bp.get("template_pick", -1)) == 3)
Kit.check("断点七字段往返:生成图代差标记键", int(bp.get("graph_gen_v2", 0)) == 1)
Kit.check("断点附随件往返:主种子/计数器/钥石累计",
int(bp.get("master_seed", -1)) == 424242 and int(bp.get("rng_counter", -1)) == 38
and int(bp.get("keystone", -1)) == 6)
@@ -237,7 +237,7 @@ func _smoke_save() -> void:
fv2f.store_string(JSON.stringify({"version": 2, "meta": {"keystone_balance": 7},
"breakpoint": {"level_index": 1, "node_id": -1, "hero_hp": 40, "gp": 5,
"bonus": {"atk": 0, "max_hp": 0, "armor_class": 0}, "shop_purchased": {},
"template_pick": -1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
"graph_gen_v2": 1, "master_seed": 777, "rng_counter": 3, "keystone": 0,
"visited_ids": []}}))
fv2f.close()
Kit.check("v2 旧档迁移 v3:断点域与 meta 域原样保留(缺新附随件=合法空态)",
@@ -72,14 +72,6 @@ static func _errs_has_cross_field(errs: Array) -> bool:
return false
# 该关 Boss 节点深度(单终点图的终点编号——数据驱动,禁写死坐标)
func _boss_depth(level_index: int) -> int:
for n in _level_active_nodes(level_index):
if String(n.get("type", "")) == "boss":
return int(n.get("node_depth", -1))
return -1
# 首遇带数据驱动定位(契约 8):该 (L, battle, enemy_id) 的最低 depth_band,禁写死坐标数字
func _first_encounter_band(level_index: int, enemy_id: String) -> int:
var best := -1
@@ -94,17 +86,6 @@ func _first_encounter_band(level_index: int, enemy_id: String) -> int:
return best
# 模板感知取节点表(row.templates 非空取模板 0=现行固定图,保持既有断言语义)
func _level_active_nodes(level_index: int) -> Array:
for row in _db.level.rows:
if int(row.level_index) == level_index:
var tpls: Array = row.get("templates", [])
if not tpls.is_empty():
return tpls[0].get("nodes", [])
return row.get("nodes", [])
return []
# ① 冒险表(单行全局 Rule 4 豁免——建表与接线闸;TP0 令牌池:配额结构/件值对/池总量域检)
func _smoke_adventure_table() -> void:
print("--- 冒险表(AdventureTable——令牌池行值单点:回血/配额/件值对/同调位)---")
@@ -222,26 +203,31 @@ func _smoke_adventure_table() -> void:
v18_ok and locked_n == 12)
# ---- 敌人表深度带键结构(迭代 34:20 行键迁移 · 键序=迁移表序 · hobgoblin 留任 · 三 Boss 行 kind/band)----
# ---- 敌人表深度带键结构(迭代 34 建·迭代 35 覆盖批换代:28 行键序=覆盖表序·hobgoblin 留任·Boss 行集带键清单)----
func _smoke_enemy_bands() -> void:
print("--- 敌人表深度带键结构(20 行键序 · hobgoblin 留任 · 三 Boss 行)---")
# ① 20 行计数 + 键序 = 迁移表序(文件行序不变——行序即契约)
print("--- 敌人表深度带键结构(28 行键序 · hobgoblin 留任 · Boss 带键覆盖表)---")
# ① 28 行计数 + 键序 = 覆盖表序(文件行序不变——行序即契约)
# 迭代 35 生成图深度扩面覆盖批随动:战斗带顶扩到各关最大生成深度(L1→6/L2→9/L3→13)、
# Boss 行按终点深度分带(L1 6/L2 8·9/L3 11·12·13),模板时代带键 4/9/9 全部在册不动
var expect_keys: Array[String] = [
"1|battle|0|goblin", "1|battle|1|goblin", "1|battle|2|bugbear", "1|battle|3|hobgoblin",
"1|boss|4|ogre_chief",
"1|battle|6|bugbear",
"1|boss|4|ogre_chief", "1|boss|6|ogre_chief",
"2|battle|0|bugbear", "2|battle|1|bugbear", "2|battle|4|giant_spider",
"2|battle|5|bugbear", "2|battle|5|giant_spider", "2|battle|6|giant_spider", "2|battle|7|giant_spider",
"2|boss|9|drider",
"2|battle|9|giant_spider",
"2|boss|8|drider", "2|boss|9|drider",
"3|battle|0|duergar", "3|battle|1|hook_horror", "3|battle|4|hook_horror", "3|battle|5|duergar",
"3|battle|6|hook_horror", "3|battle|7|duergar", "3|boss|9|duergar_king",
"3|battle|6|hook_horror", "3|battle|7|duergar", "3|battle|13|duergar",
"3|boss|9|duergar_king", "3|boss|11|duergar_king", "3|boss|12|duergar_king", "3|boss|13|duergar_king",
]
var got_keys: Array[String] = []
for row in _db.enemy.rows:
got_keys.append("%d|%s|%d|%s" % [int(row.get("level_index", -1)), String(row.get("row_kind", "")),
int(row.get("depth_band", -1)), String(row.get("enemy_id", ""))])
Kit.check("敌人表 20 行且键序 = 迁移表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕",
_db.enemy.rows.size() == 20 and got_keys == expect_keys)
Kit.check("敌人表 28 行且键序 = 覆盖表序((L,row_kind,depth_band,enemy_id) 逐行比对——文件行序不变)〔fv11〕",
_db.enemy.rows.size() == 28 and got_keys == expect_keys)
# ② hobgoblin 缺口行留任随迁(契约 9:不删行不补立绘——缺口登记随行)
var hob := {}
for row in _db.enemy.rows:
@@ -252,23 +238,27 @@ func _smoke_enemy_bands() -> void:
and int(hob.get("depth_band", -1)) == 3)
Kit.check("hobgoblin 缺口登记随行(表文件注释在位——缺口不静默)〔fv25〕",
FileAccess.get_file_as_string("res://data/tables/enemy.tres").contains("hobgoblin 行为缺口行"))
# ③ 三 Boss 行 kind/band 就位(band = 该关图 Boss 节点深度——单终点图数据驱动)
# ③ Boss 行集带键 = 覆盖表(旧判据「各恰一行=图 Boss 节点深度」属单终点模板时代;
# 生成图多终点要求每终点深度解析到互异带键 ⇒ 每关 Boss 行必然多于一行——
# 迭代 35 覆盖批随动改写为带键清单,「带键互异」的运行时把关在 level_shop_economy_test〔v11〕)
var boss_bands := {}
for row in _db.enemy.rows:
if String(row.get("row_kind", "")) == "boss":
var lk: int = int(row.get("level_index", -1))
if not boss_bands.has(lk):
boss_bands[lk] = []
boss_bands[lk].append(int(row.get("depth_band", -1)))
var expect_bands := {1: [4, 6], 2: [8, 9], 3: [9, 11, 12, 13]}
var boss_ok := true
var boss_detail := ""
for L in [1, 2, 3]:
var bdepth := _boss_depth(L)
var hits := 0
for row in _db.enemy.rows:
if int(row.get("level_index", -1)) == L and String(row.get("row_kind", "")) == "boss":
hits += 1
if int(row.get("depth_band", -1)) != bdepth:
boss_ok = false
boss_detail += " L%d:%d≠%d" % [L, int(row.get("depth_band", -1)), bdepth]
if hits != 1:
var got: Array = boss_bands.get(L, [])
got.sort()
if got != expect_bands[L]:
boss_ok = false
boss_detail += " L%d:boss 行×%d" % [L, hits]
Kit.check("三关 Boss 行各恰一行且 row_kind=boss、band=图 Boss 节点深度(%d/%d/%d%s)〔fv13〕" % [
_boss_depth(1), _boss_depth(2), _boss_depth(3), boss_detail], boss_ok)
boss_detail += " L%d:%s≠%s" % [L, str(got), str(expect_bands[L])]
Kit.check("三关 Boss 行集带键 = 覆盖表(模板终点 4/9/9 在册+生成图终点 6/8/9/11/12/13 扩带)%s〔fv13〕" % boss_detail,
boss_ok)
# ② 免伤墙归零护栏泛化保留(BUG-0003 事故类永久免疫):hero_defense 池任何 percent 行
@@ -29,7 +29,7 @@ func _run() -> void:
quit(Kit.finish("level_map_info_fog_test"))
# 白盒 runner:夹具行集注入 db.level + level_row,入口停点态(node_index=-1,候选=入边首节点)
# 白盒 runner:夹具行集注入 db.level + level_row,入口停点态(node_index=-1,候选=首层首节点句柄 1)
func _runner(db, nodes: Array, candidates: Array, n: int = 2):
var t := LevelTableScript.new()
t.info_visible_layers = n
@@ -37,7 +37,6 @@ func _runner(db, nodes: Array, candidates: Array, n: int = 2):
db.level = t
var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes})
r._graph_mode = true
r.node_index = -1
r._candidates.assign(candidates)
return r
@@ -46,7 +45,7 @@ func _runner(db, nodes: Array, candidates: Array, n: int = 2):
# ---- ① 范围外只见结构:雾下节点类型图标与文字均不绘制〔fv2〕----
func _test_fog_hides_type() -> void:
var node := {"node_depth": 3, "type": "battle"}
var node := {"id": 3, "type": "battle", "depth": 3, "next": []}
var shown: Dictionary = ScreenScript.node_display(node, 2, [2], false, false)
Kit.check("可见集内节点:图标/类型文字均绘制〔fv2〕",
bool(shown["draw_icon"]) and bool(shown["draw_label"]))
@@ -60,24 +59,24 @@ func _test_fog_hides_type() -> void:
func _test_boss_exempt_and_multi_boss() -> void:
var type_vis: Array = [1, 7, 9] # 双 Boss 图:7/9 均在可见集
var boss_a: Dictionary = ScreenScript.node_display({"node_depth": 7, "type": "boss"}, 7, type_vis, false, false)
var boss_b: Dictionary = ScreenScript.node_display({"node_depth": 9, "type": "boss"}, 9, type_vis, false, false)
var boss_a: Dictionary = ScreenScript.node_display({"id": 7, "type": "boss", "depth": 7, "next": []}, 7, type_vis, false, false)
var boss_b: Dictionary = ScreenScript.node_display({"id": 9, "type": "boss", "depth": 9, "next": []}, 9, type_vis, false, false)
Kit.check("多终点:两个 Boss 节点均带 Boss 描边〔fv3〕",
bool(boss_a["boss_border"]) and bool(boss_b["boss_border"]))
Kit.check("Boss 不入雾:可见集与图标/文字三通道均在〔fv3〕",
bool(boss_a["draw_icon"]) and bool(boss_a["draw_label"]) and bool(boss_b["draw_icon"]))
var far_boss: Dictionary = ScreenScript.node_display({"node_depth": 9, "type": "boss"}, 9, [1], false, false)
var far_boss: Dictionary = ScreenScript.node_display({"id": 9, "type": "boss", "depth": 9, "next": []}, 9, [1], false, false)
Kit.check("Boss 豁免独立于可见集:不在 type_vis 的 Boss 仍出示类型〔fv3〕",
bool(far_boss["draw_icon"]) and bool(far_boss["draw_label"]))
var fogged_battle: Dictionary = ScreenScript.node_display({"node_depth": 5, "type": "battle"}, 5, type_vis, false, false)
var fogged_battle: Dictionary = ScreenScript.node_display({"id": 5, "type": "battle", "depth": 5, "next": []}, 5, type_vis, false, false)
Kit.check("Boss 豁免不扩散:雾下非 Boss 仍隐类型〔fv3〕", not bool(fogged_battle["draw_icon"]))
# ---- ③ 节点内详情图上从不显示〔fv5〕----
func _test_details_never_shown() -> void:
var plain := {"node_depth": 3, "type": "battle"}
var loaded := {"node_depth": 3, "type": "battle", "enemy_id": "goblin_secret", "reward_id": "reward_secret"}
var plain := {"id": 3, "type": "battle", "depth": 3, "next": [4]}
var loaded := {"id": 3, "type": "battle", "depth": 3, "next": [4], "enemy_id": "goblin_secret", "reward_id": "reward_secret"}
var a: Dictionary = ScreenScript.node_display(plain, 3, [3], false, true)
var b: Dictionary = ScreenScript.node_display(loaded, 3, [3], false, true)
var same := true
@@ -92,7 +91,7 @@ func _test_details_never_shown() -> void:
# ---- ④ label_text 三态文案〔fv2〕----
func _test_label_three_states() -> void:
var node := {"node_depth": 3, "type": "battle"}
var node := {"id": 3, "type": "battle", "depth": 3, "next": [4]}
Kit.check("当前位文案=「当前位置」〔fv2〕",
String(ScreenScript.node_display(node, 3, [3], true, false)["label_text"]) == "当前位置")
Kit.check("候选文案=「战斗 · 可选」〔fv2〕",
@@ -103,9 +102,10 @@ func _test_label_three_states() -> void:
# ---- 大图(11 层×6 槽含双 Boss)画布拟合与布局包络〔fv8〕----
# 大图夹具:编号按 12 层×6 槽构造,层 0 入口、末层双 Boss;含层 1 首节点→第 3 层节点的跨层跳边。
# 跳边目标按 BFS 最短路径提前一层,其后继链随之提前——布局落 11 层(最大槽 7:
# 跳边目标并入第 2 层槽组)。断言按布局实测取最大层/最大槽,画布公式与之同源。
# 大图夹具:按 12 层×6 槽构造(id = 图内句柄 = 构造序号;depth 随构造序号严格前进——生成图连线铁律),
# 层 0 入口、末层双 Boss(id 61/62,句柄互异、next 恒空);含层 1 首节点→第 3 层节点的跨层长跳边。
# 跳边目标按 BFS 最短路径提前一层,其后继链随之逐层提前(目标并入第 2 层槽组)——布局落 11 层。
# 断言按布局实测取最大层/最大槽,画布公式与之同源。
func _big_graph_nodes() -> Array:
var layers: Array = []
var total := 0
@@ -118,30 +118,25 @@ func _big_graph_nodes() -> Array:
total += 1
layers.append(ids)
var nodes: Array = []
var jump_from := -1
var jump_to := -1
for l in tiers:
for i in layers[l].size():
var nid: int = layers[l][i]
var n := {"node_depth": nid}
if l == 0:
n["type"] = "battle"
n["next"] = layers[1].duplicate() # 入口扇出到全层(层宽 6)
elif l == tiers - 1:
n["type"] = "boss"
n["next"] = []
var t := "battle"
var next_ids: Array = []
if l == tiers - 1:
t = "boss"
elif l == 0:
next_ids = layers[1].duplicate() # 入口扇出到全层(层宽 6)
else:
if i % 5 == 0:
n["type"] = "reward" if l % 2 == 0 else "rest"
else:
n["type"] = "battle"
t = "reward" if l % 2 == 0 else "rest"
var w2: int = layers[l + 1].size()
n["next"] = [layers[l + 1][i % w2], layers[l + 1][(i + 1) % w2]]
nodes.append(n)
jump_from = layers[1][0]
jump_to = layers[3][2]
next_ids = [layers[l + 1][i % w2], layers[l + 1][(i + 1) % w2]]
nodes.append({"id": nid, "type": t, "depth": nid, "next": next_ids})
var jump_from: int = layers[1][0]
var jump_to: int = layers[3][2]
for n in nodes:
if int(n["node_depth"]) == jump_from:
if int(n["id"]) == jump_from:
(n["next"] as Array).append(jump_to)
break
return nodes
@@ -188,9 +183,9 @@ func _test_big_graph_fit(db) -> void:
var boss_ids: Array = []
for node in nodes:
if String(node["type"]) == "boss":
boss_ids.append(int(node["node_depth"]))
boss_ids.append(int(node["id"]))
Kit.check("大图呈现:两个终点 Boss 均有描边标记〔fv8〕",
disp.has(boss_ids[0]) and disp.has(boss_ids[1])
and bool(disp[boss_ids[0]]["boss_border"]) and bool(disp[boss_ids[1]]["boss_border"]))
Kit.check("大图呈现:入口停点远处战斗节点(编号 32,层 6)类型入雾〔fv8〕", not bool(disp[32]["draw_icon"]))
Kit.check("大图呈现:入口停点远处战斗节点(句柄 32,层 6)类型入雾〔fv8〕", not bool(disp[32]["draw_icon"]))
screen.queue_free()
@@ -0,0 +1,29 @@
# 迭代 35 卡② 代拍板追认单(节点图消费侧换代批)
- 日期:2026-10-24
- 依据:`reports/需求卡片-节点图消费侧换代批-2026-10-21.md` §10 代拍板白名单六条——授权发生在写卡段,本批只执行预授权并留痕
- 待用户动作:追认或推翻。推翻任一条即回写对应实现面(不改设计文档)
## 一、白名单内六条的执行值
| # | 卡片白名单条目 | 本批拍定值 | 落点 |
|---|---|---|---|
| ① | 图数据内部适配形态 | 生成图落到 `level_row` 的**行副本**上(`row.duplicate(true)` + `merged["nodes"] = graph["nodes"]`),表本体不被写脏;节点字段恰 `id/type/depth/next`,句柄即 `id`;读面 API 六签名逐字不变 | `game/core/level_runner.gd` `_assign_generated_graph`/`nodes()`;断言=`node_map_templates_test` 「起关生效图=生成器同档同种子直取逐位一致」 |
| ② | 校验实现形态与错误类别命名 | 无环判据取「出边目标 `depth` 严格大于源」写法(不做 DFS 判环);错误类别沿用 `structural`/`dangling_next`/`dup_key`/`enum`/`domain` 词表,零新增类别 | `game/data/schemas/level_table.gd` `validate_graph`;断言〔v1〕17 条 |
| ③ | 断点键换代细节 | 代差标记键命名 `graph_gen_v2`(值恒 1);退役键=`template_pick`;`node_id`/`visited_ids` 按图内句柄落档(与 `current_node_id()`/`visited_ids()` 同空间);旧断点拒绝后的盘面处置=**留档不清**(`resume_adventure` 返回 false + push_error,菜单侧「继续上局」自然失效由既有空断点态承载) | `game/core/adventure_runner.gd` `_snapshot_breakpoint`/`resume_adventure`;断言=`breakpoint_resume_test` P7 六条+`node_map_templates_test` ⑤ 拒绝条 |
| ④ | 冒烟断言定位写法与措辞 | 断言定位两分:**结构/覆盖/确定性类**直取生成器(参数档+种子 42/42–61 扫描),**行进/停点/兑付类**走 runner 与 AdventureRunner 真实入口(`start_level`/`start_level_external`/`pick_next_node`/`resume_adventure`);断言文本一律不写死节点编号形状 | `game/tests/integration/node_map_templates_test.gd`、`game/tests/unit/core/level_shop_economy_test.gd`〔v11〕、`game/tests/unit/core/adventure_feats_test.gd` |
| ⑤ | route 轴实现与 MC 字段值形态 | route 轴沿用「候选句柄升序= min/降序= max」的既有评分 tie-break(`_route_pick` 只换寻址空间,评分逻辑零动);MC 出数字段 `level_templates` → `level_maps`,值形态 `"%d#%d" % [关卡序号, 主种子]`(关卡×生成图标识) | `game/sim/level_autoplay.gd` `_route_pick`/`_on_adventure_node_entered`、`game/sim/adventure_monte_carlo.gd` 出数面 |
| ⑥ | 提交切分与分支命名 | 分支 `iter35b`(工作区 `../g-plan2-iter35b`);切分=覆盖数据批 → T1 校验换代 → T2 一口(④静态检查退役+随动)→ T2 二口+T3 断言面主体 → T3 收口(商店宿主)→ 变异验证与产出物 | git log(`9214ef8e`、`c0dd4420`、`98c61d50`、`9ee3bea1` 及后续收口笔) |
## 二、白名单外、本批自认需要呈报的五件
1. **「奖励节点三选一 ≥1 次」断言的替代形态**(`adventure_feats_test`)。旧判据「L1 链必经 1 个」的前提是单链模板形状,随模板池消亡;实测确定性选路评分在种子 42–47 × min/max 两轴共 12 次全程冒险里**一次都没有走进奖励支路**(生成图里奖励节点的句柄序恒在战斗支路之后,评分优先推进)。若保留「≥1 次」原句就是逼着改扫描口径凑数。本批拆成两条:① 图侧「三关生成图各含 ≥1 奖励节点」;② 行进侧走真实入口——打赢入口战、在岔口显式 `pick_next_node` 点奖励支路、断言三选一候选可取(3 件)且停点在途且兑付受理。语义从「形状必经」换成「接线可用+图上存在」,未放宽到恒真。
2. **三处 `_level_active_nodes` 副本曾是假绿**。`templates` 键清出后该 helper 落到 `row.get("nodes", [])` 返回空数组,凡以它为节点的扫描断言(覆盖义务、Boss 深度、逐节点行集)都变成「扫描面为空所以零违例」的空转绿。本批把三宿主 helper 换成参数档+种子 42 的生成图取数,并在覆盖断言里加「扫描节点数 > 0」的非空前置。这条是缺陷清偿,不是判据改动,但因为它把三条一直绿着的断言重新变成有效的(当场新增暴露一次 `boss_depth` 读到 -1 的真红),特此留痕。
3. **商店到达余额读数 2 → 3**(`adventure_feats_test` ⑦ GP 跨关延续)。旧断言 `shop_seq.size() == 2` 的依据是「L1 单链无店、L2/L3 各一店」;卡② 商店编排语义为「每终点前恰一店」⇒ 每关路径必穿一店 ⇒ 三关全程 = 3 次到达。本批按新语义把读数断言改成 3 并保留「首达余额 > 0」。若 S3 三性质扫描(`level_shop_economy_test`)与这条投影不一致,二者之一必红,属互相校验。
4. **`_boss_depth` helper 随删两处**(`round_flow_skill_pool_test`、`table_structure_guard_test`)。它们调用已退役的模板取数面、且在各自宿主内**零调用方**(〔fv13〕换代时留下的死件)。按契约 6「禁留死代码」删除;不涉及任何断言删除。
5. **两个呈现宿主的夹具拓扑重建**(`level_map_type_vis_test`、`level_map_info_fog_test`)。旧夹具用「6→3 回边」表达非法图,而新形状要求 `depth` 沿出边严格前进,回边无法自洽表达;本批把该夹具改为长跳前向边+侧链节点,保留全部阈值与可见集断言,负向回边判据改由 `level_table_validate_test`〔v1〕的夹具图承载。此条由并行子代理执行,**独立验收审计须复核其「断言条数与阈值零改动」的自述**(自述见实现报告随动段)。
## 三、遗留读数义务(与实现报告同册)
- 用户目检两条未做(登记不阻塞):三关实机选路观感不回归、首局种子 42 固定图的教学体验。
- MC/对拍零跑:本批无玩法数值改动,按红线不发起跑批;11 回放确定性对拍按前批裁决不补跑。
@@ -0,0 +1,25 @@
# 迭代 35 卡② 反哺点清单(节点图消费侧换代批)
- 日期:2026-10-24
- 依据:`reports/需求卡片-节点图消费侧换代批-2026-10-21.md` §6 关联文档同步面六条+实现批执行中新发现的落点
- 处置:本批为代码实现批,禁写 `design/` 与根流程清单,以下只列不改;每条由用户裁决后归文档批落文
## 一、卡片 §6 预列六条
| # | 文件与节位 | 现状陈述 → 应改成 | 依据 |
|---|---|---|---|
| ① | `design/gdd/systems/冒险循环/关卡节点地图.md` :57/:156/:178 | 「战斗|携带节点编号」「组装关卡序号 + 节点编号」「每场开战调用携带(关卡序号 + 节点编号)」三处 → 统一为「关卡序号+节点深度(自入口步数)」,与 :23/:65/:111/:119 同口径 | 卡② 契约 2(弃节点编号寻址)+ D1:65/:119 定稿句;实现落点 `game/core/level_runner.gd` enc 组装段(`node_depth` 取节点 `depth` 值) |
| ② | `design/gdd/systems/冒险循环/冒险结构.md` :293、:264 | :293 覆盖义务「每个战斗与关底节点(关,深)在敌人与棋盘生成的数值表中有精确行」→ 「敌侧按深度带命中(落带不早于请求深度,禁顶 clamp 消解)+板侧解析命中」;:264 关卡行集行的「旧模板池随实现批清数据」括注已成既成事实 → 注销括注、改述为参数档+生成器承载 | 卡③ 契约 1+卡② 完成定义 6;实现落点 `game/data/tables/level.tres`(templates 键已清出)、`game/tests/unit/core/level_shop_economy_test.gd` 覆盖断言〔v11〕 |
| ③ | `design/gdd/systems/冒险循环/关卡节点图生成.md` | 用语核对结论:消费侧注释只引用其「产出规格/执行边界」节号,未发现「岔口/终点/路径」用语漂移 → 记「无漂移」 | 实现在 `game/data/schemas/level_table.gd`、`game/core/level_runner.gd` 注释面自查(卡② T0 事实坐实项) |
| ④ | `design/gdd/ux/level-map.md` | 呈现面随动登记:句柄寻址后地图显示的节点数字=图内句柄 id(非深度、非数组序),多入口/多终点/含岔口三形态需补呈现条款 → 归卡④ 或文档批处理,本批未改呈现代码 | 卡② §6 表「呈现面随动义务」;实现落点 `game/core/level_runner.gd` `node_id_of`/`visited_ids` 语义换代 |
| ⑤ | `待办清单.md`(06) | 卡② 条目状态推进与退役记载:本批实现闭合后该条目按流水线规则由收口段处理,本会话未改该文件 | req-card 06 状态回写义务+迭代批禁改根流程清单红线 |
| ⑥ | `roadmap.md` | 迭代 35 行批内状态刷新(卡② 由「已编」推进到「实现批完成待收口」)——本会话未改该文件 | 同上(实现批禁写根规则文件) |
## 二、实现批新发现的四条(卡片未预列,一并呈报)
| # | 文件与节位 | 发现 | 建议处置 |
|---|---|---|---|
| ⑦ | `game/tests/验证集清单.md`(批 B 迁移账本的断言索引,11 行含 `first_script`/`template_pick` 措辞) | 该清单是批 B 迁入时的断言文本快照索引,只由 `tools/extract_checks.py` 手工重生成、不进任何闸;卡② 断言换代后其中 11 行文本与行号已过期 | 由用户裁决:跑一次 `tools/extract_checks.py` 重生成,或在清单头部登记「换代批后快照失效、按宿主现文为准」 |
| ⑧ | `game/tests/integration/node_map_templates_test.gd`(宿主文件名) | 该宿主现承载「生成图确定性断言套」,文件名里的 templates 是模板时代遗留名;卡片 S6 明令换代断言落「S1 同宿主」且 manifest 零新增登记,故本批保留原名 | 待用户裁决:改名(如 `node_map_determinism_test.gd`)须同批改 `run_all.gd` manifest 路径行,属独立小批 |
| ⑨ | `game/data/tables/level.tres` 表尾注释 | 卡片 T3 要求表尾写「15 张模板去向+参数档字段指引」,实现里带了一句「随迭代 35 卡② 清出」的迭代叙事,与 doc-conventions §5「禁写变更叙事」的口径有张力 | 待用户裁决:保留(卡片明令的去向注记)或删去迭代号只留图源换代事实句 |
| ⑩ | `design/gdd/systems/冒险循环/关卡节点地图.md`(或 06 体验验证清单)——教学担子交接 | 卡② 待裁点已定「模板 0 教学重放语义废除,教学位由首局固定种子 42 的生成图承载」;文档侧仍写着「模板 0=教学图」的表述在 ①②之外的段落里残留,且「首局图观感是否可当教学」属真人判断 | 建议随 ①② 一并清扫措辞,并把「首局教学图体验」列进 `体验验证清单.md`(卡片 §8.2 已把它列为用户目检项,登记不阻塞) |
@@ -0,0 +1,172 @@
# 迭代 35 卡② 实现报告(消费侧换代批·含生成图深度扩面覆盖数据同批落)
- 日期:2026-10-24 起批(进行中)
- 工作区:`D:/code/g-plan2-iter35b`(分支 `iter35b`,起点=master `99e1b20b`,已含卡① 合回 `6475ebff`)
- 卡片:`reports/需求卡片-节点图消费侧换代批-2026-10-21.md` v1.1
- 批性质:代码实现批——`design/` 与根规则文件只读;账面随动义务落 `reports/`(见 §五)
- 开工实况:卡② §0 的硬交付序列(生成图深度扩面的敌/板表覆盖)由用户 2026-10-24 裁决**与本卡同批落**(卡片原文允许「须先落或与本卡同批落」),故本报告 §一 先记覆盖数据批,后续各段记卡② 本体。
## 一、覆盖数据段(已完成·提交 `9214ef8e`+判据加固一笔)
**授权口径**:数值面改动本属独立批次;用户点名随本批同落,取值授权为「按现有曲线插值、全 provisional」。
| 面 | 改动 | 量程依据(卡① 实测) |
|---|---|---|
| 敌侧战斗带顶 | L1 3→6、L2 7→9、L3 7→13,各加一行;敌种复用在册 id(bugbear / giant_spider / duergar),零新内容 | 生成图实际战斗深度 L1≤4、L2≤4、L3≤9;顶带按立卡要件取该关最大生成深度 |
| Boss 按终点深度分带 | L1 加带 6、L2 加带 8、L3 加带 11/12/13(敌种复用 ogre_chief / drider / duergar_king) | 终点深度=layers−r:L1 6;L2 9/8;L3 13/12/11 |
| 板侧 `node_depth` 键域 | 补齐 L1 0–6/L2 0–9/L3 0–13(新增 12 行,其余字段按该关同档行复制) | 生成图每个深度都要出板(覆盖义务含全部节点类型) |
| 敌种与数值纪律 | AC 全部落在迭代 5 已裁带宽(杂鱼 0–5/Boss 8–12);模板时代带键 0–3/4/9 与 hobgoblin 留任位一律不动 | `table_structure_guard_test` 带域断言零改判 |
**验收断言**(宿主 `game/tests/unit/core/level_shop_economy_test.gd` 新增 `_smoke_generated_coverage`;扫描面=三关参数档 × 种子 42–61):
- 〔v11〕敌侧落带——生成图每个 battle/boss 节点深度经 `resolve_enemy_rowset` 解析出行的 `depth_band ≥ 请求深度`(等价 `EnemyTable.band_of` 非 -1)。
判据加固记录:初稿写作「行集非空」,被撤销证明证伪——越顶时 resolver clamp 到顶带照样返非空行,正是设计禁的「顶 clamp 消解」,故改为落带等式判据。
- 〔v11〕板侧精确命中——`resolve_board_gen(L, D)` 非空且返回行 `node_depth == D`。
- 〔v11〕多终点带键互异——同图各终点深度解析出的 `depth_band` 两两不等。
- 量程读数:L1 深度域 0–6、L2 0–9、L3 0–13;宿主全绿 152 checks(其中本段新起算 3 条)。
**变异验证**(逐条注入→最窄宿主见红→`git checkout` 还原;注入前实现已入库,防还原吞掉未提交码):
| 注入 | 期望红点 | 实测 |
|---|---|---|
| L2 Boss 带键 8 并入顶带 9(回单带) | 带互异 | RED-OK:`多终点 Boss 各终点解析带键两两互异〔v11〕` 红 |
| L3 战斗顶带 13 降到 8(深 9 战斗节点越顶) | 敌侧落带 | RED-OK:`敌侧解析落带 ≥ 请求深度〔v11〕` 红(连带 `(3,battle,8)` 精确键断言红) |
| 删除 board_gen `(3,13)` 精确行 | 板侧精确命中 | RED-OK:`每个节点深度在 board_gen 精确命中〔v11〕` 红 |
| L3 战斗顶带 13→12(首版注入) | 敌侧落带 | NOT-RED·已标注:13 号战斗顶带未被任何生成节点请求(该行为立卡要件保守上限的余量)——据此把判据从「非空」改为「落带 ≥ 深度」,并补上一条真红注入 |
**旧断言随动四条(声明于此,非静默改判)**:
| 断言 | 原口径 | 新口径 | 理由 |
|---|---|---|---|
| `table_structure_guard` 〔fv11〕 | 敌人表 20 行且键序=迁移表序 | 28 行且键序=覆盖表序 | 扩带必然改行数与键序;行序即契约的判据形态不变 |
| `table_structure_guard` 〔fv13〕 | 三关 Boss 各恰一行且 band=图 Boss 节点深度 | 三关 Boss 带键清单=覆盖表(L1 4/6、L2 8/9、L3 9/11/12/13) | 判据对象是单终点模板时代形状;生成图多终点必然多行,「互异」移交〔v11〕运行时把关 |
| `level_shop_economy` 混编段 | 注入段还原后表回 20 行 | 回 28 行 | 同一基线行数随覆盖批换代 |
| `level_shop_economy` 深度 8 段 | `(L,8)` 无板行/战斗行残留(商店房专属位清理确认) | `(L,8)` 板侧覆盖行在册;战斗侧仍禁 `(L,battle,8)` 精确键 | 生成图下深度 8 是正常可行深度;扫描面未缩,只按现行设计换板侧正负号 |
## 二、卡② 本体进度
| T 卡 | 状态 |
|---|---|
| T0 事实坐实与卡① 接口对齐 | 待做——接口四件已可由卡① 定稿直读(`generate(params, seed)` 不收 level_index、`{nodes,entries,terminals}`、节点 `id/type/depth/next`、参数档字段名),本段只需核 §3 行号漂移 |
| T1 加载校验换代(四检+良构) | 完成(提交 `c0dd4420`,明细见 §二·五)——`LevelTable.validate_graph(nodes)` 静态单点在位,表加载期图校验与旧六判据退役,新宿主〔v1〕17 条+manifest 登记,run_all 全绿 59 套件,四条变异注入均见目标尾缀红 |
| T2 消费侧切源与行进/定位/确定性换代 | 未开工——任务见分支待办 #3 |
| T3 模板清理与 S1–S8 断言换代 | 未开工(S5/V11 覆盖断言已由 §一 提前落位)——任务见分支待办 #4 |
| T4 产出物 | 本报告即起始件;代拍板单与反哺点清单随收口补 |
## 二·五 T1 加载校验换代(已完成·提交 `c0dd4420`)
**改动**:`LevelTable.validate_graph(nodes: Array) -> Array` 静态单点承载四检+良构基底(契约 1 原口径:①入口(无入边)≥1 且终点(无出边)≥1;②全员从某入口可达且可达某终点;③终点恒 Boss 且 Boss 无出边;④出边目标深度严格大于源=无环。良构=句柄唯一·出边全命中·type 五类枚举·battle/boss 带深度)。表加载期不再校验图:旧六判据(Boss 恰一/战斗≥2/单链末 Boss/前三位纯战斗/出度≤2/Boss 编号最大)+模板池校验分支+BFS 层宽≤5 一并删除,行级校验(level_index 唯一/display_name/rest_heal_pct/info_visible_layers)保留。商店位与入口终点个数按「勿扩勿缩」不入本检。
**新宿主**:`game/tests/unit/core/level_table_validate_test.gd`〔v1〕17 条断言(正例三形/负例八类/范围反证三条/②④ 关系实证三条)+ `run_all.gd` manifest 一行;闸 02 实跑 59 套件全绿。
**撤销(变异)证明**——逐条注入→本宿主见红→`git checkout` 还原,注入前实现已入库:
| 注入 | 目标尾缀 | 实测 |
|---|---|---|
| 注掉「终点恒为 Boss」判据 | 〔v1〕终点非 Boss 负例 | RED-OK:`负例:终点非 Boss 命中「终点恒为 Boss」〔v1〕` 红 |
| 注掉前进方向判据 | 〔v1〕回边/环两负例 | RED-OK:`回边(目标深度不严格大于源)…` 与 `环(2→1 回边)…` 双红 |
| 注掉句柄唯一登记 | 〔v1〕dup_key 负例 | RED-OK:`负例:句柄重复报 dup_key〔v1〕` 红 |
| 注掉入口收集分支(附带做) | 〔v1〕正例 | RED-OK:多入口/含岔口正例双红(入口集空→可达性全断) |
| 前两轮注错语法(`if false and if …`)致解析错 | — | 已作废重跑,未计入红证;教训=注入必须保持可编译,否则「红」是解析失败给的 |
**判据②与④在 DAG 上互斥(新发现,拟入反哺点)**:无环图每个源点都是入口、每条极长路都止于终点,故「孤儿/断头」构造不出独立负例;② 的真实定位是 ④ 失效时的纵深防御面。测试据此改为两独立链正证不误伤+自环先被 ④ 拦的实证。文档(关卡节点地图 v2.1 :51 走图三句/关卡节点图生成 v1.1 结构铁律)宜注明这一兜底定位,免得后续批误判 ② 有独立拦截能力。
**件核**:全仓 grep 六判据消息文本 → 零残留;唯一余留是 `game/data/tables/level.tres:22` 表尾模板生成史注释,随 T3 模板清出一并消失。
**本段旧断言随动(四条,声明)**:`level_shop_economy` ③ 段整段改走 `validate_graph`,其中「入口唯一违例」随设计换代翻为多入口合法正例、「孤儿」条改为非 Boss 终点;`level_map_type_vis` 夹具注释订正;`round_flow_skill_pool` 敌表池清单 20→28 行补覆盖批八行池克隆键;`board_gen_reward`「(2,3) ceil 上取」改判精确命中并以「临时摘该行」的夹具面保留 ceil 能力判据。(前两条属本卡 §9.5① S 随动族;后两条属 §一 覆盖批的键域随动,先前漏跑整套未暴露,本轮 run_all 抓出。)
## 二·七 T2 第一口(已完成·提交 `98c61d50`)
跨表校验 ④ 整体退役(详见提交信息),两条拦截语义由〔v11〕承接、覆盖不降级的依据=生成图深度域是模板深度域的超集;`save_manager` 首次冒险位注释随确定性承载换代改口径(键与行为零改动);run_all 59 套件全绿。
## 二·八 续做清单(冷启动可照此接上·T2 剩余+T3)
1. `game/core/level_runner.gd`:删 `_resolve_template_row`(:300-324 含 `active_template_name/index` 声明)→ 起关改调 `NodeMapGenerator.new(RngService).generate(ConfigDB.level_gen_params_row(L), seed)`;拿到图后、进节点前调 `LevelTable.validate_graph(nodes)`,违规 `push_error`+返回 false(走 `main.gd:396 _fatal_screen` 挂点);`start_level`/`start_level_external` 的 `template_pick` 尾参删除;`_entry_node`→`_entry_nodes`(多入口,入口停点候选=全部入口,单入口逐位不变);`_node_id`(:341-347)改返回 `id` 句柄、`_out_ids`/`_out_nodes`/`visited_ids`/`pick_next_node`/`_restore_to_stop` 同空间随动、单链隐式出边(:360-385 体内)退役;`begin_battle` enc(:513-525)`node_depth` 取节点 `depth` 值(键名不变)。
2. `game/core/adventure_runner.gd`:删 `_first_script`(:45/:102/:194)与 `template_pick` 计算传递(:850/:852-854/:863);断点快照删 `template_pick` 键(:1013)、`node_id`/`visited_ids`(:1000/:1017)句柄化;扩列**代差标记键**(缺省 0/写 1),`resume_adventure` 遇缺标记键旧断点=返回 false+`push_error`(禁静默按新句柄回放)。
3. `game/presentation/main.gd`:删 `first_script` 参数构造(:594-596);种子三分判定(:1476-1490 `_seed_value`)零改动。
4. 断言与驱动面换代(S1–S8,见卡② §8.1):`node_map_templates_test` 模板段①-⑥退役+⑦改写为「首局种子 42」,同宿主新增确定性四断言;`_level_active_nodes` 三副本(`level_shop_economy:970`/`adventure_feats:812`/`round_flow:357`)改经生成器取图;`level.tres` 三行 `templates` 键删除+表尾注释去向段;`sim/level_autoplay.gd`(:37/:63/:141/:200-202/:12/:377-379)与 `adventure_monte_carlo.gd:139` 换代(`level_templates`→`level_maps`);`probe/shot_probe.gd:12-13`、`ui_click_probe.gd:373` 假设性质化;`breakpoint_resume_test`/`pool_case_d_test`/`feat_vouchers_test`/`cross_system_flow_test:79`/`meta_progression_forge_test:316` 随动。
5. 件核:全仓零 `first_script`/`template_pick`/`active_template`/`node_depth 当身份` 引用;`level.tres` 无 `templates` 键。
6. 收口顺序:T2+T3 必须同轮(拆开中间态必红)→ `run_all_gates.sh --code` 全绿 → 独立验收审计 → 合回 → 反哺点/代拍板/待办清单状态随批。
7. 待用户项:覆盖批八行与棋盘 12 行数值为插值 provisional(真人试玩定味);数值面已动,11 回放确定性对拍与 `balance_verifier` 是否发起需重新裁定。
## 二·九 T2 第二口=消费侧切源主体(已完成·提交 `9ee3bea1`+`38492116`+`78924fdd`+`548cd2da`)
§二·八 清单 1–3 与 5 逐条落定:
- `level_runner.gd`:`_resolve_template_row` 与 `active_template_name/index` 删除,新增 `_assign_generated_graph(row, seed)`(`db.level_gen_params_row(L)` 取参数档 → `NodeMapGenerator.generate(params, seed)` → `LevelTable.validate_graph(graph.nodes)` 违规 `push_error`+返回 false,无任何兜底图);`start_level(level_index, seed, base_override)`/`start_level_external(..., defer_entry, resume_state)` 的 `template_pick` 尾参删除;`_entry_node`→`_entry_nodes() -> Array[Dictionary]`(全部无入边节点,多入口首跳即选路);`_node_id` 取 `id` 句柄,`_out_ids`/`_out_nodes`/`visited_ids`/`pick_next_node`/`_restore_to_stop`/`_bfs_layers` 同句柄空间随动,单链隐式出边(数组序兜底)整体退役;enc `node_depth` 取节点 `depth` 值、键名不变。
- `adventure_runner.gd`:`_first_script` 字段与参数读入、`template_pick` 计算与传递全删;断点快照 `template_pick` 键 → `graph_gen_v2: 1` 代差标记键;`resume_adventure` 首检该标记,缺键旧断点返回 false+`push_error`(禁按新句柄空间静默回放)。
- `main.gd` 删 `first_script` 参数构造(种子三分判定 `:1476-1486` 零改动,首局恒 42);`save_manager.gd` 首次冒险位注释随 T2 第一口已换代。
- 件核:全仓 `first_script`/`template_pick`/`active_template` 零引用;`level.tres` 三行无 `templates` 键;`sim/level_autoplay.gd` 的 `boss_depth` 读数键由 `node_depth` 改 `depth`(该处曾是模板时代键名残留,切源后读不到值=测量面静默漂移,随 S7 一并清)。
## 二·十 T3 断言面换代(S1–S8 全数落位)
| 清单项 | 宿主与落位 | 换代后断言面 | 变异拦截面 |
|---|---|---|---|
| S1 | `tests/integration/node_map_templates_test.gd` 整宿主重写 | ①-⑥ 模板段退役;⑦ 收束置位三条保留(措辞换「首局恒走固定种子 42」);宿主现名不变(manifest 零新增登记,改名归反哺点⑧) | M5/M7 |
| S2 | `_level_active_nodes` 三副本(`level_shop_economy`/`adventure_feats`/`round_flow_skill_pool`) | 一律改经 `NodeMapGenerator.generate(db.level_gen_params_row(L), 42)` 取图;消费点深度键 `node_depth`→`depth`;两宿主内零调用方的死件 `_boss_depth` 随删 | — |
| S3 | `level_shop_economy` 商店段 | 三条性质断言(终点前驱恰一店/商店总数=终点数/全路径恰穿一店)× L1–L3 参数档 × 种子 42+42–61 扫描;硬编码 8/9/穿节点 8/L1 行集退役;行进面「当前节点型别=shop 且出边恰一条指终点」 | M2/M3 |
| S4 | `level_shop_economy` 形状段 | 节点数/五型计数/岔口数/终点数改生成图读数性质(含「L1 终点恰一、L2/L3 含岔口」V4 投影),非空前置在位 | — |
| S5 | `level_shop_economy`〔v11〕+`adventure_feats`〔fv24〕 | 敌侧=解析落带 ≥ 请求深度(禁顶 clamp)、板侧=精确命中、多终点 Boss 带键两两互异;`adventure_feats` 原「行集内每节点在 board_gen+enemy 有精确行集」改走 `resolve_enemy_rowset`/`resolve_board_gen` 真实消费链+扫描非空前置 | 〔v11〕两条已按批内变异验证见红(前节记录) |
| S6 | `node_map_templates` 新确定性套 | 同档同种子两次产图逐位同图/换种子即变图/节点携唯一句柄+depth/起关生效图=生成器直取(单一图源)/起关产图零计数器消费/入口候选数=参数档 `entry_count`+第二入口句柄显式进入/种子 42 两开局同图/断点存→恢复→再产图同图/缺代差键旧断点拒绝/表无 `templates` 键 | M1/M5/M6/M7 |
| S7 | `sim/level_autoplay.gd`、`sim/adventure_monte_carlo.gd`、`probe/shot_probe.gd`、`probe/ui_click_probe.gd` | `template_pick` 尾参退役;MC `level_templates`→`level_maps`(值 `关卡#主种子`);route 轴沿句柄升/降序;探针「L2 商店为 Boss 前必经位」假设改性质措辞;`probe/board_shot_probe`、`probe/type_glow_shot_probe` 起关尾参残留清理 | — |
| S8 | `breakpoint_resume`(新增 P7 六断言)、`pool_case_d`、`feat_vouchers`、`cross_system_flow:79`、`meta_progression_forge:316/:323`、`curio_refresh_paid`、`save_manager_domain` | `first_script` 键清退;断点夹具 `template_pick`→`graph_gen_v2`;meta 参数面 extra 键样换 `treasure_unlocked`(「入口特有键随 extra 原样并入」判据不动);P7 钉「快照无已抽模板键+代差标记在档+停点句柄与已到序往返+续玩重生成同图+旧断点拒绝+同种子重开局同图」 | M6 |
补充两条断言(V2/V6 拦截面补位,提交 `38492116`):原「入口停点候选 ≥1」不足以拦「候选写死单入口」,改判「=参数档 `entry_count`」并加点第二入口;`adventure_feats` 新增 `_drive_to_pref` 真实选路驱动,据此落「进战载荷两样=关卡序号+节点深度,且命中节点句柄≠深度」〔v6〕与奖励支路经停三选一在途两条。
宿主读数:`node_map_templates` 21 checks、`adventure_feats` 138、`breakpoint_resume` 全绿、`pool_case_d`/`feat_vouchers`/`cross_system_flow`/`curio_refresh_paid` 21/`save_manager_domain` 101/`meta_progression_forge` 133/`round_flow_skill_pool` 127/`table_structure_guard` 67/`level_map_type_vis` 26/`level_map_info_fog` 19 全绿;`level_shop_economy` 见下节。
## 二·十一 T3 收口:`level_shop_economy` 宿主换代(147→176 checks)
换代后该宿主的图源单点 = `_generated_graph(level_index, seed)`(`db.level_gen_params_row(L)` × 种子),`_level_active_nodes` 走该单点取种子 42 的现行图;段内新增一批图读数辅助(`_entry_ids`/`_terminal_ids`/`_fork_ids`/`_type_tally`/`_path_shop_count`/`_all_entry_terminal_paths`/`_path_in_graph`/`_coverage_scan`/`_enemy_band_covers`/`_board_row_exact`),行进面与枚举面共用同一判据口径。
- S4 形状段:`L2 节点数 = 10`/`战斗节点数 = 5`/`普通战斗 6+Boss 恒一`/`恢复 1+奖励 1+商店 1`/`真实岔路 = 0`/`L1 行集零改动` 六类写死读数退役,改为「五型计数合计 = 节点数」「战斗节点数 ≥ 参数档 `battle_floor`」「Boss 节点数 = 参数档 `exit_count`」「岔口(出度 ≥2)≥1」「L1 终点恰一」「终点深度 = 参数档 `layers`」,每条都带扫描面非空前置(`L%d 现行起关图取数非空`)。
- S3 商店段:三条性质断言 × 三关参数档 × 种子 42+42–61 全扫描(违例计数落进断言消息,图数与路径数在案:60 图 / 1413 条入口→终点路径),另保留行进面两条(到店当前节点型别=shop 且出边恰一条指终点;min/max 两轴路径逐跳为图出边、末节点为 Boss、穿店恰一)。
- S5 覆盖段:`跨表行完整(7 节点)` 改 `_coverage_scan` 逐关读数——敌侧「落带 ≥ 请求深度」、板侧「精确命中且 `node_depth` 等值」,并逐关登记颗数非空;终点 Boss 行集断言按图终点深度解析(`L2 全部终点深度 … 的 Boss 行集`)。
- 收口一件:换代过程中宿主内一度写过 `MUTATION-A/B/C/D` 内联自改生成图的脚手架与两条配套 MUT 断言(把变异开关埋进测试面=测试自己造数据,属反模式),已由 `d48bf10f` 整体删除,V5 证据改由生产码注入取证(见下表 M2/M3)。
## 二·十二 变异验证记录表(卡② 补挂七条;T1 四条与〔v11〕两条见 §二·五/§一)
跑批脚本 `game/tests/.gates/mutate_iter35_card2.py`(注入→最窄宿主→还原→逐条自检,跑后 `git status` 核产品码四件零改动;脚本本身属临时件,收口前删除)。红行取被测宿主自身的 FAIL 文本,不取包装层退出码。
| # | 注入点 | 变异内容 | 最窄宿主 | 结果与红行 |
|---|---|---|---|---|
| M1 | `level_runner.gd` `_candidates = entries`(两处入口停点候选,改首条) | 多入口候选写死单入口 | `node_map_templates_test` | 红(命中):`入口停点候选数=参数档入口个数(L3 档 2——多入口首跳即选路,禁写死单入口)` |
| M2 | `node_map_generator.gd` 产图返回前把 shop 型别改 rest | 终点前驱改非店 | `level_shop_economy_test` | 红(命中):性质①「每终点直接前驱恰一家店」违例 |
| M3 | 同上,把首个 battle 型别改 shop | 某路径加第二家店 | `level_shop_economy_test` | 红:性质②「每图商店节点数=终点数」违例 60/60 图;性质③「每条入口→终点路径恰穿一家商店」违例 738/1413 条;行进面 `L2 min/max 路径穿商店恰 1 家` 同批转红 |
| M4 | `level_runner.gd` enc `"node_depth": int(node.get("depth", 0))` → 取 `id` | enc 定位信息取句柄 | `adventure_feats_test` | 红(命中):`进战载荷两样=关卡序号+节点深度(实测 depth=… 句柄=…)〔v6〕` |
| M5 | `node_map_generator.gd` `pure_stream(seed_val, SCOPE_STRUCTURE)` → `open_stream("node_map")` | 产图改依赖统一流计数器号位 | `node_map_templates_test` | 红(命中):`同参数档同种子两次产图逐位同图(D2 执行边界)` |
| M6 | `adventure_runner.gd` `if int(bp.get("graph_gen_v2", 0)) != 1:` → `if false:` | 旧断点缺代差标记键却放行回放 | `breakpoint_resume_test` | 红(命中,复跑取证到断言名):`P7 缺生成图代差标记键的旧断点显式拒绝(禁按新句柄空间静默回放)`(1/56 项失败) |
| M7 | `level.tres` 首行偷留 `"templates": [{"nodes": []}]` | 模板数据未清尽 | `node_map_templates_test` | 红(命中):`level 表三行均无 templates 键(模板数据已清出)` |
卡片 §8 变异清单九条的落位:V1 两条(T1 已录)、V11 两条(覆盖批已录)、V2/V5×2/V6/V7/V8/V9 共六条(本表 M1–M7,V5 拆两条)。
## 二·十三 实际对照表(卡片 §8.2 八行的落地坐标;审计第 1 件按此逐行核)
| 需求点 | 测试落点(文件:行/尾缀) | 真实入口 | 撤销或变异证明 |
|---|---|---|---|
| 加载校验四检+良构 | `game/tests/unit/core/level_table_validate_test.gd`(17 条,〔v1〕)+起关拒绝段 `game/core/level_runner.gd:308-312` | `LevelRunner.start_level_external`(违规图返回 false) | T1 四条注入见红(§二·五):终点恒 Boss 判据注释、无环判据短路等 |
| 行进语义(多入口/大岔口/多 Boss/单链) | `node_map_templates_test.gd:60-66`〔v2〕;`level_shop_economy_test.gd:1157-1216`(V3 段:多终点图、两路各收本路 Boss、区域隔离、收束恰一次);`level_shop_economy_test.gd:339`〔v4〕(L1 终点恰一)+`:632`(L3 入口数=`entry_count`、终点数=`exit_count`) | `LevelRunner.start_level_external`/`start_level`+`pick_next_node`/`sim/level_autoplay.run_level`(route=min/max 两轴全程驱动) | M1(候选写死单入口)→〔v2〕红 |
| 商店编排三条性质 | `level_shop_economy_test.gd:1129-1133`〔v5〕(扫描面非空+性质①②③,60 图/1413 路径读数)+`:1000-1010` 行进面 | 生成图逐节点解析+`runner` 行进到店 | M2(终点前驱非店)→性质①红;M3(加第二家店)→性质②③红 |
| 生成图覆盖+多终点 Boss 带互异 | `level_shop_economy_test.gd:259-306`〔v11〕+`adventure_feats_test.gd:111-131`〔fv24〕 | `ConfigDB.resolve_enemy_rowset`/`resolve_board_gen` 真实消费链 | 覆盖批两条注入已录(Boss 带并单带、删顶带);〔fv24〕判据与〔v11〕同源单点 |
| 定位信息两样(关卡序号+节点深度) | `adventure_feats_test.gd:159-166`〔v6〕(读 `BattleOrchestrator.encounter`) | 真实进战入口 `_drive_to_pref`→`pick_next_node`→`begin_battle` | M4(enc `node_depth` 取句柄)→〔v6〕红 |
| 确定性承载+旧断点代差 | `node_map_templates_test.gd:44-95`〔v7〕〔v8〕;`breakpoint_resume_test.gd:157-183`〔v7〕〔v8〕〔v9〕 | `NodeMapGenerator.generate`/`AdventureRunner.start_adventure`/`resume_adventure`(真实快照写手 `SaveManager`) | M5(产图改计数器流)→〔v7〕红;M6(缺代差键放行)→〔v8〕红 |
| 15 张模板清理 | `node_map_templates_test.gd:91-96`〔v9〕+件核 grep(全仓零 `templates`/`first_script`/`template_pick`/`active_template`) | `ConfigDB.level_row` 表读面 | M7(表内偷留 `templates` 键)→〔v9〕红 |
| 反哺点清单(非行为改动) | `reports/iter35-卡②-反哺点-2026-10-24.md` 十条落点 | T4 产出物件核 | 缺任一落点=V10 件核红(本文件 §三·一 目检两条同批登记) |
宿主读数(终版):`level_shop_economy_test` 181 条、`node_map_templates_test` 21 条、`adventure_feats_test` 138 条、`breakpoint_resume_test` 56 条、`level_table_validate_test` 17 条。
## 三·一 实机目检总清单(真人判断项,登记即完成义务,不作为合回前置)
| # | 观测项 | 真实入口 | 期望看到什么 | 本批机检已覆盖的部分 |
|---|---|---|---|---|
| 目检① | 三关选路观感不回归:多入口首跳、大岔口取舍、多终点走向可见 | 主菜单开局 → 关卡地图(`res://probe/ui_click_probe.tscn` 亦可走同路径) | 开局即出现两个可选入口(L2/L3);岔口处候选互异且分属不同支路;走 A 支只遇 A 支终点 Boss | 候选数=参数档 `entry_count`、第二入口句柄可进入、终点数=`exit_count`、岔口 ≥1、每终点前恰一店(`node_map_templates`/`level_shop_economy` 扫描面) |
| 目检② | 首局教学图体验(种子 42 固定图) | 新档开局(`is_first_adventure_done==false` → 恒种子 42) | 同一张图、节奏可教学:入口战→岔口→商店→Boss 的可读顺序,含至少一个奖励位与一个恢复位 | 种子 42 两开局逐位同图、开局图=参数档直取、三关图各含奖励/恢复节点、恢复公式两分支读数 |
登记口径:以上两项由用户自行安排试玩;本批不以任何目检为停工或合回理由(硬性红线「真人检测只登记不阻塞」)。
## 三·二 其余遗留读数义务
- 覆盖数据为 provisional 插值,未跑 MC/对拍——数值面既动,卡② 收口前须请用户裁定是否发起 11 回放确定性对拍与 `balance_verifier` 重跑(本批目前只跑到「覆盖断言绿」,未测难度观感)。
- 待办清单 06 条目「覆盖批未立卡」缺口段现况已过期(覆盖数据已随本批落地),状态回写归文档批。
@@ -0,0 +1,117 @@
# 迭代 35 卡② 独立验收审计(节点图消费侧换代批)
- 审计日期:2026-10-24(执行跨至 2026-10-25 完成)
- 审计对象:分支 `iter35b` 尖 `0c5cd428`,改动范围 `git diff master...HEAD`(merge-base `bb63c93c`,31 文件级)
- 审计身份:未参与实现的独立会话子代理(迭代 loop 内启动,依 test-conventions §9.7 授权跑测)
- 合同:`reports/需求卡片-节点图消费侧换代批-2026-10-21.md` v1.1;被审产出物=实现报告/代拍板单/反哺点清单三份
## 总结论:**通过**
逐条独立复核(不采信实现方「已全绿」)后,V1–V11、S1–S8、件核四项、批性质禁令、变异伴生九条拦截面全部成立;实现报告 §二·十三 实际对照表的每条测试落点均真实存在、真实入口非绕过改动点的下层直调;三条撤销证明(M1/M4/M6)由本审计亲手注入复现出红并还原自证 git status 干净。三处「必看」的旧断言改写均为等价或更强、且已在卡片 §8.2/§一随动表/代拍板单申报,无未申报削弱。**建议合回**。
残留项均为已在报告内申报的过程件与文档措辞张力(反哺点⑦/⑨),属文档批处理面,不构成实现批的验收缺漏。次要行号漂移(±1–5 行)不影响判据可定位性(以 尾缀 grep 为权威落点)。
## 一、环境与跑测方法
- 基线复核:本审计自跑 `PYTHONIOENCODING=utf-8 Godot_v4.7.2 --headless --path . --script res://tests/run_all.gd`,结果 `all green (60 suites)`;对 `FAIL|红色|断言失败` 的 7 处命中逐条判读,全为良性(通过态测试函数名含 "failure_restores"、故意的 corrupt-JSON 解析报错、"[PASS] …R3 fail-fast" 行),**零真实 FAIL**。
- 关键宿主自证读数:`level_table_validate_test` 17、`node_map_templates_test` 21、`adventure_feats_test` 138、`level_shop_economy_test` 181、`breakpoint_resume_test` PASS —— 与实现报告 §二·十三「终版读数」逐一对齐。
- 撤销证明:在指定文件注入变异→跑最窄宿主取宿主自身 FAIL 行→写回原文→`git status`/`git diff HEAD` 自证字节级还原(三次注入后树均干净)。
- 静态核:diff 路径、件核四项、旧断言删除—替换、非空前置、代拍板超清单五件,逐项 grep + 打开文件确认。
- 依赖说明:探针/冒烟包装闸(run_all_gates.sh 的 probe/smoke/balance 段)未由本审计重跑,依 `game/tests/.gates/receipt.json`(记录 commit=`0c5cd428`、域=code、全绿、11 回放确定性对拍 NEED-APPROVAL 经用户裁定零跑)作背景;代码域测试面已由 run_all.gd 独立复现。**MC/11 回放确定性对拍的零跑是红线内正确处置**(卡② 本体零玩法数值;覆盖批数值随卡② 同批、档位须用户发起),非本审计可越权补跑,亦不阻断代码侧验收结论。
## 二、逐需求点审计表(V1–V11)
| # | 审计项 | 判据出处(卡片) | 实际跑了什么/打开确认了什么 | 结果 |
|---|---|---|---|---|
| V1 | 加载校验四检+良构+起关拒绝 | §4 契约1;§8 V1;§8.2 行1 | 打开 `level_table.gd:50 validate_graph`=①入口终点≥1 ②全员可达双向BFS ③终点恒Boss/Boss无出边 ④出边目标depth严格>源;良构=句柄唯一·出边全命中·type五枚举·battle/boss带depth。`level_runner.gd:308` 起关调 `LevelTable.validate_graph` 违规 return false(真实入口 `start_level`/`start_level_external`)。跑 `level_table_validate_test`〔v1〕17 条全绿(正例三形/负例八类/范围反证三/②④互斥实证)。旧六判据文本全仓 grep 零残留(`编号最大`/`纯战斗`/`出度上限` 作判据 0 命中);`validate()` 已缩为行级,`level_shop_economy:427` 保留「行级校验零错误(图合法性移交 validate_graph)」 | 通过 |
| V2 | 多入口首跳(候选=全部入口) | §4 契约5;§8 V2 | 〔v2〕`node_map_templates:64` 「入口停点候选数=参数档入口个数(L3 档 2)」经 `start_level_external(...,defer_entry=true)`→`level_runner:214 _candidates=entries` 执行;第二入口 `:68` 走 `pick_next_node`。**亲手注入 M1**(两处 `_candidates=entries`→`[entries[0]]`)→该断言变红,还原绿 | 通过 |
| V3 | 大岔口分区域兑付+多Boss收束恰一次 | §4 契约5;§8 V3 | 打开 `level_shop_economy:1156 _smoke_multi_terminal_settlement`:真实入口 `sim/level_autoplay.run_level(min/max 两轴)`+`runner.start_level(2,42)`+`pick_next_node`;断言 L2≥2终点Boss、两路各收本路终点Boss互异、区域隔离(A路未到B终点)、`level_finished` count==1 且 result=victory 且 node_id∈Boss集。基线该宿主 181 全绿 | 通过 |
| V4 | 单链退化合法+三关含岔口+L1终点恰一 | §1完成定义;§8 V4 | 〔v4〕`level_shop:340`「L1 终点恰一个(大岔口自L2起)」`_terminal_ids==1 and boss==1`;`:633` L3 入口数=entry_count·终点数=exit_count≥2;含岔口 `:341/442`(出度≥2节点≥1);单链合法由 V1 `level_table_validate:56` 夹具承载。`adventure_feats:165`〔v4〕三关各含≥1奖励节点 | 通过 |
| V5 | 商店编排三性质 | §4 契约4;§8 V5 | 〔v5〕`level_shop:1129/1130/1131` 三性质(终点前驱恰一店/店数=终点数/全路径恰穿一店)×L1–L3×种子42–61 扫描;非空前置 `:1128 graph_total==3×range & term>=3 & path>=3`,逐图空即 `:1101 false`。DFS `_all_entry_terminal_paths:186` 走句柄序。加载校验内无商店位(`level_table_validate:97` 反证)。M2/M3 记录判据名=性质①/②③,与卡片「终点前驱非店」「路径加第二店」对应(底层断言已开文件确认为真判据) | 通过 |
| V6 | 定位信息两样(关卡序号+节点深度,弃编号寻址) | §4 契约2;§8 V6 | `level_runner:500` enc `"node_depth": int(node.get("depth",0))`=depth值非句柄、键名保留;`_node_id:333` 返回 `id` 句柄(双编号空间退役)。〔v6〕`adventure_feats:188` 走真实 `_drive_to_pref→pick_next_node→begin_battle→current_battle().encounter`,判据含 `enc.node_depth==node.depth` **且 `node.id != node.depth`**(防句柄冒充)。**亲手注入 M4**(enc 取 `id`)→该断言红(实测 depth=6 句柄=6),还原绿 | 通过 |
| V7 | 确定性承载(纯函数复现) | §4 契约3;§8 V7 | 〔v7〕`node_map_templates:43` 同档同种子两次 generate 逐位同图/`:45` 换种子即变/`:53` 起关生效图=生成器直取(单一图源)/`:80` 种子42两开局同图;`breakpoint:174/181` 续玩重生成同图+同种子重开局同图。M5 记录判据名「同参数档同种子两次产图逐位同图」对应卡片「生成器注计数器依赖」;底层 `pure_stream→open_stream` 注入确会破坏该断言(判据对象正确) | 通过 |
| V8 | 模板0语义废除+旧断点代差拒绝 | §4 契约3;§8 V8 | `adventure_runner:146` `if int(bp.get("graph_gen_v2",0))!=1: return false`;快照 `:1014 graph_gen_v2:1`。〔v8〕`node_map:88` 续玩受理(标记在档)+`:94` 缺标记键旧断点 `==false`;`breakpoint:161/171/179` P7 标记键+句柄往返+旧断点拒绝。`is_first_adventure_done` 两态(种子42/随机)由 `node_map:106-112` 收束置位承载。**亲手注入 M6**(`if false:`)→`breakpoint:179`〔v8〕「缺生成图代差标记键的旧断点显式拒绝」红,还原绿 | 通过 |
| V9 | 15 张模板清理 | §4 契约6;§8 V9;完成定义6 | 〔v9〕`node_map:101` 三行 level_row 均无 `templates` 键+`breakpoint:159` `not has("template_pick")`。件核四项见第四节。`_resolve_template_row`/`active_template_*` 代码零引用。`level.tres` 仅剩 3 行 `level_index/display_name/rest_heal_pct`。M7 记录判据名「level 表三行均无 templates 键」对应卡片「level.tres 偷留 templates 键」 | 通过 |
| V10 | 反哺点清单六条各有落点 | §6;§8 V10(非行为改动) | 打开 `reports/iter35-卡②-反哺点-2026-10-24.md`:卡片预列①-⑥逐条有文件+节位+改成什么+依据;另实现批新发现⑦-⑩四条一并呈报。V10 义务满足 | 通过 |
| V11 | 生成图覆盖+多终点Boss带互异(承跨表校验④语义) | §0硬交付;§4契约8;§8 V11 | 〔v11〕`level_shop:302-305` 敌侧落带≥请求深度(`_enemy_band_covers:223` 经 `resolve_enemy_rowset` 真实链,禁顶clamp)/板侧精确命中(`node_depth==D`)/多终点带键两两互异;非空前置 `:268/275/302`。〔fv24〕`adventure_feats:130` 同源消费链+`cov_scan>0`。§一 覆盖批两条注入记录(Boss带并单带→互异红;删顶带→落带红)判据名对应卡片伴生条。**跨表校验④整段已从 `config_db_cross_validator.gd` 删除**(`node_lists`/`level_node_dangling`/带clamp/带互异静态代码零残留),两拦截语义由〔v11〕升级到真实生成图,与 §8.2 预声明④一致 | 通过 |
## 三、S1–S8 断言随动核
| # | 核到之处 | 结果 |
|---|---|---|
| S1 | `node_map_templates_test` 整宿主重写,全文件无「模板 0/回滚档/池=5/node_map抽签」文本(grep 0);⑦收束置位改写为「首局恒走固定种子42」 | 通过 |
| S2 | `_level_active_nodes` 三副本(`level_shop:48`/`adventure_feats:895`/`round_flow:343`)均改 `NodeMapGenerator.generate(level_gen_params_row(L),42)`;死件 `_boss_depth` 全仓 .gd 零引用(仅 `验证集清单.md` 快照残留,见第五节) | 通过 |
| S3 | 商店三性质+行进面(到店节点型别=shop且出边恰指终点)换代完成,硬编码 8/9/穿节点8/L1行集退役 | 通过 |
| S4 | 形状写死断言(节点数=10/普通战斗6+Boss恒一/恢复1+奖励1+商店1)改为生成图读数性质(五型合计=节点数/≥battle_floor/Boss数=exit_count/岔口≥1/终点depth=layers),每条带非空前置 `L%d 现行起关图取数非空` | 通过 |
| S5 | 覆盖判据换真实消费链+落带非顶clamp+带互异(见 V11) | 通过 |
| S6 | 确定性四断言(同图/换种子变图/句柄+depth/单一图源/种子42两开局/断点重生成同图/缺templates)落 `node_map_templates` 同宿主,manifest 零新增 | 通过 |
| S7 | `level_autoplay:141/200-202` `level_maps`(值 `关卡#主种子`)替代 `level_templates`;MC 出数面 `adventure_monte_carlo:139` 随动;sim 内 `template_pick` 零残留;route 轴沿句柄升降序 | 通过 |
| S8 | `breakpoint_resume` P7 六条;`pool_case_d`/`feat_vouchers`/`cross_system_flow` first_script 键清退(.gd 零引用);`meta_progression_forge:323` extra 键样例换 `treasure_unlocked`,「入口特有键随 extra 原样并入」判据结构保持(仍验 wide_scout_band+shop_mode==pool) | 通过 |
## 四、件核四项(命令与命中数)
| 项 | 命令 | 命中 | 判读 |
|---|---|---|---|
| 全仓零 `first_script` | `grep -rn first_script game --include=*.gd` | **.gd 0** | 代码面零引用(通过)。`.md` 仅 `game/tests/验证集清单.md` 快照 8 处(不在本 diff、不进任何闸、已申报反哺点⑦待用户重生成/注记)——属过程文档快照,非代码死引用,见第五节例外说明 |
| 全仓零 `template_pick` | `grep -rn template_pick game --include=*.gd` | **.gd 1** | 唯一命中 `breakpoint_resume_test:159 not bp_j.has("template_pick")`=**反向断言**(断言退役键缺席),属例外。其余仅 `验证集清单.md` 快照 3 处 |
| 全仓零 `active_template` | `grep -rn active_template game --include=*.gd` | **.gd 0** | 代码面零引用(通过);快照 2 处同上 |
| `level.tres` 无 `templates` 键 | `grep -n templates game/data/tables/level.tres` | **0 数据键** | 仅 1 处表尾去向注释(第16行「旧15张模板池…清出」),三行均为行级字段无 templates/nodes(V9 达成) |
## 五、测试削弱核查(三处必看 + 全量删除扫描)
对 `git diff master...HEAD` 的**全部删除断言行**(约 60 条 Kit.check)逐条溯源其替换体,未发现「删断言/放宽阈值/缩扫描面/换下层直调/精确改恒真/空扫描判绿」的未申报削弱。三处重点:
1. **奖励三选一断言替代**(`adventure_feats`):旧「L1 链必经≥1 次」→新「图侧每关≥1奖励节点〔v4〕 + `_drive_to_pref("reward")` 真实岔口点选→`node_curio_candidates`+`present_node_curio`呈停+`apply_node_curio`受理」,另有 `:197` 兑付回执==经停逐笔。判据语义从「形状必经」变「接线可用+图上存在」,**已在代拍板①申报**(实测确定性评分 12 次全程恒不进奖励支路,含岔口设计下奖励位可选为既定),替代体仍走真实入口证机制可兑付,未放宽为恒真。判:申报过的合法替代,不红。
2. **`shop_seq.size()` 2→3**(`adventure_feats:325`):随契约4「每关路径恰穿一店⇒三关=3」的必然投影,**代拍板③申报**,与 V5 三性质扫描互为校验(不一致则二者之一必红),非任意放宽。判:不红。
3. **三处 `_level_active_nodes` 换代**:templates 键清出后旧实现落 `row.get("nodes",[])` 返空数组会致覆盖/Boss深度/逐节点扫描断言集体空转绿——本批三副本均改生成图取数,且所有消费断言带非空前置(`cov_scan>0`/`scanned>0`/`graph_total==3×range`/逐图空→显式 `false`)。**代拍板②申报为缺陷清偿**(当场重新暴露一次 `boss_depth` 读 -1 真红并清偿)。判:有效强化,不红。
**其余删除断言**全部落入卡片 §8.2「旧断言预声明」四类(S1–S8随动/V5商店换代/V8收束置位改写/④静态检查退役由S5承接),替换体经开文件确认为等价或更强(模板形状写死→生成图性质扫描、静态代理→真实消费链落带)。
## 六、变异伴生九条拦截面判定(§8.2)
| 卡片条 | 记录/复现 | 判据名是否对应 | 结果 |
|---|---|---|---|
| V1 终点恒Boss判据注释 | §二·五 记录 `终点非 Boss 负例〔v1〕` 红 | 对应(负例夹具命中「终点 1 非 Boss」) | 判定成立 |
| V1 无环判据短路 | §二·五 记录 回边/环两负例红 | 对应(「违反前进方向」) | 判定成立 |
| V11 Boss带并单带 | §一 记录 带互异红 | 对应〔v11〕两两互异 | 判定成立 |
| V11 删顶带 | §一 记录 落带≥深度红 | 对应〔v11〕band_of非-1 | 判定成立 |
| V2 候选写死单入口 | **本审计亲手注入 M1** | 红行原文见第七节 | **已复现** |
| V5 终点前驱非店 | §二·十二 M2 | 对应性质①(已确认 pred 型别判定为真判据) | 判定成立 |
| V5 路径加第二店 | §二·十二 M3 | 对应性质②③(已确认 shop 计数判定为真判据) | 判定成立 |
| V6 enc取句柄 | **本审计亲手注入 M4** | 红行原文见第七节 | **已复现** |
| V7 生成器注计数器依赖 | §二·十二 M5 | 对应〔v7〕同种子逐位同图 | 判定成立 |
| V8 旧断点放行回放 | **本审计亲手注入 M6** | 红行原文见第七节 | **已复现** |
| V9 表留 templates 键 | §二·十二 M7 | 对应〔v9〕三行无 templates(已确认 iter has("templates")) | 判定成立 |
结论:九条中三条由本审计独立注入复现出红,其余六条的实现方记录判据名与卡片要求逐条对应、且底层断言开文件确认为真实判据(非恒真/非空扫描),无「记录名与卡片错配」情形。
## 七、撤销证明复现记录(本审计亲手注入,附红行原文)
- **M1(V2)**:`level_runner.gd` 两处 `_candidates = entries` → `[entries[0]]`(`:214` defer入口 / `:243` 断点入口停点)。跑 `node_map_templates_test`。红行:
`ERROR: [FAIL] 入口停点候选数=参数档入口个数(L3 档 2——多入口首跳即选路,禁写死单入口)〔v2〕`
还原后 `git status` 干净。
- **M4(V6)**:`level_runner.gd:500` enc `"node_depth": int(node.get("depth",0))` → `int(node.get("id",0))`。跑 `adventure_feats_test`。红行:
`ERROR: [FAIL] 进战载荷两样=关卡序号+节点深度(实测 depth=6 句柄=6)〔v6〕`
还原后干净。
- **M6(V8)**:`adventure_runner.gd:146` `if int(bp.get("graph_gen_v2",0))!=1:` → `if false:`。跑 `breakpoint_resume_test`。红行:
`[breakpoint_resume_test] FAIL — P7 缺生成图代差标记键的旧断点显式拒绝(禁按新句柄空间静默回放)〔v8〕`
还原后干净。
三条注入均:注入前确认 `git status` 干净;取被测宿主自身 FAIL 行(非包装层退出码);写回原文后 `git diff HEAD` 为空、`git status` 干净。
## 八、代拍板超清单五件(白名单外)的独立判断
1. 奖励断言替代 → 见第五节①,申报充分、替代体仍证接线,通过。
2. `_level_active_nodes` 三处空转缺陷清偿 → 三副本确认换代、非空前置齐备,清偿真实(当场重暴露 boss_depth=-1 红),通过。
3. 商店到达余额 2→3 → 契约4 必然投影、与 V5 互校,通过。
4. `_boss_depth` 死件随删(两宿主零调用方)→ 全仓 .gd 零引用确认,涉删纯死代码不触断言,通过。
5. 两呈现宿主(`level_map_type_vis`/`level_map_info_fog`)夹具拓扑重建、自述「断言条数与阈值零改动」→ **本审计独立计数核**:19→19、26→26 无缩减;可见集阈值判据仍在(层距 N 可见/N+1·N+2 不可见、候选豁免、Boss 豁免不扩散、句柄32层6入雾),负向回边判据移交 `level_table_validate`〔v1〕,自述属实,通过。
## 九、批性质与残留申报项
- **批性质禁令**:`git diff master...HEAD --name-only` 仅 31 个 `game/` 文件 + 3 份 `reports/` 产出物;grep `design/|AGENTS.md|roadmap.md|待办清单|体验验证清单` = **零命中**。实现批未写 design/ 与根流程清单,合规。
- **申报的非阻塞残留**(属文档批/用户裁决面,不红):
- 反哺点⑦:`game/tests/验证集清单.md` 为批 B 断言文本快照索引,本批未重生成,仍含退役措辞与旧行号,不进任何闸;已诚实列清单待用户裁决重生成或注记。件核四项中的 `.md` 快照命中即源于此,代码面干净,不构成契约6「死代码死断言」违令。
- 反哺点⑨:`level.tres:16` 表尾去向注记含「随迭代 35 卡② 清出」的迭代叙事,与 doc-conventions §5「禁写变更叙事」有张力;但卡片 T3 明令表尾写「15 张模板去向」注记,属卡片授权范围,已申报待裁。
- 实机目检两条(三关选路观感、首局种子42教学图)已登记于实现报告 §三·一,依「真人检测只登记不阻塞」红线,不作合回前置。
- **次要观察**:报告 §二·十三 若干行号与实际有 ±1–5 行漂移(如 v9 标 :91-96 实为 :101、v4 标 :339 实为 :340),但每条 尾缀 均可 grep 定位、断言真实存在,不影响验收。